29-HashMap 為什麼是執行緒不安全的?

敖奕_Nuage發表於2020-12-26

HashMap 是我們平時工作和學習中用得非常非常多的一個容器,也是 Map 最主要的實現類之一,但是它自身並不具備執行緒安全的特點,可以從多種情況中體現出來,下面我們就對此進行具體的分析。

原始碼分析

第一步,我們來看一下 HashMap 中 put 方法的原始碼:

public V put(K key, V value) {
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    } 
 
    //modCount++ 是一個複合操作
    modCount++;
 
    addEntry(hash, key, value, i);
    return null;
}

在 HashMap 的 put() 方法中,可以看出裡面進行了很多操作,那麼在這裡,我們把目光聚焦到標記出來的 modCount++ 這一行程式碼中,相信有經驗的小夥伴一定發現了,這相當於是典型的“i++”操作,正是我們在 06 課時講過的執行緒不安全的“執行結果錯誤”的情況。從表面上看 i++ 只是一行程式碼,但實際上它並不是一個原子操作,它的執行步驟主要分為三步,而且在每步操作之間都有可能被打斷。

  • 第一個步驟是讀取;
  • 第二個步驟是增加;
  • 第三個步驟是儲存。

那麼我們接下來具體看一下如何發生的執行緒不安全問題。
在這裡插入圖片描述
我們根據箭頭指向依次看,假設執行緒 1 首先拿到 i=1 的結果,然後進行 i+1 操作,但此時 i+1 的結果並沒有儲存下來,執行緒 1 就被切換走了,於是 CPU 開始執行執行緒 2,它所做的事情和執行緒 1 是一樣的 i++ 操作,但此時我們想一下,它拿到的 i 是多少?實際上和執行緒 1 拿到的 i 的結果一樣都是 1,為什麼呢?因為執行緒 1 雖然對 i 進行了 +1 操作,但結果沒有儲存,所以執行緒 2 看不到修改後的結果。

然後假設等執行緒 2 對 i 進行 +1 操作後,又切換到執行緒 1,讓執行緒 1 完成未完成的操作,即將 i + 1 的結果 2 儲存下來,然後又切換到執行緒 2 完成 i = 2 的儲存操作,雖然兩個執行緒都執行了對 i 進行 +1 的操作,但結果卻最終儲存了 i = 2 的結果,而不是我們期望的 i = 3,這樣就發生了執行緒安全問題,導致了資料結果錯誤,這也是最典型的執行緒安全問題。

所以,從原始碼的角度,或者說從理論上來講,這完全足以證明 HashMap 是執行緒非安全的了。因為如果有多個執行緒同時呼叫 put() 方法的話,它很有可能會把 modCount 的值計算錯(上述的原始碼分析針對的是 Java 7 版本的原始碼,而在 Java 8 版本的 HashMap 的 put 方法中會呼叫 putVal 方法,裡面同樣有 ++modCount 語句,所以原理是一樣的)。

實驗:擴容期間取出的值不準確

剛才我們分析了原始碼,你可能覺得不過癮,下面我們就開啟程式碼編輯器,用一個實驗來證明 HashMap 是執行緒不安全的。

為什麼說 HashMap 不是執行緒安全的呢?我們先來講解下原理。HashMap 本身預設的容量不是很大,如果不停地往 map 中新增新的資料,它便會在合適的時機進行擴容。而在擴容期間,它會新建一個新的空陣列,並且用舊的項填充到這個新的陣列中去。那麼,在這個填充的過程中,如果有執行緒獲取值,很可能會取到 null 值,而不是我們所希望的、原來新增的值。所以我們程式就想演示這種情景,我們來看一下這段程式碼:

public class HashMapNotSafe {
 
    public static void main(String[] args) {
        final Map<Integer, String> map = new HashMap<>();
 
        final Integer targetKey = 0b1111_1111_1111_1111; // 65 535
        final String targetValue = "v";
        map.put(targetKey, targetValue);
 
        new Thread(() -> {
            IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
        }).start();
 
        while (true) {
            if (null == map.get(targetKey)) {
                throw new RuntimeException("HashMap is not thread safe.");
            }
        }
    }
}

程式碼中首先建立了一個 HashMap,並且定義了 key 和 value, key 的值是一個二進位制的 1111_1111_1111_1111,對應的十進位制是 65535。之所以選取這樣的值,就是為了讓它在擴容往回填充資料的時候,儘量不要填充得太快,比便於我們能捕捉到錯誤的發生。而對應的 value 是無所謂的,我們隨意選取了一個非 null 的 “v” 來表示它,並且把這個值放到了 map 中。

