一、HashMap底層數據結構
- JDK1.7及之前:數組+鏈表
- JDK1.8:數組+鏈表+紅黑樹
關於HashMap基本的大家都知道,但是為什么數組的長度必須是2的指數次冪,為什么HashMap的加載因子要設置為0.75,為什么鏈表長度大於等於8時轉成了紅黑樹?
HashMap添加元素分析
當添加元素時,會通過哈希值和數組長度計算計算下標來准確定位該元素應該put的位置,通常我們為了使元素時分布均勻會使用取模運算,用一個值去模上總長度,例如:index=hashCode % arr.length
(實際並非這樣,后面講解),計算出index后,就會將該元素添加進去,理想狀態下是將每個值都均勻的添加到數組中,但問題是不可能達到這樣的理想狀態,這時候就會產生哈希沖突,例如:小龍女通過計算添加到了數組3號位置,但是此時楊過這個元素通過計算產生了一個與小龍女相同的索引位置,這是就產生了哈希沖突
此時,就產生了第二種數據結構——鏈表,沖突的元素會在該索引處以鏈表的形式保存
但是當鏈表的長度過長時,其固有弊端就顯現出來了,即查詢效率較低,時間復雜度可能會達到O(n)級別,而數組的查詢時間復雜度僅為O(1)
此時,就引出了第三種數據結構——紅黑樹,紅黑樹是一棵接近於平衡的二叉樹,其查詢時間復雜度為O(logn),遠遠比鏈表的查詢效率高。但如果鏈表長度不到一定的閾值,直接使用紅黑樹代替鏈表也是不行的,因為紅黑樹的自身維護的代價也是比較高的,每插入一個元素都可能打破紅黑樹的平衡性,這就需要每時每刻對紅黑樹再平衡(左旋、右旋、重新着色)
二、為什么數組的長度必須是2的指數次冪
HashMap中數組的初始長度為16,我們創建一個空參的HashMap並點進源碼如下圖,從開發者提供的注釋可以看到,空參的HashMap初始容量是16,,默認加載因子為0.75
此時我們再往HashMap隨機傳入一個參數,例如11
再點開其源碼,發現其是空參方法的一個重載方法,即通過指定的初始值來創建一個HashMap,使用默認的加載因子仍是0.75,其通過this關鍵字調用了本類的另外一個重載方法
找到其調用的重載方法如下,我們可以看到,在方法中將初始容量和加載因子傳入了進去並做了判斷,即如果初始容量小於0,則拋出異常,如果出事容量大於了最大容量,則讓其等於最大容量,同樣對加載因子也做了判斷,最后,設置了加載因子和其源碼中定義的一個閾值,需要注意的是,這里的閾值使用了一個tableSizefor
方法,它的作用是返回一個大於輸入參數且最近的2的整數次冪的數
看一下tableSizefor
方法如下,這里使用的是位運算,假設n的二進01xxx...xxx,接着對n右移1位001xx...xxx,再位或:011xx...xxx;對n右移2為00011...xxx,再位或:01111...xxx,此時前面已經有四個1了,再右移4位且位或可得8個1,同理,有8個1,右移8位肯定會讓后八位也為1。綜上可得,該算法讓最高位的1后面的位全變為1。最后再讓結果n+1,即得到了2的整數次冪的值了。cap-1再賦值給n的目的是讓找到的目標值大於或等於原值
上面已經講了HashMap是怎樣將數組初始容量的長度轉化為2的整數次冪的,那么為什么要把初始容量轉成2的指數次冪呢?不轉成2的指數次冪也是可以存儲的啊,為什么要轉?
- 首先看HashMap的put方法
其中的hash方法用於計算key的哈希值
我們一開始提到過,添加元素時索引的下標可以通過取模運算獲得,但是我們知道計算機的運行效率:加法(減法)>乘法>除法>取模,取模的效率是最低的。所以我們要在HashMap中避免頻繁的取模運算,又因為在我們HashMap中他要通過取模去定位我們的索引,並且HashMap是在不停的擴容,數組一旦達到容量的閾值的時候就需要對數組進行擴容。那么擴容就意味着要進行數組的移動,數組一旦移動,每移動一次就要重回記算索引,這個過程中牽扯大量元素的遷移,就會大大影響效率。那么如果說我們直接使用與運算,這個效率是遠遠高於取模運算的
- 再來看putVal方法,它是實現具體的put操作的方法,來看一下源碼
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//1. 如果當前table為空,新建默認大小的table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2. 獲取當前key對應的節點
if ((p = tab[i = (n - 1) & hash]) == null)
//3. 如果不存在,新建節點
tab[i] = newNode(hash, key, value, null);
else {
//4. 存在節點
Node<K,V> e; K k;
//5. key的hash相同,key的引用相同或者key equals,則覆蓋
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//6. 如果當前節點是一個紅黑樹樹節點,則添加樹節點
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//7. 不是紅黑樹節點,也不是相同節點,則表示為鏈表結構
else {
for (int binCount = 0; ; ++binCount) {
//8. 找到最后那個節點
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//9. 如果鏈表長度超過8轉成紅黑樹
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//10.如果鏈表中有相同的節點,則覆蓋
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
//是否替換掉value值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//記錄修改次數
++modCount;
//是否超過容量,超過需要擴容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
由以上源碼第2步,tab[i = (n - 1) & hash]
中tab就是HashMap的實體數組,其下邊通過i = (n - 1) & hash
來獲取(n表示數組長度,hash表示hashCode值),但是這必須保證數組長度為2的整數次冪,我們繼續往下看
現在我們可以使用與運算(n-1) & hash
取代取模運算hash%length
,因為這兩種方式記算出來的結果是一致的(n就是length),也就是(length-1)&hash = hash%length
,例如:假設數組長度為4,哈希值為10
(n-1) & hash = (4-1) & 10 = 00000011 & 00001010 = 00000010 = 2
hash % length = 10 % 4 = 2
但是當數組的長=長度不為2的指數次冪時,兩種方式計算的結果不一樣,即length-1)&hash ≠ hash&length
例如:再假設數組長度為5,哈希值10
(n-1) & hash = (5-1) & 10 = 00000100 & 00001010 = 00000000 = 0
hash % length = 10 % 5 = 2
顯然,當數組長度不為2的整數次冪時二者是不相等的
但最重要的一點,是要保證定位出來的值是在數組的長度之內的,不能超出數組長度,並且減少哈希碰撞,讓每個位都可能被取到,我們來看下面例子
例如:(16-1) & hash
二進制的15: 0000 0000 0000 1111
hash(隨機) 1101 0111 1011 0000
hash(隨機) 1101 0111 1011 1111
結果 0000 0000 0000 0001 ~ 0000 0000 0000 1111
即得出的索引下標只能在0~15之間,保證了所有索引都在數組長度的范圍內而不會越界,並且由於2的指數次冪-1都是...1111的形式的,即最后一位是1,這樣,由於hash是隨機的,進行與運算后每一位都是能取到的
反例:(7-1) & hash
二進制6: 0000 0000 0000 0110
hash 1011 1001 0101 0000
hash 1001 0001 0000 1111
結果 0000 0000 0000 0000 ~ 0000 0000 0000 0110
即得出的索引范圍在0~6,雖然不會越界,但最后一位是0,即現在無論hash為何值,0001,0011,0101這幾個值是不可能取到的,這就加劇了hash碰撞,並且浪費了大量數組空間,顯然是我們不想看到的
總結:首先使用位運算來加快計算的效率,而要使用位運算,就需要數組-1然后與hash值保證其在數組范圍內,只有當數組長度為2的指數次冪時,其計算得出的值才能和取模算法的值相等,並且保證能取到數組的每一位,減少哈希碰撞,不浪費大量的數組資源
三、為什么加載因子是0.75
加載因子如果定的太大,比如1,這就意味着數組的每個空位都需要填滿,即達到理想狀態,不產生鏈表,但實際是不可能達到這種理想狀態,如果一直等數組填滿才擴容,雖然達到了最大的數組空間利用率,但會產生大量的哈希碰撞,同時產生更多的鏈表,顯然不符合我們的需求。
但如果設置的過小,比如0.5,這樣一來保證了數組空間很充足,減少了哈希碰撞,這種情況下查詢效率很高,但消耗了大量空間。
因此,我們就需要在時間和空間上做一個折中,選擇最合適的負載因子以保證最優化,取到了0.75
四、為什么鏈表長度大於等於8時轉成了紅黑樹
這里要提到一個概率論中的泊松分布,因為鏈表長度大於等於8時轉成紅黑樹正是遵循泊松分布,先來看一下泊松分布
再看一下HashMap源碼中注釋對泊松分布的描述
意思就是HashMap節點分布遵循泊松分布,按照泊松分布的計算公式計算出了鏈表中元素個數和概率的對照表,可以看到鏈表中元素個數為8時的概率已經非常小。
另一方面紅黑樹平均查找長度是log(n),長度為8的時候,平均查找長度為3,如果繼續使用鏈表,平均查找長度為8/2=4,這才有轉換為樹的必要。鏈表長度如果是小於等於6,6/2=3,雖然速度也很快的,但是鏈表和紅黑樹之間的轉換也很耗時。
當然,雖然在hashmap底層有這種紅黑樹的結構,但是我們要知道能產生這種結構的概率也不大,所以我們知道在 JDK1.7 到 JDK1.8 這其中HashMap的性能也只提高了7%~8% 左右