HashMap為什麼是執行緒不安全的?
前言
大家都知道HashMap
是執行緒不安全的,我們應該使用ConcurrentHashMap
。但是為什麼HashMap
是執行緒不安全的呢,今天就一起來探討這個問題。
注意
HashMap
的執行緒不安全體現在會造成死迴圈、資料丟失、資料覆蓋這些問題。其中死迴圈和資料丟失是在JDK1.7中出現的問題,在JDK1.8中已經得到解決,然而1.8中仍會有資料覆蓋這樣的問題。
擴容引發的執行緒不安全
HashMap
的執行緒不安全主要是發生在擴容函式中,即根源是在transfer函式中,JDK1.7中HashMap
的transfer
函式如下:
JDK1.7中的執行緒不安全
1 void transfer(Entry[] newTable, booleanrehash) { 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 }
這段程式碼是HashMap
的擴容操作,重新定位每個桶的下標,並採用頭插法將元素遷移到新陣列中。頭插法會將連結串列的順序翻轉,這也是形成死迴圈的關鍵點。理解了頭插法後再繼續往下看是如何造成死迴圈以及資料丟失的。
擴容造成死迴圈和資料丟失的分析過程
假設現在有兩個執行緒A、B同時對下面這個HashMap
進行擴容操作:
正常擴容後的結果是下面這樣的:
但是當執行緒A執行到上面transfer
函式的第11行程式碼時,CPU時間片耗盡,執行緒A被掛起。即如下圖中位置所示:
此時執行緒A中:e=3、next=7、e.next=null
接著繼續執行下一輪迴圈,此時e=7,從主記憶體中讀取e.next時發現主記憶體中7.next=3,於是乎next=3,並將7採用頭插法的方式放入新陣列中,並繼續執行完此輪迴圈,結果如下:
執行下一次迴圈可以發現,next=e.next=null,所以此輪迴圈將會是最後一輪迴圈。接下來當執行完e.next=newTable[i]即3.next=7後,3和7之間就相互連線了,當執行完newTable[i]=e後,3被頭插法重新插入到連結串列中,執行結果如下圖所示:
上面說了此時e.next=null即next=null,當執行完e=null後,將不會進行下一輪迴圈。到此執行緒A、B的擴容操作完成,很明顯當執行緒A執行完後,HashMap
中出現了環形結構,當在以後對該HashMap
進行操作時會出現死迴圈。
並且從上圖可以發現,元素5在擴容期間被莫名的丟失了,這就發生了資料丟失的問題。
JDK1.8中的執行緒不安全
根據上面JDK1.7出現的問題,在JDK1.8中已經得到了很好的解決,如果你去閱讀1.8的原始碼會發現找不到transfer
函式,因為JDK1.8直接在resize
函式中完成了資料遷移。另外說一句,JDK1.8在進行元素插入時使用的是尾插法。
為什麼說JDK1.8會出現資料覆蓋的情況喃,我們來看一下下面這段JDK1.8中的put操作程式碼:
1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, 2 boolean evict) { 3 Node<K,V>[] tab; Node<K,V> p; int n, i; 4 if ((tab = table) == null || (n = tab.length) == 0) 5 n = (tab = resize()).length; 6 if ((p = tab[i = (n - 1) & hash]) == null) // 如果沒有hash碰撞則直接插入元素 7 tab[i] = newNode(hash, key, value, null); 8 else { 9 Node<K,V> e; K k; 10 if (p.hash == hash && 11 ((k = p.key) == key || (key != null && key.equals(k)))) 12 e = p; 13 else if (p instanceof TreeNode) 14 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); 15 else { 16 for (int binCount = 0; ; ++binCount) { 17 if ((e = p.next) == null) { 18 p.next = newNode(hash, key, value, null); 19 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 20 treeifyBin(tab, hash); 21 break; 22 } 23 if (e.hash == hash && 24 ((k = e.key) == key || (key != null && key.equals(k)))) 25 break; 26 p = e; 27 } 28 } 29 if (e != null) { // existing mapping for key 30 V oldValue = e.value; 31 if (!onlyIfAbsent || oldValue == null) 32 e.value = value; 33 afterNodeAccess(e); 34 return oldValue; 35 } 36 } 37 ++modCount; 38 if (++size > threshold) 39 resize(); 40 afterNodeInsertion(evict); 41 return null; 42 }
其中第六行程式碼是判斷是否出現hash碰撞,假設兩個執行緒A、B都在進行put操作,並且hash函式計算出的插入下標是相同的,當執行緒A執行完第六行程式碼後由於時間片耗盡導致被掛起,而執行緒B得到時間片後在該下標處插入了元素,完成了正常的插入,然後執行緒A獲得時間片,由於之前已經進行了hash碰撞的判斷,所有此時不會再進行判斷,而是直接進行插入,這就導致了執行緒B插入的資料被執行緒A覆蓋了,從而執行緒不安全。
除此之前,還有就是程式碼的第38行處有個++size
,我們這樣想,還是執行緒A、B,這兩個執行緒同時進行put操作時,假設當前HashMap
的zise大小為10,當執行緒A執行到第38行程式碼時,從主記憶體中獲得size的值為10後準備進行+1操作,但是由於時間片耗盡只好讓出CPU,執行緒B快樂的拿到CPU還是從主記憶體中拿到size的值10進行+1操作,完成了put操作並將size=11寫回主記憶體,然後執行緒A再次拿到CPU並繼續執行(此時size的值仍為10),當執行完put操作後,還是將size=11寫回記憶體,此時,執行緒A、B都執行了一次put操作,但是size的值只增加了1,所有說還是由於資料覆蓋又導致了執行緒不安全
總結
HashMap
的執行緒不安全主要體現在下面兩個方面:
1.在JDK1.7中,當併發執行擴容操作時會造成環形鏈和資料丟失的情況。
2.在JDK1.8中,在併發執行put操作時會發生資料覆蓋的情況。