1. 程式人生 > 程式設計 >HashMap在多執行緒下不安全問題(JDK1.7)

HashMap在多執行緒下不安全問題(JDK1.7)

本文分析是基於JDK1.7的HashMap

多執行緒情況下HashMap所帶來的問題

  1. 多執行緒put操作後,get操作導致死迴圈。
  2. 多執行緒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過程:

  1. 假設我們的hash演演算法是簡單的key mod一下表的大小(即陣列的長度)。
  2. 最上面是old hash表,其中HASH表的size=2,所以key=3,5,7在mod 2 以後都衝突在table[1]這個位置上了。
  3. 接下來HASH表擴容,resize=4,然後所有的<key,value>重新進行雜湊分佈,過程如下:

在單執行緒情況下,一切看起來都很美妙,擴容過程也相當順利。接下來看下併發情況下的擴容。

併發情況下的擴容

  1. 有兩個執行緒,分別用紅色和藍色標註了。

  2. 線上程1執行到第5行程式碼就被CPU排程掛起(執行完了,獲取到next是7),去執行執行緒2,且執行緒2把上面程式碼都執行完畢。我們來看看這個時候的狀態

  1. 接著CPU切換到執行緒1上來,執行4-12行程式碼(已經執行完了第五行),首先安置健值為3這個Entry:

注意::執行緒二已經完成執行完成,現在table裡面所有的Entry都是最新的,就是說7的next是3,3的next是null;現在第一次迴圈已經結束,3已經安置妥當。

  1. 看看接下來會發生什麼事情:
    • e=next=7;
    • e!=null,迴圈繼續
    • next=e.next=3
    • e.next 7的next指向3
    • 放置7這個Entry,現在如圖所示:

  1. 放置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。

  1. 程式碼接著往下執行,e=next=null,此時條件判斷會終止迴圈。這次擴容結束了。但是後續如果有查詢(無論是查詢的迭代還是擴容),都會hang死在table[3]這個位置上。現在回過來看文章開頭的那個Demo,就是掛死在擴容階段的transfer這個方法上面。

出現問題的關鍵原因:如果擴容前相鄰的兩個Entry在擴容後還是分配到相同的table位置上,就會出現死迴圈的BUG。在複雜的生產環境中,這種情況儘管不常見,但是可能會碰到。

多執行緒put操作,導致元素丟失

下面來介紹下元素丟失的問題。這次我們選取3、5、7的順序來演示:

  1. 如果線上程一執行到第5行程式碼就被CPU排程掛起:

  1. 執行緒二執行完成:

  1. 這個時候接著執行執行緒一,首先放置7這個Entry:

  1. 再放置5這個Entry:

  1. 由於5的next為null,此時擴容動作結束,導致3這個Entry丟失。

JDK 8 的改進

JDK 8 中採用的是位桶 + 連結串列/紅黑樹的方式,當某個位桶的連結串列的長度超過 8 的時候,這個連結串列就將轉換成紅黑樹

HashMap 不會因為多執行緒 put 導致死迴圈(JDK 8 用 head 和 tail 來保證連結串列的順序和之前一樣;JDK 7 rehash 會倒置連結串列元素),但是還會有資料丟失等弊端(併發本身的問題)。因此多執行緒情況下還是建議使用 ConcurrentHashMap

為什麼執行緒不安全

HashMap 在併發時可能出現的問題主要是兩方面:

  1. 如果多個執行緒同時使用 put 方法新增元素,而且假設正好存在兩個 put 的 key 發生了碰撞(根據 hash 值計算的 bucket 一樣),那麼根據 HashMap 的實現,這兩個 key 會新增到陣列的同一個位置,這樣最終就會發生其中一個執行緒 put 的資料被覆蓋

  2. 如果多個執行緒同時檢測到元素個數超過陣列大小 * loadFactor,這樣就會發生多個執行緒同時對 Node 陣列進行擴容,都在重新計算元素位置以及複製資料,但是最終只有一個執行緒擴容後的陣列會賦給 table,也就是說其他執行緒的都會丟失,並且各自執行緒 put 的資料也丟失