一、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)
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 }
這個方法的功能是將原來的記錄重新計算在新桶的位置,然后遷移過去。

我們假設有兩個線程同時需要執行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是線程不安全的。
