一次因HashSet引起的并發(fā)問題詳解
為啥要用HahSet?
假如我們現(xiàn)在想要在一大堆數(shù)據(jù)中查找X數(shù)據(jù)。LinkedList的數(shù)據(jù)結(jié)構(gòu)就不說了,查找效率低的可怕。ArrayList哪,如果我們不知道X的位置序號,還是一樣要全部遍歷一次直到查到結(jié)果,效率一樣可怕。HashSet天生就是為了提高查找效率的。
背景
上午剛到公司,準(zhǔn)備開始一天的摸魚之旅時突然收到了一封監(jiān)控中心的郵件。
心中暗道不好,因為監(jiān)控系統(tǒng)從來不會告訴我應(yīng)用完美無 bug,其實系統(tǒng)挺猥瑣。
打開郵件一看,果然告知我有一個應(yīng)用的線程池隊列達(dá)到閾值觸發(fā)了報警。
由于這個應(yīng)用出問題非常影響用戶體驗;于是立馬讓運維保留現(xiàn)場 dump 線程和內(nèi)存同時重啟應(yīng)用,還好重啟之后恢復(fù)正常。于是開始著手排查問題。
分析
首先了解下這個應(yīng)用大概是做什么的。
簡單來說就是從 MQ 中取出數(shù)據(jù)然后丟到后面的業(yè)務(wù)線程池中做具體的業(yè)務(wù)處理。
而報警的隊列正好就是這個線程池的隊列。
跟蹤代碼發(fā)現(xiàn)構(gòu)建線程池的方式如下:
ThreadPoolExecutor executor = new ThreadPoolExecutor(coreSize, maxSize, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());; put(poolName,executor);
采用的是默認(rèn)的 LinkedBlockingQueue 并沒有指定大?。ㄟ@也是個坑),于是這個隊列的默認(rèn)大小為 Integer.MAX_VALUE。
由于應(yīng)用已經(jīng)重啟,只能從僅存的線程快照和內(nèi)存快照進(jìn)行分析。
內(nèi)存分析
先利用 MAT 分析了內(nèi)存,的到了如下報告。

其中有兩個比較大的對象,一個就是之前線程池存放任務(wù)的 LinkedBlockingQueue,還有一個則是 HashSet。
當(dāng)然其中隊列占用了大量的內(nèi)存,所以優(yōu)先查看,HashSet 一會兒再看。
由于隊列的大小給的夠大,所以結(jié)合目前的情況來看應(yīng)當(dāng)是線程池里的任務(wù)處理較慢,導(dǎo)致隊列的任務(wù)越堆越多,至少這是目前可以得出的結(jié)論。
線程分析
再來看看線程的分析,這里利用fastthread.io 這個網(wǎng)站進(jìn)行線程分析。
因為從表現(xiàn)來看線程池里的任務(wù)遲遲沒有執(zhí)行完畢,所以主要看看它們在干嘛。
正好他們都處于 RUNNABLE 狀態(tài),同時堆棧如下:

發(fā)現(xiàn)正好就是在處理上文提到的 HashSet,看這個堆棧是在查詢 key 是否存在。通過查看 312 行的業(yè)務(wù)代碼確實也是如此。
這里的線程名字也是個坑,讓我找了好久。
定位
分析了內(nèi)存和線程的堆棧之后其實已經(jīng)大概猜出一些問題了。
這里其實有一個前提忘記講到:
這個告警是凌晨三點發(fā)出的郵件,但并沒有電話提醒之類的,所以大家都不知道。
到了早上上班時才發(fā)現(xiàn)并立即 dump 了上面的證據(jù)。
所有有一個很重要的事實:這幾個業(yè)務(wù)線程在查詢 HashSet 的時候運行了 6 7 個小時都沒有返回。
通過之前的監(jiān)控曲線圖也可以看出:

