你是否聽說過 HashMap 在多執行緒環境下操作可能會導致程式死迴圈?
作者:炸雞可樂
原文出處:www.pzblog.cn
一、問題描述
經常有些面試官會問,是否瞭解過 HashMap 在多執行緒環境下使用時可能會發生死迴圈,導致伺服器 cpu 100% 的線上故障?
關於這個問題,很多年前,在淘寶內網裡就有很多的程式設計師發過這種帖子說一個CPU 被100%了,原因竟是多執行緒環境下使用 HashMap 造成的死迴圈,並且這個事發生了很多次。
雖然 Java 官方明確表示,在多執行緒環境下不推薦使用 HashMap,但是對於這種問題,小編其實也比較意外,如果不是深入的去了解 HashMap,都不知道有這樣的問題。
為什麼會產生死迴圈呢?下面我們來還原一下問題的經過。
二、問題重現
在之前的集合系列文章中,我們瞭解到 HashMap 是一個雜湊陣列 + 連結串列的資料結構,在實際的程式開發中,我們經常會使用到 HashMap,如果對 HashMap 不是很瞭解,大家可以看小編之前寫的《深入淺出分析 HashMap 》一文。
HashMap 是一個非執行緒安全的集合操作類,如果我們的程式操作是單執行緒的,那麼一切都沒問題。當我們的程式是多執行緒操作 HashMap 類時,那麼問題就來了,我們一起來複現一下。
測試程式碼,如下:
使用了4個執行緒來向 HashMap 中新增元素,可能一次執行不一定有效果,可以反覆執行幾次!
控制檯輸出結果:
可以清晰的看到,在遍歷 map 的內容時,已經死迴圈了!
再來看看,活動監視器,結果如下:
cpu 的使用率,直接接近 200%!
接下來我們去檢視下 java 中剛剛執行的 HashThreadTest 類堆疊情況:
可以看到,HashMap 的擴容操作導致了死迴圈!
通過測試,我們發現 HashMap 在多執行緒環境下進行操作,的確會產生死迴圈,並且會導致 CPU 100%!
這是為什麼呢?我們一起來閱讀一下原始碼!
三、原始碼閱讀
注意注意,小編在進行測試的時候,使用的是 JDK1.7 的版本!
如果你使用 JDK1.8 的版本,不好意思,不一定能復現這個問題!因為 JDK1.8 已經修復了這個問題,但是依然不建議在多執行緒環境下使用 HashMap!
我們繼續來看看為什麼使用 JDK1.7 會出現這個問題!
既然是 put 階段造成的資料問題,我們不妨一起來看看 HashMap 的 put 過程!
3.1、HashMap 新增過程
HashMap 的 put 原始碼實現如下:
接著我們來看看addEntry()
方法,將元素插入到陣列中,並且檢查容量是否超標,原始碼實現如下:
上面例子中,我們初始化的時候給定的容量是 2,所以在新增元素時必定會擴容!如果超出閥值,就進行擴容處理,建立一個更大容量的 hash 表,然後把從老的 Hash 表中遷移到新的 Hash 表中,原始碼如下:
將舊 hash 表中的元素複製到新的 hash 表中,原始碼如下:
整個 put 過程,大致可以分如下幾個步驟:
- 第一步是通過 key 計算出來的 hash 和 equals 來判斷元素是否存在,如果存在,直接覆蓋;反之,插入;
- 第二步是將元素插入到 hash 表中,如果不同的元素都在一個 hash 陣列下標下,就以連結串列的形式,採用頭插法儲存在 hash 節點下;
- 最後就是判斷當前陣列容量是否大於擴容閥值,如果大於,就進行擴容處理,然後將舊元素複製到新的陣列中;
好了,這個過程基本上沒啥問題。
我們再來演示一下擴容中重新計算元素 hash 的過程!
3.2、單執行緒下擴容元素 hash 過程
假設在單執行緒環境下,我們初始化的時候,給定的陣列容量是2,分別新增3個元素,內容如下:
- key=3,value=A;
- key=4,value=B;
- key=5,value=C;
原始碼如下:
新增完成之後,陣列就會進行擴容處理,擴容後 hash 的容量為原來的2倍,擴容操作流程如下:
在單執行緒環境下,一切看起來都很正常,擴容過程也相當順利。接下來我們看下併發情況下的擴容。
3.3、多執行緒擴容元素 hash 過程
假設我們有兩個執行緒,來分別新增3個元素。
執行緒二執行完新增任務之後,在準備將舊元素遷移到新元素的時候,也就是準備 rehash 時,突然被 CPU 掛起,此時阻塞在如下圖中的第57行,不再往下執行!而執行緒一繼續執行直到擴容完成。
2個執行緒此時的執行結果,內容如下:
接著執行緒二被喚醒,繼續回到第57行執行。
此時注意了,我們來詳細的分析一下這個過程!
第一次迴圈過程如下:
- 第1步:
此時 e 等於{key:3,value:A},next=e.next={key:5,value:C}
; - 第2步:
通過 key 重新 hash 計算得到下標 i = 3
; - 第3步:
newTable為區域性變數,內容都為null,所以 e.next = newTable[i]=null
; - 第4步:
newTable[i]=e={key:3,value:A}
; - 第5步:
e=next={key:5,value:C}
;
迴圈結果如下,e={key:5,value:C}
,滿足while()
迴圈條件,接著繼續!
第二次迴圈過程如下:
- 第1步:
此時 e 等於{key:5,value:C},取最新的連結串列結構,next=e.next={key:3,value:A}
; - 第2步:
通過 key 重新 hash 計算得到下標 i = 3
; - 第3步:
在第一次迴圈中,newTable[i]已經插入值,所以 e.next = newTable[i]={key:3,value:A}
; - 第4步:
newTable[i]=e={key:5,value:C}
; - 第5步:
e=next={key:3,value:A}
;
迴圈結果如下,e={key:3,value:A}
,滿足while()
迴圈條件,接著繼續!
第三次迴圈過程如下:
- 第1步:
此時 e 等於{key:3,value:A},取最新的連結串列結構,next=e.next=null
; - 第2步:
通過 key 重新 hash 計算得到下標 i = 3
; - 第3步:
在第二次迴圈中,newTable[i]已經插入值,所以 e.next = newTable[i]={key:5,value:C}
; - 第4步:
newTable[i]=e={key:3,value:A}
; - 第5步:
e=next=null
;
迴圈結果如下,e=null
,while()
程式不在迴圈!
綜合執行緒1、執行緒2執行結果,最終 hashMap 的儲存結果,如下圖:
可以很清晰的看到,連結串列發生死迴圈了!
於是,當我們在遍歷 hashMap 連結串列內容的時候,就會出現上文中問題復現的場景,死迴圈式的輸出相同的內容,CPU 直接飆到200%了!
對於這種問題,當初有人上報到 SUN 公司,但是 SUN 不認為這是一個問題,因為 HashMap 本來就不支援併發操作!
所以,不建議在多執行緒環境下使用 HashMap,那如果要在多執行緒環境下使用 map 操作類,該怎麼辦呢?
四、解決辦法
辦法肯定是有的,如果大家想在多執行緒場景下使用 HashMap,有兩種解決辦法:
- 第一種,推薦使用併發包中的 ConcurrentHashMap 類,一種使用分段鎖的 hashMap 類,在之後的文章中,咱們也會介紹到它。
- 另一種,是使用
Collections.synchronizedMap(Mao<K,V> map)
工具方法,將 HashMap 變成一個執行緒安全的 map,其實就是對 map 中的方法進行加鎖處理,保證多執行緒下操作安全!
五、參考
1、JDK1.7&JDK1.8 原始碼
2、coolshell -陳皓 - JAVA HASHMAP的死循