Java集合:HashMap线程不安全?有哪些表现?
HashMap是線程不安全的!主要表現(xiàn)在多線程情況下:
1)hash沖突時,put方法不是同步的,先存的值會被后存的值覆蓋。(1.7和1.8都有的表現(xiàn))
2)在resize的時候,可能會導(dǎo)致死循環(huán)(環(huán)形鏈表)(僅1.7會有的表現(xiàn),因為其頭插法導(dǎo)致)
讓我們先來了解一下HashMap的底層存儲結(jié)構(gòu),HashMap底層是一個Entry數(shù)組,一旦發(fā)生Hash沖突的的時候,HashMap采用拉鏈法解決碰撞沖突,Entry內(nèi)部的變量:
[java]?view plain?copy
?
? ? ? ? 通過Entry內(nèi)部的next變量可以知道使用的是鏈表,這時候我們可以知道,如果多個線程,在某一時刻同時操作HashMap并執(zhí)行put操作,而有大于兩個key的hash值相同,如圖中a1、a2,這個時候需要解決碰撞沖突,而解決沖突的辦法上面已經(jīng)說過,對于鏈表的結(jié)構(gòu)在這里不再贅述,暫且不討論是從鏈表頭部插入還是從尾部初入,這個時候兩個線程如果恰好都取到了對應(yīng)位置的頭結(jié)點e1,而最終的結(jié)果可想而知,a1、a2兩個數(shù)據(jù)中勢必會有一個會丟失,如圖所示:
?
再來看下put方法
?
[java]?view plain?copy
?
put方法不是同步的,同時調(diào)用了addEntry方法:
?
[java]?view plain?copy
?
addEntry方法依然不是同步的,所以導(dǎo)致了線程不安全出現(xiàn)傷處問題,其他類似操作不再說明,源碼一看便知,
?resize死循環(huán)(JDK1.7)
重新調(diào)整 HashMap 大小的時候,存在條件競爭。
因為如果兩個線程都發(fā)現(xiàn) HashMap 需要重新調(diào)整大小了,它們會同時試著調(diào)整大小。在調(diào)整大小的過程中,存儲在鏈表中的元素的次序會反過來。因為移動到新的 bucket 位置的時候,HashMap 并不會將元素放在鏈表的尾部,而是放在頭部。這是為了避免尾部遍歷(tail traversing)。如果條件競爭發(fā)生了,那么就死循環(huán)了。多線程的環(huán)境下不使用 HashMap。
HashMap 的容量是有限的。當(dāng)經(jīng)過多次元素插入,使得 HashMap 達(dá)到一定飽和度時,Key 映射位置發(fā)生沖突的幾率會逐漸提高。這時候, HashMap 需要擴展它的長度,也就是進行Resize。
擴容:創(chuàng)建一個新的 Entry 空數(shù)組,長度是原數(shù)組的2倍
rehash:遍歷原 Entry 數(shù)組,把所有的 Entry 重新 Hash 到新數(shù)組
為什么多線程會導(dǎo)致死循環(huán),它是怎么發(fā)生的?
我們都知道HashMap初始容量大小為16,一般來說,當(dāng)有數(shù)據(jù)要插入時,都會檢查容量有沒有超過設(shè)定的thredhold,如果超過,需要增大Hash表的尺寸,但是這樣一來,整個Hash表里的元素都需要被重算一遍。這叫rehash,這個成本相當(dāng)?shù)拇蟆?/p>
| 1 2 3 4 5 6 7 8 9 10 11 12 13 | void resize(int newCapacity) { ????????Entry[] oldTable = table; ????????int oldCapacity = oldTable.length; ????????if (oldCapacity == MAXIMUM_CAPACITY) { ????????????threshold = Integer.MAX_VALUE; ????????????return; ????????} ? ????????Entry[] newTable = new Entry[newCapacity]; ????????transfer(newTable, initHashSeedAsNeeded(newCapacity)); ????????table = newTable; ????????threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); } |
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | 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); ????????????????} ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? //頭插法(JDK1.7) ????????????????int i = indexFor(e.hash, newCapacity); ????????????????e.next = newTable[i]; ????????????????newTable[i] = e; ????????????????e = next; ????????????} ????????} } |
| ? | ? |
大概看下transfer:
經(jīng)過這幾步,我們會發(fā)現(xiàn)轉(zhuǎn)移的時候是逆序的。假如轉(zhuǎn)移前鏈表順序是1->2->3,那么轉(zhuǎn)移后就會變成3->2->1。這時候就有點頭緒了,死鎖問題不就是因為1->2的同時2->1造成的嗎?所以,HashMap 的死鎖問題就出在這個transfer()函數(shù)上。
1.1?單線程 rehash 詳細(xì)演示
單線程情況下,rehash 不會出現(xiàn)任何問題:
- 假設(shè)hash算法就是最簡單的 key mod table.length(也就是數(shù)組的長度)。
- 最上面的是old hash 表,其中的Hash表的 size = 2, 所以 key = 3, 7, 5,在 mod 2以后碰撞發(fā)生在 table[1]
- 接下來的三個步驟是 Hash表 resize 到4,并將所有的?<key,value>?重新rehash到新 Hash 表的過程
如圖所示:頭插法
?
1.2?多線程 rehash 詳細(xì)演示
為了思路更清晰,我們只將關(guān)鍵代碼展示出來
| 1 2 3 4 5 6 | while(null != e) { ????Entry<K,V> next = e.next; ????e.next = newTable[i]; ????newTable[i] = e; ????e = next; } |
假設(shè)這里有兩個線程同時執(zhí)行了put()操作,并進入了transfer()環(huán)節(jié)
| 1 2 3 4 5 6 | while(null != e) { ????Entry<K,V> next = e.next; //線程1執(zhí)行到這里被調(diào)度掛起了 ????e.next = newTable[i]; ????newTable[i] = e; ????e = next; } |
那么現(xiàn)在的狀態(tài)為:
?
從上面的圖我們可以看到,因為線程1的 e 指向了 key(3),而 next 指向了 key(7),在線程2 rehash 后,就指向了線程2 rehash 后的鏈表。
然后線程1被喚醒了:
然后該執(zhí)行 key(3)的 next 節(jié)點 key(7)了:
這時候的狀態(tài)圖為:
?
然后又該執(zhí)行 key(7)的 next 節(jié)點 key(3)了:
這時候的狀態(tài)如圖所示:
?
很明顯,環(huán)形鏈表出現(xiàn)了!!當(dāng)然,現(xiàn)在還沒有事情,因為下一個節(jié)點是 null,所以transfer()就完成了,等put()的其余過程搞定后,HashMap 的底層實現(xiàn)就是線程1的新 Hash 表了。
JDK 1.7 HashMap擴容導(dǎo)致死循環(huán)的主要原因
HashMap擴容導(dǎo)致死循環(huán)的主要原因在于擴容后鏈表中的節(jié)點在新的hash桶使用頭插法插入。
新的hash桶會倒置原h(huán)ash桶中的單鏈表,那么在多個線程同時擴容的情況下就可能導(dǎo)致產(chǎn)生一個存在閉環(huán)的單鏈表,從而導(dǎo)致死循環(huán)。
JDK 1.8 HashMap擴容不會造成死循環(huán)的原因
在JDK 1.8中執(zhí)行上面的擴容死循環(huán)代碼示例就不會發(fā)生死循環(huán)。由于使用的是尾插法,不會導(dǎo)致單鏈表的倒置,所以擴容的時候不會導(dǎo)致死循環(huán)。
通過上面的分析,不難發(fā)現(xiàn)循環(huán)的產(chǎn)生是因為新鏈表的順序跟舊的鏈表是完全相反的,所以只要保證建新鏈時還是按照原來的順序的話就不會產(chǎn)生循環(huán)。
?
這里雖然JDK 1.8 中HashMap擴容的時候不會造成死循環(huán),但是如果多個線程同時執(zhí)行put操作,可能會導(dǎo)致同時向一個單鏈表中插入數(shù)據(jù),從而導(dǎo)致數(shù)據(jù)丟失的。
所以不論是JDK 1.7 還是 1.8,HashMap線程都是不安全的,要使用線程安全的Map可以考慮ConcurrentHashMap。
?
總結(jié)
以上是生活随笔為你收集整理的Java集合:HashMap线程不安全?有哪些表现?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java集合:JDK7与JDK8中Has
- 下一篇: Java集合:ConcurrentHas