操作系統(tǒng)在之前一直處于高負(fù)載中,直到我們早上看到報警重啟之后才降低。
同時發(fā)現(xiàn)這個應(yīng)用生產(chǎn)上運行的是 JDK1.7 ,所以我初步認(rèn)為應(yīng)該是在查詢 key 的時候進(jìn)入了 HashMap 的環(huán)形鏈表導(dǎo)致 CPU 高負(fù)載同時也進(jìn)入了死循環(huán)。
為了驗證這個問題再次 review 了代碼。
整理之后的偽代碼如下:
//線程池
private ExecutorService executor;
private Set<String> set = new hashSet();
private void execute(){
while(true){
//從 MQ 中獲取數(shù)據(jù)
String key = subMQ();
executor.excute(new Worker(key)) ;
}
}
public class Worker extends Thread{
private String key ;
public Worker(String key){
this.key = key;
}
@Override
private void run(){
if(!set.contains(key)){
//數(shù)據(jù)庫查詢
if(queryDB(key)){
set.add(key);
return;
}
}
//達(dá)到某種條件時清空 set
if(flag){
set = null ;
}
}
}
大致的流程如下:
- 源源不斷的從 MQ 中獲取數(shù)據(jù)。
- 將數(shù)據(jù)丟到業(yè)務(wù)線程池中。
- 判斷數(shù)據(jù)是否已經(jīng)寫入了 Set。
- 沒有則查詢數(shù)據(jù)庫。
- 之后寫入到 Set 中。
這里有一個很明顯的問題,那就是作為共享資源的 Set 并沒有做任何的同步處理。
這里會有多個線程并發(fā)的操作,由于 HashSet 其實本質(zhì)上就是 HashMap,所以它肯定是線程不安全的,所以會出現(xiàn)兩個問題:
- Set 中的數(shù)據(jù)在并發(fā)寫入時被覆蓋導(dǎo)致數(shù)據(jù)不準(zhǔn)確。
- 會在擴(kuò)容的時候形成環(huán)形鏈表。
第一個問題相對于第二個還能接受。
通過上文的內(nèi)存分析我們已經(jīng)知道這個 set 中的數(shù)據(jù)已經(jīng)不少了。同時由于初始化時并沒有指定大小,僅僅只是默認(rèn)值,所以在大量的并發(fā)寫入時候會導(dǎo)致頻繁的擴(kuò)容,而在 1.7 的條件下又可能會形成環(huán)形鏈表。
不巧的是代碼中也有查詢操作(contains()),觀察上文的堆棧情況:

發(fā)現(xiàn)是運行在 HashMap 的 465 行,來看看 1.7 中那里具體在做什么:

