1. 程式人生 > >8. 造成HashMap非執行緒安全的原因

8. 造成HashMap非執行緒安全的原因

在前面我的一篇總結(6. 執行緒範圍內共享資料)文章中提到,為了資料能線上程範圍內使用,我用了 HashMap 來儲存不同執行緒中的資料,key 為當前執行緒,value 為當前執行緒中的資料。我取的時候根據當前執行緒名從 HashMap 中取即可。

因為當初學習 HashMap 和 HashTable 原始碼的時候,知道 HashTable 是執行緒安全的,因為裡面的方法使用了 synchronized 進行同步,但是 HashMap 沒有,所以 HashMap 是非執行緒安全的。

在上面提到的例子中,我想反正不用修改 HashMap,只需要從中取值即可,所以不會有執行緒安全問題,但是我忽略了一個步驟:我得先把不同執行緒的資料存到 HashMap 中吧,這個存就可能出現問題,雖然我存的時候 key 使用了不同的執行緒名字,理論上來說是不會衝突的,但是這種設計或者思想本來就不夠嚴謹。我後來仔細推敲了下,重新溫習了下 HashMap 的原始碼,再加上網上查的一些資料,在這裡總結一下 HashMap 到底什麼時候可能出現執行緒安全問題。

我們知道 HashMap 底層是一個 Entry 陣列,當發生 hash 衝突的時候,HashMap 是採用連結串列的方式來解決的,在對應的陣列位置存放連結串列的頭結點。對連結串列而言,新加入的節點會從頭結點加入。javadoc 中有一段關於 HashMap 的描述:

此實現不是同步的。如果多個執行緒同時訪問一個雜湊對映,而其中至少一個執行緒從結構上修改了該對映,則它必須保持外部同步。(結構上的修改是指新增或刪除一個或多個對映關係的任何操作;僅改變與例項已經包含的鍵關聯的值不是結構上的修改。)這一般通過對自然封裝該對映的物件進行同步操作來完成。如果不存在這樣的物件,則應該使用 Collections.synchronizedMap 方法來“包裝”該對映。最好在建立時完成這一操作,以防止對對映進行意外的非同步訪問,如下所示:
Map m = Collections.synchronizedMap(new HashMap(...));

可以看出,解決 HashMap 執行緒安全問題的方法很簡單,下面我簡單分析一下可能會出現執行緒問題的一些地方。

1. 向HashMap中插入資料的時候

在 HashMap 做 put 操作的時候會呼叫到以下的方法:

//向HashMap中新增Entry
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length); //擴容2倍
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}
//建立一個Entry
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];//先把table中該位置原來的Entry儲存
    //在table中該位置新建一個Entry,將原來的Entry掛到該Entry的next
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    //所以table中的每個位置永遠只儲存一個最新加進來的Entry,其他Entry是一個掛一個,這樣掛上去的
    size++;
}

現在假如 A 執行緒和 B 執行緒同時進入 addEntry,然後計算出了相同的雜湊值對應了相同的陣列位置,因為此時該位置還沒資料,然後對同一個陣列位置呼叫 createEntry,兩個執行緒會同時得到現在的頭結點,然後 A 寫入新的頭結點之後,B 也寫入新的頭結點,那 B 的寫入操作就會覆蓋A的寫入操作造成 A 的寫入操作丟失。

2. HashMap擴容的時候

還是上面那個 addEntry 方法中,有個擴容的操作,這個操作會新生成一個新的容量的陣列,然後對原陣列的所有鍵值對重新進行計算和寫入新的陣列,之後指向新生成的陣列。來看一下擴容的原始碼:

//用新的容量來給table擴容  
void resize(int newCapacity) {  
    Entry[] oldTable = table; //儲存old table  
    int oldCapacity = oldTable.length; //儲存old capacity  
    // 如果舊的容量已經是系統預設最大容量了,那麼將閾值設定成整形的最大值,退出    
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        threshold = Integer.MAX_VALUE;  
        return;  
    }  
  
    //根據新的容量新建一個table  
    Entry[] newTable = new Entry[newCapacity];  
    //將table轉換成newTable  
    transfer(newTable, initHashSeedAsNeeded(newCapacity));  
    table = newTable;  
    //設定閾值  
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);  
} 

那麼問題來了,當多個執行緒同時進來,檢測到總數量超過門限值的時候就會同時呼叫 resize操作,各自生成新的陣列並 rehash 後賦給該 map 底層的陣列 table,結果最終只有最後一個執行緒生成的新陣列被賦給 table 變數,其他執行緒的均會丟失。而且當某些執行緒已經完成賦值而其他執行緒剛開始的時候,就會用已經被賦值的 table 作為原始陣列,這樣也會有問題。所以在擴容操作的時候也有可能會引起一些併發的問題。

3. 刪除HashMap中資料的時候

刪除鍵值對的原始碼如下:

//根據指定的key刪除Entry,返回對應的value  
public V remove(Object key) {  
    Entry<K,V> e = removeEntryForKey(key);  
    return (e == null ? null : e.value);  
}  
  
//根據指定的key,刪除Entry,並返回對應的value  
final Entry<K,V> removeEntryForKey(Object key) {  
    if (size == 0) {  
        return null;  
    }  
    int hash = (key == null) ? 0 : hash(key);  
    int i = indexFor(hash, table.length);  
    Entry<K,V> prev = table[i];  
    Entry<K,V> e = prev;  
  
    while (e != null) {  
        Entry<K,V> next = e.next;  
        Object k;  
        if (e.hash == hash &&  
            ((k = e.key) == key || (key != null && key.equals(k)))) {  
            modCount++;  
            size--;  
            if (prev == e) //如果刪除的是table中的第一項的引用  
                table[i] = next;//直接將第一項中的next的引用存入table[i]中  
            else  
                prev.next = next; //否則將table[i]中當前Entry的前一個Entry中的next置為當前Entry的next  
            e.recordRemoval(this);  
            return e;  
        }  
        prev = e;  
        e = next;  
    }  
  
    return e;  
}

刪除這一塊可能會出現兩種執行緒安全問題,第一種是一個執行緒判斷得到了指定的陣列位置i並進入了迴圈,此時,另一個執行緒也在同樣的位置已經刪掉了i位置的那個資料了,然後第一個執行緒那邊就沒了。但是刪除的話,沒了倒問題不大。

再看另一種情況,當多個執行緒同時操作同一個陣列位置的時候,也都會先取得現在狀態下該位置儲存的頭結點,然後各自去進行計算操作,之後再把結果寫會到該陣列位置去,其實寫回的時候可能其他的執行緒已經就把這個位置給修改過了,就會覆蓋其他執行緒的修改。

其他地方還有很多可能會出現執行緒安全問題,我就不一一列舉了,總之 HashMap 是非執行緒安全的,在高併發的場合使用的話,要用 Collections.synchronizedMap 進行包裝一下,或者直接使用 ConcurrentHashMap 都行。

關於 HashMap 的執行緒非安全性,就總結這麼多,如有問題,歡迎交流,我們一同進步~