Java基礎:HashMap假死鎖問題的測試、分析和總結


前言

  前兩天在公司的內部博客看到一個同事分享的線上服務掛掉CPU100%的文章,讓我聯想到HashMap在不恰當使用情況下的死循環問題,這里做個整理和總結,也順便復習下HashMap。

直接上測試代碼

  由於機器配置和性能不同,測試出效果的線程數和put數量也各不相同

public class HashMapInfiniteLoopTest {
    /**
     * 基於JDK1.7測試HashMap在多線程環境下假死鎖的情況
     * JDK1.8的HashMap實現跟1.7比較已經有很大的變化,已不存在這樣的問題
     * (其實這本來不是JDK的一個問題,HashMap本就不是線程安全的,多線程環境下共享一定要用線程安全的Map容器)
     */
    public static void main(String[] args) {
        String jdkVer = System.getProperty("java.version"); //JDK版本
        String jdkMod = System.getProperty("sun.arch.data.model"); //32位還是64位
        System.out.println(jdkVer +"#"+ jdkMod);

        final Map<String, String> map = new HashMap<>();
//        final Map<String, String> map = new ConcurrentHashMap<>();
        for(int i=0; i<30; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName());
                    for(int j=0; j<1000; j++) {
                        map.put(""+j+"_"+System.currentTimeMillis(), ""+j+"_"+System.currentTimeMillis());
                    }
                }
            }, "myThread_"+i).start();
        }
    }
}

  通過jconsole查看Java進程情況:

  最后只能強制結束進程

 

分析

  HashMap使用hash表來作為其底層存儲的數據結構(數組下標實現快速索引,鏈表實現元素碰撞處理),並且支持動態擴容,主要通過resize方法實現,也是從這個方法開始出問題的。(這里有兩個面試官喜歡問的點:1.table的默認長度以及擴容前后大小?2.為什么要求table的長度必須是2的N次方?)

  因為整個HashMap都不是線程安全的,所以JDK也未對resize方法做同步,如果錯誤的在多線程環境下共享訪問了HashMap就有可能引起我前面提到的假死鎖問題。動態擴容的時候需要把舊的鏈表遷移到新的hash表中,如果是在多線程環境下,可能會形成循環鏈表,在再次put遍歷每個鏈表檢查是否存在相同key時,死循環就出現了(如果是get也會有同樣的情況)。

下面是我整理轉載自https://coolshell.cn/articles/9606.html的部分內容(寫得太好了):

1
2
3
4
5
6
7
8
9
10
11
12
void resize( int newCapacity)
{
     Entry[] oldTable = table;
     int oldCapacity = oldTable.length;
     ......
     //創建一個新的Hash Table
     Entry[] newTable = new Entry[newCapacity];
     //將Old Hash Table上的數據遷移到New Hash Table上
     transfer(newTable);
     table = newTable;
     threshold = ( int )(newCapacity * loadFactor);
}

遷移的源代碼,注意高亮處:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void transfer(Entry[] newTable)
{
     Entry[] src = table;
     int newCapacity = newTable.length;
     //下面這段代碼的意思是:
     //  從OldTable里摘一個元素出來,然后放到NewTable中
     for ( int j = 0 ; j < src.length; j++) {
         Entry<K,V> e = src[j];
         if (e != null ) {
             src[j] = null ;
             do {
                 Entry<K,V> next = e.next;
                 int i = indexFor(e.hash, newCapacity);
                 e.next = newTable[i];
                 newTable[i] = e;
                 e = next;
             } while (e != null );
         }
     }
}
  • 假設我們的hash算法就是簡單的用key mod 一下表的大小(也就是數組的長度)。
  • 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都沖突在table[1]這里了。
  • 接下來的三個步驟是Hash表 resize成4,然后所有的<key,value> 重新rehash的過程

並發下的Rehash

1)假設我們有兩個線程。我用紅色和淺藍色標注了一下。

我們再回頭看一下我們的 transfer代碼中的這個細節:

1
2
3
4
5
6
7
do {
     Entry<K,V> next = e.next; // <--假設線程一執行到這里就被調度掛起了
     int i = indexFor(e.hash, newCapacity);
     e.next = newTable[i];
     newTable[i] = e;
     e = next;
} while (e != null );

而我們的線程二執行完成了。於是我們有下面的這個樣子。

注意,因為Thread1的 e 指向了key(3),而next指向了key(7),其在線程二rehash后,指向了線程二重組后的鏈表。我們可以看到鏈表的順序被反轉后。

2)線程一被調度回來執行。

  • 先是執行 newTalbe[i] = e;
  • 然后是e = next,導致了e指向了key(7),
  • 而下一次循環的next = e.next導致了next指向了key(3)

3)一切安好。

線程一接着工作。把key(7)摘下來,放到newTable[i]的第一個,然后把e和next往下移。

4)環形鏈接出現。

e.next = newTable[i] 導致  key(3).next 指向了 key(7)

注意:此時的key(7).next 已經指向了key(3), 環形鏈表就這樣出現了。

於是,當我們的線程一調用到,HashTable.get(7)時,悲劇就出現了——Infinite Loop。

總結

  多線程並發環境下訪問共享的map時一定要用線程安全的Map容器,如ConcurrentHashMap,HashTable等。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM