為什么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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM