在前面個人一篇總結文章中提到,爲了數據能在線程範圍內使用,我用了 HashMap 來存儲不一樣線程中的數據,key 爲當前線程,value 爲當前線程中的數據。我取的時候根據當前線程名從 HashMap 中取便可。java
由於當初學習 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 線程安全問題的方法很簡單,下面我簡單分析一下可能會出現線程問題的一些地方。學習
在 HashMap 作 put 操做的時候會調用到如下的方法:this
//向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 的寫入操做丟失。spa
仍是上面那個 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 做爲原始數組,這樣也會有問題。因此在擴容操做的時候也有可能會引發一些併發的問題。設計
刪除鍵值對的源代碼以下:
//根據指定的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 的線程非安全性,就總結這麼多,若有問題,歡迎交流,咱們一同進步~