圖解HashMap(二)

HuYounger發表於2017-12-07

概述

上篇分析了HashMap的設計思想以及Java7和Java8原始碼上的實現,當然還有一些"坑"還沒填完,比如大家都知道HashMap是執行緒不安全的資料結構,多執行緒情況下HashMap會引起死迴圈引用,它是怎麼產生的?Java8引入了紅黑樹,那是怎麼提高效率的?本篇先填第一個坑,還是以圖解的形式加深理解。

Java7分析

通過上一篇的整體學習,可以知道當存入的鍵值對超過HashMap的閥值時,HashMap會擴容,即建立一個新的陣列,並將原陣列裡的鍵值對"轉移"到新的陣列中。在“轉移”的時候,會根據新的陣列長度和要轉移的鍵值對key值重新計算在新陣列中的位置。重溫下Java7中負責"轉移"功能的程式碼

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;
            e = next;
        }
    }
}
複製程式碼

為了加深理解,畫個圖如下

圖解HashMap(二)

這裡假設擴容前後5號坑石頭、蓋倫、蒙多的hash值與新舊陣列長度取模運算後還是5。上篇文章也總結了,Java7擴容轉移前後連結串列順序會倒置。當只有單執行緒操作hashMap時,一切都是那麼美好,但是如果多執行緒同時操作一個hashMap,問題就來了,下面看下多執行緒操作一個hashMap在Java7原始碼下是怎樣引起死迴圈引用。

前戲是這樣的:有兩個執行緒分別叫Thread1和Thread2,它們都有操作同一個hashMap的權利,假設hashMap中的鍵值對是12個,石頭和蓋倫擴容前後的hash值與新舊陣列長度取模運算後還是5。擴容前的模擬堆記憶體情況如圖

圖解HashMap(二)

Thread1得到執行權(Thread2被掛起),Thread1往hashMap裡put第13個鍵值對的時候判斷超過閥值,執行擴容操作,Thread1建立了一個新陣列,還沒來得及轉移舊鍵值對的時候,系統時間片反手切到Thread2(Thread1被掛起),整個過程用圖表示

圖解HashMap(二)

可以看到Thread1只是建立了個新陣列,還沒來得及轉移就被掛起了,新陣列沒有內容,此時在Thread1的視角認的是e是石頭,next是蓋倫;此時的模擬記憶體圖情況

圖解HashMap(二)

再看下Thread2的操作,同樣Thread2往hashMap裡put第13個鍵值對的時候判斷超過閥值,執行擴容操作,Thread2先建立一個新陣列,不同的是,Thread2運氣好,在時間片輪換前轉移工作也走完了。第一次遍歷

圖解HashMap(二)

第二次遍歷

圖解HashMap(二)

此時模擬的記憶體情況

圖解HashMap(二)

可以看到此時對於蓋倫來說,他的next是石頭;對於石頭來說,它的next為null,隱患就此埋下。接下來時間片又切到Thread1(停了半天終於輪到我出場了),先看下Thread1的處境

圖解HashMap(二)

結合程式碼分析如下

第一步:

圖解HashMap(二)

第二步:

圖解HashMap(二)

第三步:

圖解HashMap(二)

第四步:

圖解HashMap(二)

第五步:

圖解HashMap(二)

第六步:

圖解HashMap(二)

第七步:

圖解HashMap(二)

第八步:

圖解HashMap(二)

第九步:

圖解HashMap(二)

第10步:

圖解HashMap(二)

到這終於看到蓋倫和石頭"互指",水乳交融。

圖解HashMap(二)

那這會帶來什麼後果呢?後續操作新陣列的5號坑會進入死迴圈(注意,操作其他坑並不會有問題),例如Java7 put操作

圖解HashMap(二)

Java7 get操作會執行getEntry,同樣會引起死迴圈。

圖解HashMap(二)

到此,Java7多執行緒操作HashMap可能形成死迴圈的原因剖析完成。

Java8分析

通過上一篇的學習可知,Java7轉移前後位置顛倒,而Java8轉移鍵值對前後位置不變。同樣的前戲,看下程式碼

圖解HashMap(二)

此時模擬堆記憶體情況

圖解HashMap(二)

Thread1的情況

圖解HashMap(二)

這時候Thread2獲得執行權,擴容並完成轉移工作,通過上篇的學習可知,Java8在轉移前會建立兩條連結串列,即擴容後位置不加原陣列長度的lo鏈和要加原陣列長度的hi鏈,這裡假設石頭和蓋倫擴容前後都在5號坑,即這是一條lo鏈(其實就算不在同一個坑也不影響,原因就是Java8擴容前後鏈順序不變)。Thread2遍歷第一次圖解HashMap(二)

第二次

圖解HashMap(二)

可以看到Thread2全程是沒有去修改石頭和蓋倫的引用關係,石頭.next是蓋倫,蓋倫.next是null。那麼Thread1得到執行權後其實只是重複了Thread2的工作。

總結

通過原始碼分析,Java7在多執行緒操作hashmap時可能引起死迴圈,原因是擴容轉移後前後連結串列順序倒置,在轉移過程中修改了原來連結串列中節點的引用關係;Java8在同樣的前提下並不會引起死迴圈,原因是擴容轉移後前後連結串列順序不變,保持之前節點的引用關係。那是不是意味著Java8就可以把HashMap用在多執行緒中呢?個人感覺即使不會出現死迴圈,但是通過原始碼看到put/get方法都沒有加同步鎖,多執行緒情況最容易出現的就是:無法保證上一秒put的值,下一秒get的時候還是原值,建議使用ConcurrentHashMap。

感謝

講HashMap多執行緒死迴圈最詳細的外國小哥

相關文章