一、什么是哈希表
在討論哈希表之前,我們先大概了解下其他數據結構在新增,查找等基礎操作執行性能
數組:采用一段連續的存儲單元來存儲數據。對于指定下標的查找,時間復雜度為O(1);通過給定值進行查找,需要遍歷數組,逐一比對給定關鍵字和數組元素,時間復雜度為O(n),當然,對于有序數組,則可采用二分查找,插值查找,斐波那契查找等方式,可將查找復雜度提高為O(logn);對于一般的插入刪除操作,涉及到數組元素的移動,其平均復雜度也為O(n)
線性鏈表:對于鏈表的新增,刪除等操作(在找到指定操作位置后),僅需處理結點間的引用即可,時間復雜度為O(1),而查找操作需要遍歷鏈表逐一進行比對,復雜度為O(n)
二叉樹:對一棵相對平衡的有序二叉樹,對其進行插入,查找,刪除等操作,平均復雜度均為O(logn)。
哈希表:相比上述幾種數據結構,在哈希表中進行添加,刪除,查找等操作,性能十分之高,不考慮哈希沖突的情況下,僅需一次定位即可完成,時間復雜度為O(1),接下來我們就來看看哈希表是如何實現達到驚艷的常數階O(1)的。
我們知道,數據結構的物理存儲結構只有兩種:順序存儲結構和鏈式存儲結構(像棧,隊列,樹,圖等是從邏輯結構去抽象的,映射到內存中,也這兩種物理組織形式),而在上面我們提到過,在數組中根據下標查找某個元素,一次定位就可以達到,哈希表利用了這種特性,哈希表的主干就是數組。
比如我們要新增或查找某個元素,我們通過把當前元素的關鍵字 通過某個函數映射到數組中的某個位置,通過數組下標一次定位就可完成操作。
存儲位置 = f(關鍵字)
其中,這個函數f一般稱為哈希函數,這個函數的設計好壞會直接影響到哈希表的優劣。舉個例子,比如我們要在哈希表中執行插入操作:
查找操作同理,先通過哈希函數計算出實際存儲地址,然后從數組中對應地址取出即可。
哈希沖突
如果兩個不同的元素,通過哈希函數得出的實際存儲地址相同怎么辦?也就是說,當我們對某個元素進行哈希運算,得到一個存儲地址,然后要進行插入的時候,發現已經被其他元素占用了,其實這就是所謂的哈希沖突,也叫哈希碰撞。前面我們提到過,哈希函數的設計至關重要,好的哈希函數會盡可能地保證計算簡單和散列地址分布均勻,但是,我們需要清楚的是,數組是一塊連續的固定長度的內存空間,再好的哈希函數也不能保證得到的存儲地址絕對不發生沖突。那么哈希沖突如何解決呢?哈希沖突的解決方案有多種:開放定址法(發生沖突,繼續尋找下一塊未被占用的存儲地址),再散列函數法,鏈地址法,而HashMap即是采用了鏈地址法,也就是數組+鏈表的方式
二、HashMap實現原理
HashMap概述:HashMap是基于哈希表的Map接口的非同步實現。此實現提供所有可選的映射操作,并允許使用null值和null鍵。此類不保證映射的順序,特別是它不保證該順序恒久不變。 在java編程語言中,最基本的結構就是兩種,一個是數組,另外一個是模擬指針(引用),所有的數據結構都可以用這兩個基本結構來構造的,HashMap也不例外。HashMap實際上是一個“鏈表散列”的數據結構,即數組和鏈表的結合體。
HashMap的主干是一個Entry數組。Entry是HashMap的基本組成單元,每一個Entry包含一個key-value鍵值對。
//HashMap的主干數組,可以看到就是一個Entry數組,初始值為空數組{}
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一個靜態內部類。代碼如下:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存儲指向下一個Entry的引用,單鏈表結構
int hash;//對key的hashcode值進行hash運算后得到的值,存儲在Entry,避免重復計算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
可以看出,Entry就是數組中的元素,每個 Map.Entry 其實就是一個key-value對,它持有一個指向下一個元素的引用,這就構成了鏈表。
HashMap的整體結構如下:
總結:HashMap由數組+鏈表組成的,數組是HashMap的主體,鏈表則是主要為了解決哈希沖突而存在的,如果定位到的數組位置不含鏈表(當前entry的next指向null),那么對于查找,添加等操作很快,僅需一次尋址即可;如果定位到的數組包含鏈表,對于添加操作,其時間復雜度為O(n),首先遍歷鏈表,存在即覆蓋,否則新增;對于查找操作來講,仍需遍歷鏈表,然后通過key對象的equals方法逐一比對查找。所以,性能考慮,HashMap中的鏈表出現越少,性能才會越好。
三、HashMap實現存儲和讀取
1、存儲 put()
public V put(K key, V value) {
// HashMap允許存放null鍵和null值。
// 當key為null時,調用putForNullKey方法,將value放置在數組第一個位置。
if (key == null)
return putForNullKey(value);
// 根據key的keyCode重新計算hash值。
int hash = hash(key.hashCode());
// 搜索指定hash值在對應table中的索引。
int i = indexFor(hash, table.length);
// 如果 i 索引處的 Entry 不為 null,通過循環不斷遍歷 e 元素的下一個元素。
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
// 如果發現已有該鍵值,則存儲新的值,并返回原始值
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 如果i索引處的Entry為null,表明此處還沒有Entry。
modCount++;
// 將key、value添加到i索引處。
addEntry(hash, key, value, i);
return null;
}
根據hash值得到這個元素在數組中的位置(即下標),如果數組該位置上已經存放有其他元素了,那么在這個位置上的元素將以鏈表的形式存放,新加入的放在鏈頭,最先加入的放在鏈尾。如果數組該位置上沒有元素,就直接將該元素放到此數組中的該位置上。
hash(int h)方法根據key的hashCode重新計算一次散列。此算法加入了高位計算,防止低位不變,高位變化時,造成的hash沖突。
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
我們可以看到在HashMap中要找到某個元素,需要根據key的hash值來求得對應數組中的位置。如何計算這個位置就是hash算法。前面說過HashMap的數據結構是數組和鏈表的結合,所以我們當然希望這個HashMap里面的元素位置盡量的分布均勻些,盡量使得每個位置上的元素數量只有一個,那么當我們用hash算法求得這個位置的時候,馬上就可以知道對應位置的元素就是我們要的,而不用再去遍歷鏈表,這樣就大大優化了查詢的效率。
根據上面 put 方法的源代碼可以看出,當程序試圖將一個key-value對放入HashMap中時,程序首先根據該 key的 hashCode() 返回值決定該 Entry 的存儲位置:如果兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的存儲位置相同。如果這兩個 Entry 的 key 通過 equals 比較返回 true,新添加 Entry 的 value 將覆蓋集合中原有 Entry的 value,但key不會覆蓋。如果這兩個 Entry 的 key 通過 equals 比較返回 false,新添加的 Entry 將與集合中原有 Entry 形成 Entry 鏈,而且新添加的 Entry 位于 Entry 鏈的頭部——具體說明繼續看 addEntry() 方法的說明。
通過這種方式就可以高效的解決HashMap的沖突問題。
2、讀取get()
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
return e.value;
}
return null;
}
從HashMap中get元素時,首先計算key的hashCode,找到數組中對應位置的某一元素,然后通過key的equals方法在對應位置的鏈表中找到需要的元素。
簡單歸納,HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 對象。HashMap 底層采用一個 Entry[] 數組來保存所有的 key-value 對,當需要存儲一個 Entry 對象時,會根據hash算法來決定其在數組中的存儲位置,在根據equals方法決定其在該數組位置上的鏈表中的存儲位置;當需要取出一個Entry時,也會根據hash算法找到其在數組中的存儲位置,再根據equals方法從該位置上的鏈表中取出該Entry。
3、HashMap擴容
當hashmap中的元素越來越多的時候,碰撞的幾率也就越來越高(因為數組的長度是固定的),所以為了提高查詢的效率,就要對hashmap的數組進行擴容,數組擴容這個操作也會出現在ArrayList中,所以這是一個通用的操作,很多人對它的性能表示過懷疑,不過想想我們的“均攤”原理,就釋然了,而在hashmap數組擴容之后,最消耗性能的點就出現了:原數組中的數據必須重新計算其在新數組中的位置,并放進去,這就是resize。
那么HashMap什么時候進行擴容呢?當HashMap中的元素個數超過數組大小loadFactor時,就會進行數組擴容,loadFactor的默認值為0.75,數組大小為16,那么當HashMap中元素個數超過16*0.75=12的時候,就把數組的大小擴展為2*16=32,即擴大一倍,然后重新計算每個元素在數組中的位置,而這是一個非常消耗性能的操作,所以如果我們已經預知HashMap中元素的個數,那么預設元素的個數能夠有效的提高HashMap的性能。