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

為什麼HashMap執行緒不安全

一、Map概述

我們都知道HashMap是執行緒不安全的,但是HashMap的使用頻率在所有map中確實屬於比較高的。因為它可以滿足我們大多數的場景了。

Map類繼承圖

上面展示了java中Map的繼承圖,Map是一個介面,我們常用的實現類有HashMap、LinkedHashMap、TreeMap,HashTable。HashMap根據key的hashCode值來儲存value,需要注意的是,HashMap不保證遍歷的順序和插入的順序是一致的。HashMap允許有一條記錄的key為null,但是對值是否為null不做要求。HashTable類是執行緒安全的,它使用synchronize來做執行緒安全,全域性只有一把鎖,線上程競爭比較激烈的情況下hashtable的效率是比較低下的。因為當一個執行緒訪問hashtable的同步方法時,其他執行緒再次嘗試訪問的時候,會進入阻塞或者輪詢狀態,比如當執行緒1使用put進行元素新增的時候,執行緒2不但不能使用put來新增元素,而且不能使用get獲取元素。所以,競爭會越來越激烈。相比之下,ConcurrentHashMap使用了分段鎖技術來提高了併發度,不在同一段的資料互相不影響,多個執行緒對多個不同的段的操作是不會相互影響的。每個段使用一把鎖。所以在需要執行緒安全的業務場景下,推薦使用ConcurrentHashMap,而HashTable不建議在新的程式碼中使用,如果需要執行緒安全,則使用ConcurrentHashMap,否則使用HashMap就足夠了。

LinkedHashMap屬於HashMap的子類,與HashMap的區別在於LinkedHashMap儲存了記錄插入的順序。TreeMap實現了SortedMap介面,TreeMap有能力對插入的記錄根據key排序,預設按照升序排序,也可以自定義比較強,在使用TreeMap的時候,key應當實現Comparable。

二、HashMap的實現

java7和java8在實現HashMap上有所區別,當然java8的效率要更好一些,主要是java8的HashMap在java7的基礎上增加了紅黑樹這種資料結構,使得在桶裡面查詢資料的複雜度從O(n)降到O(logn),當然還有一些其他的優化,比如resize的優化等。

介於java8的HashMap較為複雜,本文將基於java7的HashMap實現來說明,主要的實現部分還是一致的,java8的實現上主要是做了一些優化,內容還是沒有變化的,依然是執行緒不安全的。
HashMap的實現使用了一個數組,每個陣列項裡面有一個連結串列的方式來實現,因為HashMap使用key的hashCode來尋找儲存位置,不同的key可能具有相同的hashCode,這時候就出現雜湊衝突了,也叫做雜湊碰撞,為了解決雜湊衝突,有開放地址方法,以及鏈地址方法。HashMap的實現上選取了鏈地址方法,也就是將雜湊值一樣的entry儲存在同一個陣列項裡面,可以把一個數組項當做一個桶,桶裡面裝的entry的key的hashCode是一樣的。

HashMap的結構模型(java8) 上面的圖片展示了我們的描述,其中有一個非常重要的資料結構Node<K,V>,這就是實際儲存我們的key-value對的資料結構,下面是這個資料結構的主要內容:
        final int hash;    
        final K key;
        V value;
        Node<K,V> next;   

一個Node就是一個連結串列節點,也就是我們插入的一條記錄,明白了HashMap使用鏈地址方法來解決雜湊衝突之後,我們就不難理解上面的資料結構,hash欄位用來定位桶的索引位置,key和value就是我們的資料內容,需要注意的是,我們的key是final的,也就是不允許更改,這也好理解,因為HashMap使用key的hashCode來尋找桶的索引位置,一旦key被改變了,那麼key的hashCode很可能就會改變了,所以隨意改變key會使得我們丟失記錄(無法找到記錄)。next欄位指向連結串列的下一個節點。

HashMap的初始桶的數量為16,loadFact為0.75,當桶裡面的資料記錄超過閾值的時候,HashMap將會進行擴容則操作,每次都會變為原來大小的2倍,直到設定的最大值之後就無法再resize了。

下面對HashMap的實現做簡單的介紹,具體實現還得看程式碼,對於java8中的HashMap實現,還需要能理解紅黑樹這種資料結構。

1、根據key的hashCode來決定應該將該記錄放在哪個桶裡面,無論是插入、查詢還是刪除,這都是第一步,計算桶的位置。因為HashMap的length總是2的n次冪,所以可以使用下面的方法來做模運算:

