1. 程式人生 > 實用技巧 >HashMap為什麼是執行緒不安全的?

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

前言

大家都知道HashMap是執行緒不安全的,我們應該使用ConcurrentHashMap。但是為什麼HashMap是執行緒不安全的呢,今天就一起來探討這個問題。

注意

HashMap的執行緒不安全體現在會造成死迴圈、資料丟失、資料覆蓋這些問題。其中死迴圈和資料丟失是在JDK1.7中出現的問題,在JDK1.8中已經得到解決,然而1.8中仍會有資料覆蓋這樣的問題。

擴容引發的執行緒不安全

HashMap的執行緒不安全主要是發生在擴容函式中,即根源是在transfer函式中,JDK1.7中HashMaptransfer函式如下:

JDK1.7中的執行緒不安全

 1 void transfer(Entry[] newTable, boolean
rehash) { 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操作時會發生資料覆蓋的情況。