1. 程式人生 > >高併發環境下,HashMap可能出現的致命問題。注意:是在jdk8以下版本

高併發環境下,HashMap可能出現的致命問題。注意:是在jdk8以下版本

原文地址:https://blog.csdn.net/dgutliangxuan/article/details/78779448

概念1:Rehash的概念? 

Rehash 是HashMap在擴容時候的一個步驟。

HashMap的容量是有限的。當經過多次元素插入,使得HashMap達到一定飽和度時,Key對映位置發生衝突的機率會逐漸提高。

這時候,HashMap需要擴充套件它的長度,也就是進行Resize

這裡寫圖片描述

影響發生Resize的因素有兩個: 
1.Capacity(HashMap的當前長度–容量) 
HashMap的當前長度。上一期曾經說過,HashMap的長度是2的冪。

2.LoadFactor(負載因子) 
HashMap負載因子,預設值為0.75f

衡量HashMap是否進行Resize的條件如下: 
HashMap.Size >= Capacity * LoadFactor (預設情況下是 == 原來長度 * 0.75)

HashMap的Resize方法具體做了什麼事情?

1.擴容 
建立一個新的Entry空陣列,長度是原陣列的2倍。

2.ReHash 
遍歷原Entry陣列,把所有的Entry重新Hash到新陣列。為什麼要重新Hash呢?因為長度擴大以後,Hash的規則也隨之改變。

讓我們回顧一下Hash公式: 
index = HashCode(Key) & (Length - 1)

當原陣列長度為8時,Hash運算是和111B(代表二進位制的7)做與運算;新陣列長度為16,Hash運算是和1111B(代表二進位制的15)做與運算。Hash結果顯然不同

這裡寫圖片描述

這裡寫圖片描述

ReHash的Java程式碼如下:

/**
 * Transfers all entries from current table to newTable.
 */
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在多執行緒下的Rehash可能會出現什麼樣的問題呢?

假設一個HashMap已經到了Resize的臨界點。此時有兩個執行緒A和B,在同一時刻對HashMap進行Put操作: 
這裡寫圖片描述 
此時達到Resize條件,兩個執行緒各自進行Rezie的第一步,也就是擴容:

這時候,兩個執行緒都走到了ReHash的步驟。讓我們回顧一下ReHash的程式碼: 
這裡寫圖片描述

假如此時執行緒B遍歷到Entry3物件,剛執行完紅框裡的這行程式碼,執行緒就被掛起。 
於執行緒B來說 
: e = Entry3 next =Entry2 
這時候執行緒A暢通無阻地進行著Rehash,當ReHash完成後,結果如下(圖中的e和next,代表執行緒B的兩個引用): 
這裡寫圖片描述

直到這一步,看起來沒什麼毛病。接下來執行緒B恢復,繼續執行屬於它自己的ReHash。 
執行緒B剛才的狀態是:e = Entry3 next = Entry2 
這裡寫圖片描述 
當執行到上面這一行時,顯然 i = 3,因為剛才執行緒A對於Entry3的hash結果也是3。 
這裡寫圖片描述

我們繼續執行到這兩行,Entry3放入了執行緒B的陣列下標為3的位置,並且e指向了Entry2。 
此時e和next的指向如下: 
e =Entry2 next = Entry2
整體情況如下圖所示: 
這裡寫圖片描述 
接著是新一輪迴圈,又執行到紅框內的程式碼行: 
這裡寫圖片描述 
e = Entry2

next = Entry3

注:網上寫1.7版本HashMap併發產生環形連結串列問題都沒有考慮記憶體可見性,如果多執行緒情況下記憶體不可見,自然也不會有環形連結串列問題。 HashMap中沒有定義volatile變數,無法保證多執行緒情況下記憶體可見性,所以正常來說A執行緒看到的就是自己本地記憶體中的連結串列:3->7而不是執行緒B中的連結串列7->3。這裡是因為迴圈過程中在某些情況下呼叫了hash方法,hash方法呼叫了sun.misc.Hashing.stringHash32方法,強迫執行緒A讀取主存,從而保持了和執行緒B的一致。後面才會有環形連結串列的故事。所以環形連結串列發生的根本原因是記憶體可見造成的。(JDK8中對HashMap作出調整,在尾節點新增元素。1.7及以前都是頭結點新增元素。

整體情況如圖所示: 
這裡寫圖片描述 
接下來執行下面的三行,用頭插法把Entry2插入到了執行緒B的陣列的頭結點: 
這裡寫圖片描述 
整體情況如圖所示: 
這裡寫圖片描述 
第三次迴圈開始,又執行到紅框的程式碼: 
這裡寫圖片描述 
e = Entry3 
next = Entry3.next = null

最後一步,當我們執行下面這一行的時候,見證奇蹟的時刻來臨了 
這裡寫圖片描述

newTable[i] = Entry2 
e = Entry3 
Entry2.next = Entry3 
Entry3.next = Entry2 
連結串列出現了環形! 
整體情況如圖所示: 
這裡寫圖片描述 
此時,問題還沒有直接產生。當呼叫Get查詢一個不存在的Key, 
而這個Key的Hash結果恰好等於3的時候,由於位置3帶有環形連結串列,所以程式將會進入死迴圈!

這種情況,不禁讓人聯想到一道經典的面試題: 
漫畫演算法:如何判斷連結串列有環? 
如何杜絕這種情況? 
這裡寫圖片描述

總結如下

1.Hashmap在插入元素過多的時候需要進行Resize, 
Resize的條件是 HashMap.Size >= Capacity * LoadFactor。

2.Hashmap的Resize包含擴容和ReHash兩個步驟,ReHash在併發的情況下可能會形成連結串列環

3.Hashmap在併發情況下還會造成元素丟失(這與記憶體可見性有關,當e.next==null的時候就會結束迴圈)

4.Hashmap在併發情況下還會造成size不準確(因為在判斷是否需要擴容之前會做size++,其實這個時候size實際可能只是增加了1,現在確增加了2)

連結串列頭插法的會顛倒原來一個雜湊桶裡面連結串列的順序。在併發的時候原來的順序被另外一個執行緒a顛倒了,而被掛起執行緒b恢復後拿擴容前的節點和順序繼續完成第一次迴圈後,又遵循a執行緒擴容後的連結串列順序重新排列連結串列中的順序,最終形成了環。