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