JDK bug?? HashMap中的死循環(huán)問題!
本文轉(zhuǎn)載自微信公眾號「石杉的架構(gòu)筆記」,作者雨后晴天 。轉(zhuǎn)載本文請聯(lián)系石杉的架構(gòu)筆記公眾號。
1、前言
HashMap 是 java工程師最常用的數(shù)據(jù)結(jié)構(gòu)之一,但是能對其原理掌握的比較深的同學(xué)很少。尤其是本文的主題,據(jù)一些常年負(fù)責(zé)招 聘的朋友介紹。
HashMap 的死循環(huán)是面試中的常見問題,但是能講清楚的面試者很少,即使這些應(yīng)聘者工作時間都比較長。
原因是,目前講解這個問題的文章雖多,但好文章卻不多。
也有些文章講解的很完善,但內(nèi)容太過燒腦,所以看下來基本上都云里霧 里。鑒于目前的情況,本文基于我個人對源碼熟練掌握的基礎(chǔ)上,跳出源碼,因?yàn)槟翘珶X,提煉出核心環(huán)節(jié)。盡量一步一圖,以大白話 形式將底層原理呈現(xiàn)出來。本文的理論基礎(chǔ)是基于 jdk1.8 以下版本。死循環(huán)問題也主要存在 于 jdk1.8 以下的版本中。
2、HashMap 的數(shù)據(jù)結(jié)構(gòu)
jdk1.7 版本的HashMap,底層結(jié)構(gòu)是一個數(shù)組,數(shù)組中的每項(xiàng)都可以是個鏈表,開始時我們用數(shù)組保存元素,當(dāng)添加元素遇到?jīng)_突,即相同位置處有多個元素時,就將這些元素添加到對應(yīng)的鏈表 中,所以 HashMap 的底層結(jié)構(gòu)可以認(rèn)為是由數(shù)組和鏈表構(gòu)成。
如果,你在構(gòu)建一個 HashMap 時不指定數(shù)組大小,那么默認(rèn)情況下,數(shù)組大小為 16,但限于篇幅。
下面我們只畫了一個大小為 8 的 數(shù)組數(shù)組,在索引為 2 處有個鏈表,存放著 3 個元素,分別為 Entry1、Entry2、Entry3。這三個元素的 hash 運(yùn)算結(jié)果是一樣的而 且都為 2,所以在數(shù)組的 index=2 構(gòu)成了一條鏈表。
3、插入數(shù)據(jù)
在往 HashMap 中添加元素時,比如需要在上面的HashMap 中 添加一個節(jié)點(diǎn) Entry4(k4,v4)。
首先會根據(jù)它的 key 值 k4 計算 hashcode 值,然后再對這個 hashcode 值做些復(fù)雜運(yùn)算,得到在數(shù)組中的目標(biāo) index,比如為 4, 這時因?yàn)閿?shù)組索引為 4 的位置為空,可直接將該元素插入到其底層數(shù) 組中。
因?yàn)椴煌?key 完全可能有相同的 hashcode 值,當(dāng)然也完全可能在數(shù)組中有相同的目標(biāo) index。比如,要新添加一個元素 Entry5(k5,v5),對 k5 進(jìn)行運(yùn)算得到的目標(biāo) index 為4。
但是因?yàn)閿?shù)組中對應(yīng)位置已有一個元素 Entry4, 我們清楚一個數(shù)組的同一個索引處不可能連續(xù)存儲兩個元素而不被覆 蓋,所以這時會怎么處理呢?
這時鏈表就派上用場了,將這兩個元素在 index 為 4 處構(gòu)成一個 鏈表即可以完美的解決沖突問題。
但是要注意,jdk1.7 采用的是頭插 法。就是將新節(jié)點(diǎn) Entry5 放在數(shù)組中作為鏈表的頭節(jié)點(diǎn),將原來的 Entry4 移出作為其 next 節(jié)點(diǎn)。如下圖所示:
為何要一直強(qiáng)調(diào)是 jdk1.7 版本,因?yàn)樵?jdk1.8 的時候 HashMap 結(jié)構(gòu)發(fā)生了較大的變化,1.8 版本的 HashMap 采用的是尾插法而且底 層結(jié)構(gòu)也發(fā)生了變化。
4、擴(kuò)容
java 工程師都清楚 jdk1.7 版本的 HashMap初始默認(rèn)長度為 16,你也可以自己定義,但不管你是自定義還是選擇默認(rèn)長度,
隨著 元素的增加并達(dá)到了一定的閾值,總是要擴(kuò)容的。擴(kuò)容時是按 2 倍來進(jìn)行的,就是創(chuàng)建一個 2 倍大小的數(shù)組,將原 來的元素重新 Hash,計算新的 index 放入到新的數(shù)組+鏈表結(jié)構(gòu)中。
5、高并發(fā)下的擴(kuò)容問題
HashMap 的這一結(jié)構(gòu)和擴(kuò)容機(jī)制可以保證,在大數(shù)據(jù)量情況 下,讀寫性能依然能保持優(yōu)良。
插入時采用頭插法會非常快速,讀取 時,也不會因?yàn)殒湵磉^長而影響讀取性能??吹竭@,會不會覺得 Hash 是個完美的設(shè)計,其實(shí)不是,因?yàn)?HashMap 并不是線程安全的數(shù)據(jù)結(jié)構(gòu)。
尤其是在擴(kuò)容時,如果并發(fā)量不大通常不會有什么問題,但在高 并發(fā)情況下,擴(kuò)容可能導(dǎo)致很嚴(yán)重的問題。我們下面來模擬一個高并發(fā)情況下擴(kuò)容的例子。
1)假設(shè)有一個 HashMap,它的負(fù)載因子為 1,有兩個元素(7,x)和 (11,y),它們 hash 運(yùn)算的結(jié)果都為 1,所以都在 1 號鏈表(即 index 為 1 所指向的鏈表中,為便于描述,下面都簡稱)中,如下所 示。
2)這時你需要往里面添加一個節(jié)點(diǎn)(15,z),這時有 3 個節(jié)點(diǎn),因?yàn)?已達(dá)到擴(kuò)容閾值,需要擴(kuò)容。
3)首先是線程 2 過來,按照擴(kuò)容的底層源碼,需要將 e 指針指向鏈表 的頭節(jié)點(diǎn)(7,x),next 指針指向下一個節(jié)點(diǎn)(11,y),如下圖所 示:
其中 e 表示當(dāng)前線程正要遷移的節(jié)點(diǎn),next 表示下一個需要遷移 的節(jié)點(diǎn)。如果 e 指向的節(jié)點(diǎn)遷移完成,則進(jìn)入下一次循環(huán),e 指針重新指 向節(jié)點(diǎn)(11,y),next 指針重新指向節(jié)點(diǎn)(15,z)。
4)但是當(dāng)線程 2 剛開始標(biāo)記好 e 和 next 兩個指針,正準(zhǔn)備遷移第一 個節(jié)點(diǎn)時。線程 1 過來了,并完成了遷移。
5)前面我們說過,HashMap 進(jìn)入鏈表是采用頭插法,所以對三個元 素 rehash 遷移后的鏈表順序?yàn)?15,z) —> (11,y) —> (7,x) 圖中的 e 和 next 指針屬于線程
2,它們還停留在原來的節(jié)點(diǎn)上, 這一階段的結(jié)構(gòu)如下圖所示:
這時線程 2 中的 table 有(7,x)這個節(jié)點(diǎn)。
6)然后將 next 指向的節(jié)點(diǎn)(11, y)添加到線程 2 表示的 HashMap 中,因?yàn)椴捎妙^插法,所以(11, y)成了鏈表的頭節(jié)點(diǎn),原來的(7, x)則成了它的下一個節(jié)點(diǎn)
這時線程 2 中的 table 有(11,y)---->(7,x)這些節(jié)點(diǎn),鏈 表的頭節(jié)點(diǎn)為(11,y)。
7)繼續(xù)循環(huán)遷移元素,將 e 指向(7,x),則 next 為 null。然后將 線程 2 的數(shù)組下標(biāo)索引 3 指向 e 指向的節(jié)點(diǎn),即將(7,x)又添加到 頭節(jié)點(diǎn)(頭插法),這時線程 2 中的 table 有(7,x)—>(11,y) ---->(7,x),構(gòu)成了環(huán),如圖所示:
6、問題很嚴(yán)重
如果上述的遷移過程最后以線程 2 的 table 作為新的 hashmap, 則最后的遷移結(jié)果如下圖所示:
7、環(huán)形鏈表會帶來什么后果呢?
假設(shè)我現(xiàn)在要查詢上述 HashMap 是否包含節(jié)點(diǎn)(7,x),首先根 據(jù) hash 運(yùn)算得到目標(biāo) index 為 3,所以查找目標(biāo)轉(zhuǎn)到了 3 號鏈表,
然后根據(jù) key 值為 11 判斷與鏈表的頭節(jié)點(diǎn)(7,x)的 key 恰好相等,再 判斷它們的 value 也相等。說明該 HashMap 中包含了我們所要查詢 的幾點(diǎn),返回 true。
假設(shè)我現(xiàn)在要查詢節(jié)點(diǎn)(11,y),經(jīng)過 hash 運(yùn)算也將查找目標(biāo)轉(zhuǎn) 到了 3 號鏈表,然后根據(jù) key 值為 11 判斷與頭節(jié)點(diǎn)(7,x)的 key 不 相等,則轉(zhuǎn)向下一個節(jié)點(diǎn)。
通過對比,發(fā)現(xiàn)與下一個節(jié)點(diǎn)的 key 和 value 都相等,則直接返回 true。
這樣看來似乎沒什么問題,然后我們再查一個節(jié)點(diǎn)(15,m),經(jīng) 過 hash 運(yùn)算也將查找目標(biāo)轉(zhuǎn)到了 3 號鏈表,首先與頭節(jié)點(diǎn)(7,x)判斷 不相等,然后與下一個節(jié)點(diǎn)(11,y)對比也不相等。再與下一個節(jié)點(diǎn) 判斷,這時鏈表中節(jié)點(diǎn)為(7,x)其實(shí)就是重新回到了頭節(jié)點(diǎn),
它的下一 個節(jié)點(diǎn)又是(11,y),這種搜索會一直無限循環(huán)下去,CPU 很快飆 升到 100%,后果很嚴(yán)重。其實(shí)不管你搜索什么節(jié)點(diǎn),只要路由到 3 號鏈表,并且待搜索的 key 不是 7 或 11,都將發(fā)生死循環(huán)。