HashMap多線程擴容導致死循環解析(JDK1.7)

前言

前一篇 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三個節點處在一個鏈表上,擴容後依然處在一個鏈表上,代碼執行過程如下:
JDK1.7-HashMap擴容時鏈表轉移過程
需要注意的幾點是

  • 單鏈表在轉移的過程中會被反轉
  • 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;
        }
    }
}

HashMap擴容死循環

  • 線程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如何解決這個問題。不得不說,畫圖是個很好的分析方式,根據代碼,一步一步把結構圖畫出來,比對着代碼瞎琢磨效果好多了。

以上就是本篇文章的全部內容。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章