h&(length-1)

h是key的hashCode值,計算好hashCode之後,使用上面的方法來對桶的數量取模,將這個資料記錄落到某一個桶裡面。當然取模是java7中的做法,java8進行了優化,做得更加巧妙,因為我們的length總是2的n次冪,所以在一次resize之後,當前位置的記錄要麼保持當前位置不變,要麼就向前移動length就可以了。所以java8中的HashMap的resize不需要重新計算hashCode。我們可以通過觀察java7中的計算方法來抽象出演算法,然後進行優化,具體的細節看程式碼就可以了。

2、HashMap的put方法

HashMap的put方法處理邏輯(java8)

上圖展示了java8中put方法的處理邏輯,比java7多了紅黑樹部分,以及在一些細節上的優化,put邏輯和java7中是一致的。

3、resize機制

HashMap的擴容機制就是重新申請一個容量是當前的2倍的桶陣列,然後將原先的記錄逐個重新對映到新的桶裡面,然後將原先的桶逐個置為null使得引用失效。後面會講到,HashMap之所以執行緒不安全,就是resize這裡出的問題。

三、為什麼HashMap執行緒不安全

上面說到,HashMap會進行resize操作,在resize操作的時候會造成執行緒不安全。下面將舉兩個可能出現執行緒不安全的地方。

1、put的時候導致的多執行緒資料不一致。
這個問題比較好想象,比如有兩個執行緒A和B,首先A希望插入一個key-value對到HashMap中,首先計算記錄所要落到的桶的索引座標,然後獲取到該桶裡面的連結串列頭結點,此時執行緒A的時間片用完了,而此時執行緒B被排程得以執行,和執行緒A一樣執行,只不過執行緒B成功將記錄插到了桶裡面,假設執行緒A插入的記錄計算出來的桶索引和執行緒B要插入的記錄計算出來的桶索引是一樣的,那麼當執行緒B成功插入之後,執行緒A再次被排程執行時,它依然持有過期的連結串列頭但是它對此一無所知,以至於它認為它應該這樣做,如此一來就覆蓋了執行緒B插入的記錄,這樣執行緒B插入的記錄就憑空消失了,造成了資料不一致的行為。

2、另外一個比較明顯的執行緒不安全的問題是HashMap的get操作可能因為resize而引起死迴圈(cpu100%),具體分析如下:

下面的程式碼是resize的核心內容:

 1 void transfer(Entry[] newTable, boolean rehash) {  
 2         int newCapacity = newTable.length;  
 3         for (Entry<K,V> e : table) {  
 4   
 5             while(null != e) {  
 6                 Entry<K,V> next = e.next;           
 7                 if (rehash) {  
 8                     e.hash = null == e.key ? 0 : hash(e.key);  
 9                 }  
10                 int i = indexFor(e.hash, newCapacity);   
11                 e.next = newTable[i];  
12                 newTable[i] = e;  
13                 e = next;  
14             } 
15         }  
16     }  

這個方法的功能是將原來的記錄重新計算在新桶的位置,然後遷移過去。

多執行緒HashMap的resize

我們假設有兩個執行緒同時需要執行resize操作,我們原來的桶數量為2,記錄數為3,需要resize桶到4,原來的記錄分別為:[3,A],[7,B],[5,C],在原來的map裡面,我們發現這三個entry都落到了第二個桶裡面。
假設執行緒thread1執行到了transfer方法的Entry next = e.next這一句,然後時間片用完了,此時的e = [3,A], next = [7,B]。執行緒thread2被排程執行並且順利完成了resize操作,需要注意的是,此時的[7,B]的next為[3,A]。此時執行緒thread1重新被排程執行,此時的thread1持有的引用是已經被thread2 resize之後的結果。執行緒thread1首先將[3,A]遷移到新的陣列上,然後再處理[7,B],而[7,B]被連結到了[3,A]的後面,處理完[7,B]之後,就需要處理[7,B]的next了啊,而通過thread2的resize之後,[7,B]的next變為了[3,A],此時,[3,A]和[7,B]形成了環形連結串列,在get的時候,如果get的key的桶索引和[3,A]和[7,B]一樣,那麼就會陷入死迴圈。

如果在取連結串列的時候從頭開始取(現在是從尾部開始取)的話,則可以保證節點之間的順序,那樣就不存在這樣的問題了。

綜合上面兩點,可以說明HashMap是執行緒不安全的。

轉自:https://www.jianshu.com/p/e2f75c8cce01