面試中HashMap連結串列成環的問題你答出了嗎

程式設計師清辭發表於2020-08-18

        HashMap作為老生常談的問題,備受面試官的青睞,甚至成為了面試必問的問題。由於大量的針對HashMap的解析橫空出世,面試官對HashMap的要求越來越高,就像面試官對JVM掌握要求越來越高一樣,今天我們來研究下HashMap的連結串列環化的問題,你知道其中的原理嘛?關注公眾號“程式設計師清辭”,獲取更多內容

        在JDK1.7版本下,有個執行緒安全的問題,經常會被問到,很多求職者可能還在對比Hashtable執行緒安全性,其實面試官想得到的連結串列成環造成執行緒安全的問題,而這個問題在JDK1.8中已經得到了解決,但至於出現這樣問題的原因,我翻看了很多帖子,大家剖析的很透徹,但是很難理解,今天結合自己的研究利用一篇帖子來闡述其中的奧祕。

JDK1.7擴容原始碼解析

首先我來了解下HashMap中經典的擴容程式碼,回顧下擴容的過程

public class HashMap<K,V> extends AbstractMap<K,V> 
  implements Map<K,V>, Cloneable, Serializable { 
    
    //......
    
    // 擴容方法
    void resize(int newCapacity) {
        // 1、建立臨時變數,將HashMap陣列資料賦值給新陣列作臨時儲存
        Entry[] oldTable = table;
        // 2、判斷老陣列長度是否超過了允許的最大長度,最大長度為 1 << 30
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        // 3、建立新的Entry陣列,並擴容
        Entry[] newTable = new Entry[newCapacity];
        // 4、擴容賦值,即將老陣列中的資料賦值到新陣列中
        // initHashSeedAsNeeded(newCapacity) 得到的是一個hash的隨機值(雜湊種子),
       //在計算雜湊碼時會用到這個種子,作用是減少雜湊碰撞
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        // 6、擴容後賦值
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
    
    // newTable : 表示新陣列,即擴容後建立的新陣列
    // rehash : 是否需要重新計算雜湊值
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        // 5、將老map中的資料賦值到新map中(陣列和連結串列複製遷移)
        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);
                }
                // 計算Entry元素在Entry[]陣列中的位置
                int i = indexFor(e.hash, newCapacity);
​
                // 連結串列頭插法賦值過程
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
    
    //......
    
}
  1. 建立臨時變數,將HashMap陣列資料賦值給新陣列作臨時儲存
  2. 判斷老陣列長度是否超過了允許的最大長度,最大長度為 1 << 30
  3. 建立新的Entry陣列,並擴容
  4. 擴容賦值,即將老陣列中的資料賦值到新陣列中
  5. 將老map中的資料賦值到新map中(陣列和連結串列複製遷移)
  6. 擴容後賦值

連結串列遷移過程

以下三行程式碼描述了連結串列頭插的整個過程,下面來剖析下這個過程:

e.next = newTable[i];
newTable[i] = e;
e = next;

關注公眾號“程式設計師清辭”,獲取更多內容

假設HashMap的儲存狀態如下:

面試中HashMap連結串列成環的問題你答出了嗎

關注公眾號“程式設計師清辭”,獲取更多內容

e為陣列位置的元素,e1、e2為e下形成的連結串列,h為將要賦值的位置,箭頭代表連結串列指向

e.next = newTable[i]

對oldTable進行遍歷的過程中,取出元素e,假設先取出圖中的元素e,在執行這行程式碼時,相當於斷開x位置e與e1的連結串列關係,並與newTable[i]建立連結串列關係,此時newTable[i]位置為null

面試中HashMap連結串列成環的問題你答出了嗎

 

newTable[i] = e

此時將oldTable中的e複製到newTable中的i位置,同時連結串列e指向null

面試中HashMap連結串列成環的問題你答出了嗎

 

問題:那oldTable中e1和e2形成的連結串列怎麼辦?

其實在之前的程式碼中已經闡述了,詳情如下:

while(null != e) {

    // 這裡已經將e.next儲存為一個臨時變數,也就是e1和e2形成的連結串列
    Entry<K,V> next = e.next;
    if (rehash) {
        e.hash = null == e.key ? 0 :hash(e.key);
    }
    ......
}       

e = next;

將next的值賦值給e,這行程式碼對上述的連結串列沒有實質的影響,並且這已經是while迴圈的最後一行程式碼了,這行程式碼的目的是為下一次while遍歷過程能從e1元素開始,而不是e,因為此時需要的遍歷的e已經變成了e1。

通過這次資料遷移可能沒有得到比較有參考意義的分析,所以我們需要再進行一次遍歷分析,而這次遍歷分析從e1開始。這裡就不詳細闡述,直接上圖。

e.next = newTable[i]

面試中HashMap連結串列成環的問題你答出了嗎

 

newTable[i] = e

面試中HashMap連結串列成環的問題你答出了嗎

 

最終效果

面試中HashMap連結串列成環的問題你答出了嗎

 

以上就是整個資料遷移的過程,通過連結串列例項大家發現HashMap利用頭插法完成遷移的過程,下面進入重點,連結串列成環

併發操作連結串列成環

產生基本條件

  1. 多執行緒環境併發操作
  2. HashMap擴容時候發生

問題解析

在多執行緒環境下,a,b兩個執行緒同時操作這個HashMap,由於HashMap是執行緒不安全的,假如執行緒a已經完成以上全過程,也就是下圖

面試中HashMap連結串列成環的問題你答出了嗎

 

程式碼執行到如下位置,還沒有完全的出棧

面試中HashMap連結串列成環的問題你答出了嗎

 

此時執行緒b同時也在遍歷這條連結串列,同時程式碼執行到while迴圈位置

面試中HashMap連結串列成環的問題你答出了嗎

關注公眾號“程式設計師清辭”,獲取更多內容

這時執行緒b已經重新獲取e資料時,由於a執行緒的操作還沒有將資料同步到主記憶體,導致出現如下情況:

面試中HashMap連結串列成環的問題你答出了嗎

 

問題總結

  1. 插入的時候和平時我們追加到尾部的思路是不一致的,是連結串列的頭結點開始迴圈插入,導致插入的順序和原來連結串列的順序相反的。
  2. table 是共享的,table 裡面的元素也是共享的,while 迴圈都直接修改 table 裡面的元素的 next 指向,導致指向混亂。

相關文章