前言
前一篇 HashMap底層結構與實現原理 遺留了一個問題:JDK1.7中的HashMap在多線程情況下擴容可能會導致死循環。本篇就這個問題進行講解。
擴容死循環
前一篇深入的講解了HashMap1.7擴容的過程,這裏回顧一下在擴容過程中,單鏈表的表現,相關的代碼如下
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
// 外層循環遍歷數組槽(slot)
for (Entry<K,V> e : table) {
// 內層循環遍歷單鏈表
while(null != e) {
// 記錄當前節點的next節點
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
// 找到元素在新數組中的槽(slot)
int i = indexFor(e.hash, newCapacity);
// 用頭插法將元素插入新的數組
e.next = newTable[i];
newTable[i] = e;
// 遍歷下一個節點
e = next;
}
}
}
在單線程情況下,假設A、B、C三個節點處在一個鏈表上,擴容後依然處在一個鏈表上,代碼執行過程如下:
需要注意的幾點是
- 單鏈表在轉移的過程中會被反轉
table
是線程共享的,而newTable
是不共享的- 執行
table = newTable
後,其他線程就可以看到轉移線程轉移後的結果了
理解了單線程下鏈表在擴容時的行爲,再來看多線程的情況就比較容易了
此處感謝評論區
@傷神v
同學的指點,以下多線程擴容圖是修正後的圖。
還是關注transfer
方法這段代碼
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e; // *線程1在這行暫停(尚未執行)
e = next;
}
}
}
- 線程1執行
newTable[i] = e
時暫停(未執行) - 線程2直接擴容完成
- 線程1繼續執行,此時線程1可以看到線程2擴容後的結果
圖中已經畫出了每一行代碼執行後,HashMap
的結構圖,仔細觀察圖中的結構變化,就能理解爲什麼會死循環。
由此,完完整整的解釋了爲什麼多線程情況下,JDK1.7版本的HashMap
擴容有可能出現死循環。
JDK1.8改進
JDK1.8中擴容的方法是resize
,對應的代碼是(HashMap中第715行至第742行):
// 低位鏈表頭節點,尾結點
// 低位鏈表就是擴容前後,所處的槽(slot)的下標不變
// 如果擴容前處於table[n],擴容後還是處於table[n]
Node<K,V> loHead = null, loTail = null;
// 高位鏈表頭節點,尾結點
// 高位鏈表就是擴容後所處槽(slot)的下標 = 原來的下標 + 新容量的一半
// 如果擴容前處於table[n],擴容後處於table[n + newCapacity / 2]
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
// 低位鏈表在擴容後,所處槽的下標不變
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 高位鏈表在擴容後,所處槽的下標 = 原來的下標 + 擴容前的容量(也就是擴容後容量的一半)
newTab[j + oldCap] = hiHead;
}
注意第12行的代碼(e.hash & oldCap) == 0
就可以判斷,當前槽上的鏈表在擴容前和擴容後,所在的槽(slot)下標是否一致。舉個例子:
假如一個key的hash值爲1001 1100
,轉換成十進制就是156,數組長度爲1000
,轉換成十進制就是8。
1001 1100
& 0000 1000
--------------
0000 1000
也就是(e.hash & oldCap) != 0
,很容易計算出,擴容前這個key的下標是4(156 % 8 = 4
),擴容後下標是12(156 % 16 = 12
)即:12 = 4 + 16 / 2
,滿足n = n + newCapacity / 2
,由此可以看出這種計算方式非常巧妙。至於第12行之後的代碼就是基本的單鏈表操作了,只是一個單鏈表同時具有頭指針和尾指針,等到鏈表被分成高位鏈表和低位鏈表後,再一次性轉移到新的table
。這樣就完成了單鏈表在擴容過程中的轉移,使用兩條鏈表的好處就是轉移前後的鏈表不會倒置,更不會因爲多線程擴容而導致死循環。
總結
本篇主要通過圖解的方式,解釋了爲什麼JDK1.7中的HashMap在多線程情況下擴容可能死循環,也解釋了JDK1.8如何解決這個問題。不得不說,畫圖是個很好的分析方式,根據代碼,一步一步把結構圖畫出來,比對着代碼瞎琢磨效果好多了。
以上就是本篇文章的全部內容。