已經(jīng)很明顯了。這里在遍歷鏈表,同時由于形成了環(huán)形鏈表導(dǎo)致這個 e.next 永遠(yuǎn)不為空,所以這個循環(huán)也不會退出了。
到這里其實已經(jīng)找到問題了,但還有一個疑問是為什么線程池里的任務(wù)隊列會越堆越多。我第一直覺是任務(wù)執(zhí)行太慢導(dǎo)致的。
仔細(xì)查看了代碼發(fā)現(xiàn)只有一個地方可能會慢:也就是有一個數(shù)據(jù)庫的查詢。
把這個 SQL 拿到生產(chǎn)環(huán)境執(zhí)行發(fā)現(xiàn)確實不快,查看索引發(fā)現(xiàn)都有命中。
但我一看表中的數(shù)據(jù)發(fā)現(xiàn)已經(jīng)快有 7000W 的數(shù)據(jù)了。同時經(jīng)過運維得知 MySQL 那臺服務(wù)器的 IO 壓力也比較大。
所以這個原因也比較明顯了:
由于每消費一條數(shù)據(jù)都要去查詢一次數(shù)據(jù)庫,MySQL 本身壓力就比較大,加上數(shù)據(jù)量也很高所以導(dǎo)致這個 IO 響應(yīng)較慢,導(dǎo)致整個任務(wù)處理的就比較慢了。
但還有一個原因也不能忽視;由于所有的業(yè)務(wù)線程在某個時間點都進(jìn)入了死循環(huán),根本沒有執(zhí)行完任務(wù)的機(jī)會,而后面的數(shù)據(jù)還在源源不斷的進(jìn)入,所以這個隊列只會越堆越多!
這其實是一個老應(yīng)用了,可能會有人問為什么之前沒出現(xiàn)問題。
這是因為之前數(shù)據(jù)量都比較少,即使是并發(fā)寫入也沒有出現(xiàn)并發(fā)擴(kuò)容形成環(huán)形鏈表的情況。這段時間業(yè)務(wù)量的暴增正好把這個隱藏的雷給揪出來了。所以還是得信墨菲他老人家的話。
總結(jié)
至此整個排查結(jié)束,而我們后續(xù)的調(diào)整措施大概如下:
- HashSet 不是線程安全的,換為 ConcurrentHashMap同時把 value 寫死一樣可以達(dá)到 set 的效果。
- 根據(jù)我們后面的監(jiān)控,初始化 ConcurrentHashMap 的大小盡量大一些,避免頻繁的擴(kuò)容。
- MySQL 中很多數(shù)據(jù)都已經(jīng)不用了,進(jìn)行冷熱處理。盡量降低單表數(shù)據(jù)量。同時后期考慮分表。
- 查數(shù)據(jù)那里調(diào)整為查緩存,提高查詢效率。
- 線程池的名稱一定得取的有意義,不然是自己給自己增加難度。
- 根據(jù)監(jiān)控將線程池的隊列大小調(diào)整為一個具體值,并且要有拒絕策略。
- 升級到 JDK1.8。
- 再一個是報警郵件酌情考慮為電話通知😂。
HashMap 的死循環(huán)問題在網(wǎng)上層出不窮,沒想到還真被我遇到了?,F(xiàn)在要滿足這個條件還是挺少見的,比如 1.8 以下的 JDK 這一條可能大多數(shù)人就碰不到,正好又證實了一次墨菲定律。
好了,以上就是這篇文章的全部內(nèi)容了,希望本文的內(nèi)容對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,如果有疑問大家可以留言交流,謝謝大家對腳本之家的支持。
- 淺析Java中Map與HashMap,Hashtable,HashSet的區(qū)別
- Java中HashMap和Hashtable及HashSet的區(qū)別
- HashMap 和 HashSet的區(qū)別
- Java中的HashSet詳解和使用示例_動力節(jié)點Java學(xué)院整理
- java 中HashMap、HashSet、TreeMap、TreeSet判斷元素相同的幾種方法比較
- hashset去除重復(fù)值原理實例解析
- HashSet和TreeSet使用方法的區(qū)別解析
- 詳解Java中HashSet和TreeSet的區(qū)別
- Java編程中的HashSet和BitSet詳解
相關(guān)文章
詳解Spring Boot 定制HTTP消息轉(zhuǎn)換器
本篇文章主要介紹了詳解Spring Boot 定制HTTP消息轉(zhuǎn)換器,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-11-11
Spring?Boot配置文件的語法規(guī)則詳解(properties和yml)
這篇文章主要介紹了Spring?Boot配置文件的語法規(guī)則,主要介紹兩種配置文件的語法和格式,properties和yml,對于配置文件也有獨立的文件夾存放,主要用來存放一些需要經(jīng)過變動的數(shù)據(jù)(變量值),感興趣的朋友跟隨小編一起看看吧2024-07-07
SpringBoot根據(jù)參數(shù)動態(tài)調(diào)用接口實現(xiàn)類方法
在?Spring?Boot?開發(fā)中,我們經(jīng)常會遇到根據(jù)不同參數(shù)調(diào)用接口不同實現(xiàn)類方法的需求,本文將詳細(xì)介紹如何實現(xiàn)這一功能,有需要的小伙伴可以參考下2025-02-02

