形成HashMap非線程安全的緣由

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

此實現不是同步的。若是多個線程同時訪問一個哈希映射,而其中至少一個線程從結構上修改了該映射,則它必須保持外部同步。(結構上的修改是指添加或刪除一個或多個映射關係的任何操做;僅改變與實例已經包含的鍵關聯的值不是結構上的修改。)這通常經過對天然封裝該映射的對象進行同步操做來完成。若是不存在這樣的對象,則應該使用 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 的寫入操做丟失。this

  1. HashMap擴容的時候

仍是上面那個 addEntry 方法中,有個擴容的操做,這個操做會新生成一個新的容量的數組,而後對原數組的全部鍵值對從新進行計算和寫入新的數組,以後指向新生成的數組。來看一下擴容的源碼:線程

//用新的容量來給table擴容
void resize(int newCapacity) {code

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 做爲原始數組,這樣也會有問題。因此在擴容操做的時候也有可能會引發一些併發的問題。對象

  1. 刪除HashMap中數據的時候

刪除鍵值對的源代碼以下:ci

//根據指定的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 的線程非安全性,就總結這麼多,若有問題,歡迎交流,咱們一同進步~

相關文章
相關標籤/搜索