HashMap在多執行緒下不安全問題(JDK1.7)
本文分析是基於JDK1.7的HashMap
多執行緒情況下HashMap所帶來的問題
- 多執行緒put操作後,get操作導致死迴圈。
- 多執行緒put操作,導致元素丟失。
死迴圈場景重現
public class HashMapTest extends Thread {
private static HashMap<Integer,Integer> map = new HashMap<>(2);
private static AtomicInteger at = new AtomicInteger();
@Override
public void run() {
while (at.get() < 1000000) {
map.put(at.get(),at.get());
at.incrementAndGet();
}
}
public static void main(String[] args) {
HashMapTest t0 = new HashMapTest();
HashMapTest t1 = new HashMapTest();
HashMapTest t2 = new HashMapTest();
HashMapTest t3 = new HashMapTest();
HashMapTest t4 = new HashMapTest();
HashMapTest t5 = new HashMapTest();
t0.start();
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
for (int i = 0; i < 1000000; i++) {
Integer integer = map.get(i);
System.out.println(integer);
}
}
}
複製程式碼
反覆執行幾次,出現這種情況則表示死迴圈了:
由上可知,Thread-7由於HashMap的擴容導致了死迴圈。
HashMap分析
擴關鍵容原始碼
1 void transfer(Entry[] newTable,boolean rehash) {
2 int newCapacity = newTable.length;
3 for (Entry<K,V> e : table) {
4 while(null != e) {
5 Entry<K,V> next = e.next;
6 if (rehash) {
7 e.hash = null == e.key ? 0 : hash(e.key);
8 }
9 int i = indexFor(e.hash,newCapacity);
10 e.next = newTable[i];
11 newTable[i] = e;
12 e = next;
13 }
14 }
15 }
複製程式碼
正常的擴容過程
我們先來看下單執行緒情況下,正常的rehash過程:
- 假設我們的hash演演算法是簡單的key mod一下表的大小(即陣列的長度)。
- 最上面是old hash表,其中HASH表的size=2,所以key=3,5,7在mod 2 以後都衝突在table[1]這個位置上了。
- 接下來HASH表擴容,resize=4,然後所有的<key,value>重新進行雜湊分佈,過程如下:
在單執行緒情況下,一切看起來都很美妙,擴容過程也相當順利。接下來看下併發情況下的擴容。
併發情況下的擴容
-
有兩個執行緒,分別用紅色和藍色標註了。
-
線上程1執行到第5行程式碼就被CPU排程掛起(執行完了,獲取到next是7),去執行執行緒2,且執行緒2把上面程式碼都執行完畢。我們來看看這個時候的狀態
- 接著CPU切換到執行緒1上來,執行4-12行程式碼(已經執行完了第五行),首先安置健值為3這個Entry:
注意::執行緒二已經完成執行完成,現在table裡面所有的Entry都是最新的,就是說7的next是3,3的next是null;現在第一次迴圈已經結束,3已經安置妥當。
- 看看接下來會發生什麼事情:
- e=next=7;
- e!=null,迴圈繼續
- next=e.next=3
- e.next 7的next指向3
- 放置7這個Entry,現在如圖所示:
- 放置7之後,接著執行程式碼:
- e=next=3;
- 判斷不為空,繼續迴圈
- next= e.next 這裡也就是3的next 為null
- e.next=7,就3的next指向7.
- 放置3這個Entry,此時的狀態如圖
這個時候其實就出現了死迴圈了,3移動節點頭的位置,指向7這個Entry;在這之前7的next同時也指向了3這個Entry。
- 程式碼接著往下執行,e=next=null,此時條件判斷會終止迴圈。這次擴容結束了。但是後續如果有查詢(無論是查詢的迭代還是擴容),都會hang死在table[3]這個位置上。現在回過來看文章開頭的那個Demo,就是掛死在擴容階段的transfer這個方法上面。
出現問題的關鍵原因:如果擴容前相鄰的兩個Entry在擴容後還是分配到相同的table位置上,就會出現死迴圈的BUG。在複雜的生產環境中,這種情況儘管不常見,但是可能會碰到。
多執行緒put操作,導致元素丟失
下面來介紹下元素丟失的問題。這次我們選取3、5、7的順序來演示:
- 如果線上程一執行到第5行程式碼就被CPU排程掛起:
- 執行緒二執行完成:
- 這個時候接著執行執行緒一,首先放置7這個Entry:
- 再放置5這個Entry:
- 由於5的next為null,此時擴容動作結束,導致3這個Entry丟失。
JDK 8 的改進
JDK 8 中採用的是位桶 + 連結串列/紅黑樹的方式,當某個位桶的連結串列的長度超過 8 的時候,這個連結串列就將轉換成紅黑樹
HashMap 不會因為多執行緒 put 導致死迴圈(JDK 8 用 head 和 tail 來保證連結串列的順序和之前一樣;JDK 7 rehash 會倒置連結串列元素),但是還會有資料丟失等弊端(併發本身的問題)。因此多執行緒情況下還是建議使用 ConcurrentHashMap
為什麼執行緒不安全
HashMap 在併發時可能出現的問題主要是兩方面:
-
如果多個執行緒同時使用 put 方法新增元素,而且假設正好存在兩個 put 的 key 發生了碰撞(根據 hash 值計算的 bucket 一樣),那麼根據 HashMap 的實現,這兩個 key 會新增到陣列的同一個位置,這樣最終就會發生其中一個執行緒 put 的資料被覆蓋
-
如果多個執行緒同時檢測到元素個數超過陣列大小 * loadFactor,這樣就會發生多個執行緒同時對 Node 陣列進行擴容,都在重新計算元素位置以及複製資料,但是最終只有一個執行緒擴容後的陣列會賦給 table,也就是說其他執行緒的都會丟失,並且各自執行緒 put 的資料也丟失