接下來,我們就用一個新的執行緒不停地往我們的 map 中去填入新的資料,我們先來看是怎麼填入的。首先它用了一個 IntStream,這個 range 是從 0 到之前所講過的 65535,這個 range 是一個左閉右開的區間,所以會從 0、1、2、3……一直往上加,並且每一次加的時候,這個 0、1、2、3、4 都會作為 key 被放到 map 中去。而它的 value 是統一的,都是 “someValue”,因為 value 不是我們所關心的。

然後,我們就會把這個執行緒啟動起來,隨後就進入一個 while 迴圈,這個 while 迴圈是關鍵,在 while 迴圈中我們會不停地檢測之前放入的 key 所對應的 value 還是不是我們所期望的字串 “v”。我們在 while 迴圈中會不停地從 map 中取 key 對應的值。如果 HashMap 是執行緒安全的,那麼無論怎樣它所取到的值都應該是我們最開始放入的字串 “v”,可是如果取出來是一個 null,就會滿足這個 if 條件並且隨即丟擲一個異常,因為如果取出 null 就證明它所取出來的值和我們一開始放入的值是不一致的,也就證明了它是執行緒不安全的,所以在此我們要丟擲一個 RuntimeException 提示我們。

下面就讓我們執行這個程式來看一看是否會丟擲這個異常。一旦丟擲就代表它是執行緒不安全的,這段程式碼的執行結果:

Exception in thread "main" java.lang.RuntimeException: HashMap is not thread safe.
at lesson29.HashMapNotSafe.main(HashMapNotSafe.java:25)

很明顯,很快這個程式就丟擲了我們所希望看到的 RuntimeException,並且我們把它描述為:HashMap is not thread safe,一旦它能進入到這個 if 語句,就已經證明它所取出來的值是 null,而不是我們期望的字串 “v”。

通過以上這個例子,我們也證明了HashMap 是執行緒非安全的。

除了剛才的例子之外,還有很多種執行緒不安全的情況,例如:

同時 put 碰撞導致資料丟失

比如,有多個執行緒同時使用 put 來新增元素,而且恰好兩個 put 的 key 是一樣的,它們發生了碰撞,也就是根據 hash 值計算出來的 bucket 位置一樣,並且兩個執行緒又同時判斷該位置是空的,可以寫入,所以這兩個執行緒的兩個不同的 value 便會新增到陣列的同一個位置,這樣最終就只會保留一個資料,丟失一個資料。

可見性問題無法保證

我們再從可見性的角度去考慮一下。可見性也是執行緒安全的一部分,如果某一個資料結構聲稱自己是執行緒安全的,那麼它同樣需要保證可見性,也就是說,當一個執行緒操作這個容器的時候,該操作需要對另外的執行緒都可見,也就是其他執行緒都能感知到本次操作。可是 HashMap 對此是做不到的,如果執行緒 1 給某個 key 放入了一個新值,那麼執行緒 2 在獲取對應的 key 的值的時候,它的可見性是無法保證的,也就是說執行緒 2 可能可以看到這一次的更改,但也有可能看不到。所以從可見性的角度出發,HashMap 同樣是執行緒非安全的。

死迴圈造成 CPU 100%

下面我們再舉一個死迴圈造成 CPU 100% 的例子。HashMap 有可能會發生死迴圈並且造成 CPU 100% ,這種情況發生最主要的原因就是在擴容的時候,也就是內部新建新的 HashMap 的時候,擴容的邏輯會反轉雜湊桶中的節點順序,當有多個執行緒同時進行擴容的時候,由於 HashMap 並非執行緒安全的,所以如果兩個執行緒同時反轉的話,便可能形成一個迴圈,並且這種迴圈是連結串列的迴圈,相當於 A 節點指向 B 節點,B 節點又指回到 A 節點,這樣一來,在下一次想要獲取該 key 所對應的 value 的時候,便會在遍歷連結串列的時候發生永遠無法遍歷結束的情況,也就發生 CPU 100% 的情況。

所以綜上所述,HashMap 是執行緒不安全的,在多執行緒使用場景中如果需要使用 Map,應該儘量避免使用執行緒不安全的 HashMap。同時,雖然 Collections.synchronizedMap(new HashMap()) 是執行緒安全的,但是效率低下,因為內部用了很多的 synchronized,多個執行緒不能同時操作。推薦使用執行緒安全同時效能比較好的 ConcurrentHashMap。

相關文章