咱們都知道HashMap是線程不安全的,可是HashMap的使用頻率在全部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。數組
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是同樣的。bash
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實現,還須要能理解紅黑樹這種數據結構。app
一、根據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中的計算方法來抽象出算法,而後進行優化,具體的細節看代碼就能夠了。
二、HashMap的put方法
HashMap的put方法處理邏輯(java8)
複製代碼
上圖展現了java8中put方法的處理邏輯,比java7多了紅黑樹部分,以及在一些細節上的優化,put邏輯和java7中是一致的。
三、resize機制
HashMap的擴容機制就是從新申請一個容量是當前的2倍的桶數組,而後將原先的記錄逐個從新映射到新的桶裏面,而後將原先的桶逐個置爲null使得引用失效。後面會講到,HashMap之因此線程不安全,就是resize這裏出的問題。
上面說到,HashMap會進行resize操做,在resize操做的時候會形成線程不安全。下面將舉兩個可能出現線程不安全的地方。
一、put的時候致使的多線程數據不一致。 這個問題比較好想象,好比有兩個線程A和B,首先A但願插入一個key-value對到HashMap中,首先計算記錄所要落到的桶的索引座標,而後獲取到該桶裏面的鏈表頭結點,此時線程A的時間片用完了,而此時線程B被調度得以執行,和線程A同樣執行,只不過線程B成功將記錄插到了桶裏面,假設線程A插入的記錄計算出來的桶索引和線程B要插入的記錄計算出來的桶索引是同樣的,那麼當線程B成功插入以後,線程A再次被調度運行時,它依然持有過時的鏈表頭可是它對此一無所知,以致於它認爲它應該這樣作,如此一來就覆蓋了線程B插入的記錄,這樣線程B插入的記錄就憑空消失了,形成了數據不一致的行爲。
二、另一個比較明顯的線程不安全的問題是HashMap的get操做可能由於resize而引發死循環(cpu100%),具體分析以下:
下面的代碼是resize的核心內容:
這是jdk7的實現方式,jdk8不是這樣的。
// 這個方法的功能是將原來的記錄從新計算在新桶的位置,而後遷移過去。
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
複製代碼
多線程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是線程不安全的。
根據上面JDK1.7出現的問題,在JDK1.8中已經獲得了很好的解決,若是你去閱讀1.8的源碼會發現找不到transfer函數,由於JDK1.8直接在resize函數中完成了數據遷移。另外說一句,JDK1.8在進行元素插入時使用的是尾插法。
爲何說JDK1.8會出現數據覆蓋的狀況喃,咱們來看一下下面這段JDK1.8中的put操做代碼:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 若是沒有hash碰撞則直接插入元素
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
複製代碼
其中第六行代碼是判斷是否出現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操做時會發生數據覆蓋的狀況。