一次因HashSet引起的并發(fā)問題詳解
為啥要用HahSet?
假如我們現(xiàn)在想要在一大堆數(shù)據(jù)中查找X數(shù)據(jù)。LinkedList的數(shù)據(jù)結(jié)構(gòu)就不說了,查找效率低的可怕。ArrayList哪,如果我們不知道X的位置序號,還是一樣要全部遍歷一次直到查到結(jié)果,效率一樣可怕。HashSet天生就是為了提高查找效率的。
背景
上午剛到公司,準(zhǔn)備開始一天的摸魚之旅時(shí)突然收到了一封監(jiān)控中心的郵件。
心中暗道不好,因?yàn)楸O(jiān)控系統(tǒng)從來不會(huì)告訴我應(yīng)用完美無 bug,其實(shí)系統(tǒng)挺猥瑣。
打開郵件一看,果然告知我有一個(gè)應(yīng)用的線程池隊(duì)列達(dá)到閾值觸發(fā)了報(bào)警。
由于這個(gè)應(yīng)用出問題非常影響用戶體驗(yàn);于是立馬讓運(yùn)維保留現(xiàn)場 dump 線程和內(nèi)存同時(shí)重啟應(yīng)用,還好重啟之后恢復(fù)正常。于是開始著手排查問題。
分析
首先了解下這個(gè)應(yīng)用大概是做什么的。
簡單來說就是從 MQ 中取出數(shù)據(jù)然后丟到后面的業(yè)務(wù)線程池中做具體的業(yè)務(wù)處理。
而報(bào)警的隊(duì)列正好就是這個(gè)線程池的隊(duì)列。
跟蹤代碼發(fā)現(xiàn)構(gòu)建線程池的方式如下:
ThreadPoolExecutor executor = new ThreadPoolExecutor(coreSize, maxSize, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());; put(poolName,executor);
采用的是默認(rèn)的 LinkedBlockingQueue 并沒有指定大?。ㄟ@也是個(gè)坑),于是這個(gè)隊(duì)列的默認(rèn)大小為 Integer.MAX_VALUE。
由于應(yīng)用已經(jīng)重啟,只能從僅存的線程快照和內(nèi)存快照進(jìn)行分析。
內(nèi)存分析
先利用 MAT 分析了內(nèi)存,的到了如下報(bào)告。
其中有兩個(gè)比較大的對象,一個(gè)就是之前線程池存放任務(wù)的 LinkedBlockingQueue,還有一個(gè)則是 HashSet。
當(dāng)然其中隊(duì)列占用了大量的內(nèi)存,所以優(yōu)先查看,HashSet 一會(huì)兒再看。
由于隊(duì)列的大小給的夠大,所以結(jié)合目前的情況來看應(yīng)當(dāng)是線程池里的任務(wù)處理較慢,導(dǎo)致隊(duì)列的任務(wù)越堆越多,至少這是目前可以得出的結(jié)論。
線程分析
再來看看線程的分析,這里利用fastthread.io 這個(gè)網(wǎng)站進(jìn)行線程分析。
因?yàn)閺谋憩F(xiàn)來看線程池里的任務(wù)遲遲沒有執(zhí)行完畢,所以主要看看它們在干嘛。
正好他們都處于 RUNNABLE 狀態(tài),同時(shí)堆棧如下:
發(fā)現(xiàn)正好就是在處理上文提到的 HashSet,看這個(gè)堆棧是在查詢 key 是否存在。通過查看 312 行的業(yè)務(wù)代碼確實(shí)也是如此。
這里的線程名字也是個(gè)坑,讓我找了好久。
定位
分析了內(nèi)存和線程的堆棧之后其實(shí)已經(jīng)大概猜出一些問題了。
這里其實(shí)有一個(gè)前提忘記講到:
這個(gè)告警是凌晨三點(diǎn)發(fā)出的郵件,但并沒有電話提醒之類的,所以大家都不知道。
到了早上上班時(shí)才發(fā)現(xiàn)并立即 dump 了上面的證據(jù)。
所有有一個(gè)很重要的事實(shí):這幾個(gè)業(yè)務(wù)線程在查詢 HashSet 的時(shí)候運(yùn)行了 6 7 個(gè)小時(shí)都沒有返回。
通過之前的監(jiān)控曲線圖也可以看出:
操作系統(tǒng)在之前一直處于高負(fù)載中,直到我們早上看到報(bào)警重啟之后才降低。
同時(shí)發(fā)現(xiàn)這個(gè)應(yīng)用生產(chǎn)上運(yùn)行的是 JDK1.7 ,所以我初步認(rèn)為應(yīng)該是在查詢 key 的時(shí)候進(jìn)入了 HashMap 的環(huán)形鏈表導(dǎo)致 CPU 高負(fù)載同時(shí)也進(jìn)入了死循環(huán)。
為了驗(yàn)證這個(gè)問題再次 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á)到某種條件時(shí)清空 set if(flag){ set = null ; } } }
大致的流程如下:
- 源源不斷的從 MQ 中獲取數(shù)據(jù)。
- 將數(shù)據(jù)丟到業(yè)務(wù)線程池中。
- 判斷數(shù)據(jù)是否已經(jīng)寫入了 Set。
- 沒有則查詢數(shù)據(jù)庫。
- 之后寫入到 Set 中。
這里有一個(gè)很明顯的問題,那就是作為共享資源的 Set 并沒有做任何的同步處理。
這里會(huì)有多個(gè)線程并發(fā)的操作,由于 HashSet 其實(shí)本質(zhì)上就是 HashMap,所以它肯定是線程不安全的,所以會(huì)出現(xiàn)兩個(gè)問題:
- Set 中的數(shù)據(jù)在并發(fā)寫入時(shí)被覆蓋導(dǎo)致數(shù)據(jù)不準(zhǔn)確。
- 會(huì)在擴(kuò)容的時(shí)候形成環(huán)形鏈表。
第一個(gè)問題相對于第二個(gè)還能接受。
通過上文的內(nèi)存分析我們已經(jīng)知道這個(gè) set 中的數(shù)據(jù)已經(jīng)不少了。同時(shí)由于初始化時(shí)并沒有指定大小,僅僅只是默認(rèn)值,所以在大量的并發(fā)寫入時(shí)候會(huì)導(dǎo)致頻繁的擴(kuò)容,而在 1.7 的條件下又可能會(huì)形成環(huán)形鏈表。
不巧的是代碼中也有查詢操作(contains()),觀察上文的堆棧情況:
發(fā)現(xiàn)是運(yùn)行在 HashMap 的 465 行,來看看 1.7 中那里具體在做什么:
已經(jīng)很明顯了。這里在遍歷鏈表,同時(shí)由于形成了環(huán)形鏈表導(dǎo)致這個(gè) e.next 永遠(yuǎn)不為空,所以這個(gè)循環(huán)也不會(huì)退出了。
到這里其實(shí)已經(jīng)找到問題了,但還有一個(gè)疑問是為什么線程池里的任務(wù)隊(duì)列會(huì)越堆越多。我第一直覺是任務(wù)執(zhí)行太慢導(dǎo)致的。
仔細(xì)查看了代碼發(fā)現(xiàn)只有一個(gè)地方可能會(huì)慢:也就是有一個(gè)數(shù)據(jù)庫的查詢。
把這個(gè) SQL 拿到生產(chǎn)環(huán)境執(zhí)行發(fā)現(xiàn)確實(shí)不快,查看索引發(fā)現(xiàn)都有命中。
但我一看表中的數(shù)據(jù)發(fā)現(xiàn)已經(jīng)快有 7000W 的數(shù)據(jù)了。同時(shí)經(jīng)過運(yùn)維得知 MySQL 那臺(tái)服務(wù)器的 IO 壓力也比較大。
所以這個(gè)原因也比較明顯了:
由于每消費(fèi)一條數(shù)據(jù)都要去查詢一次數(shù)據(jù)庫,MySQL 本身壓力就比較大,加上數(shù)據(jù)量也很高所以導(dǎo)致這個(gè) IO 響應(yīng)較慢,導(dǎo)致整個(gè)任務(wù)處理的就比較慢了。
但還有一個(gè)原因也不能忽視;由于所有的業(yè)務(wù)線程在某個(gè)時(shí)間點(diǎn)都進(jìn)入了死循環(huán),根本沒有執(zhí)行完任務(wù)的機(jī)會(huì),而后面的數(shù)據(jù)還在源源不斷的進(jìn)入,所以這個(gè)隊(duì)列只會(huì)越堆越多!
這其實(shí)是一個(gè)老應(yīng)用了,可能會(huì)有人問為什么之前沒出現(xiàn)問題。
這是因?yàn)橹皵?shù)據(jù)量都比較少,即使是并發(fā)寫入也沒有出現(xiàn)并發(fā)擴(kuò)容形成環(huán)形鏈表的情況。這段時(shí)間業(yè)務(wù)量的暴增正好把這個(gè)隱藏的雷給揪出來了。所以還是得信墨菲他老人家的話。
總結(jié)
至此整個(gè)排查結(jié)束,而我們后續(xù)的調(diào)整措施大概如下:
- HashSet 不是線程安全的,換為 ConcurrentHashMap同時(shí)把 value 寫死一樣可以達(dá)到 set 的效果。
- 根據(jù)我們后面的監(jiān)控,初始化 ConcurrentHashMap 的大小盡量大一些,避免頻繁的擴(kuò)容。
- MySQL 中很多數(shù)據(jù)都已經(jīng)不用了,進(jìn)行冷熱處理。盡量降低單表數(shù)據(jù)量。同時(shí)后期考慮分表。
- 查數(shù)據(jù)那里調(diào)整為查緩存,提高查詢效率。
- 線程池的名稱一定得取的有意義,不然是自己給自己增加難度。
- 根據(jù)監(jiān)控將線程池的隊(duì)列大小調(diào)整為一個(gè)具體值,并且要有拒絕策略。
- 升級到 JDK1.8。
- 再一個(gè)是報(bào)警郵件酌情考慮為電話通知😂。
HashMap 的死循環(huán)問題在網(wǎng)上層出不窮,沒想到還真被我遇到了。現(xiàn)在要滿足這個(gè)條件還是挺少見的,比如 1.8 以下的 JDK 這一條可能大多數(shù)人就碰不到,正好又證實(shí)了一次墨菲定律。
好了,以上就是這篇文章的全部內(nèi)容了,希望本文的內(nèi)容對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,如果有疑問大家可以留言交流,謝謝大家對腳本之家的支持。
- 淺析Java中Map與HashMap,Hashtable,HashSet的區(qū)別
- Java中HashMap和Hashtable及HashSet的區(qū)別
- HashMap 和 HashSet的區(qū)別
- Java中的HashSet詳解和使用示例_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
- java 中HashMap、HashSet、TreeMap、TreeSet判斷元素相同的幾種方法比較
- hashset去除重復(fù)值原理實(shí)例解析
- HashSet和TreeSet使用方法的區(qū)別解析
- 詳解Java中HashSet和TreeSet的區(qū)別
- Java編程中的HashSet和BitSet詳解
相關(guān)文章
java實(shí)現(xiàn)代碼統(tǒng)計(jì)小程序
這篇文章主要為大家詳細(xì)介紹了java實(shí)現(xiàn)代碼統(tǒng)計(jì)小程序,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-09-09實(shí)戰(zhàn)SpringBoot集成JWT實(shí)現(xiàn)token驗(yàn)證
本文詳細(xì)講解了SpringBoot集成JWT實(shí)現(xiàn)token驗(yàn)證,文中通過示例代碼介紹的非常詳細(xì)。對大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-12-12Java文件讀寫IO/NIO及性能比較詳細(xì)代碼及總結(jié)
這篇文章主要介紹了Java文件讀寫IO/NIO及性能比較詳細(xì)代碼及總結(jié),具有一定借鑒價(jià)值,需要的朋友可以參考下。2017-12-12SpringBoot實(shí)現(xiàn)HTTP服務(wù)監(jiān)聽的代碼示例
前后端分離項(xiàng)目中,在調(diào)用接口調(diào)試時(shí)候,我們可以通過cpolar內(nèi)網(wǎng)穿透將本地服務(wù)端接口模擬公共網(wǎng)絡(luò)環(huán)境遠(yuǎn)程調(diào)用調(diào)試,本次教程我們以Java服務(wù)端接口為例,需要的朋友可以參考下2023-05-05Java的動(dòng)態(tài)綁定與雙分派_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
這篇文章主要介紹了Java的動(dòng)態(tài)綁定與雙分派,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-08-08Spring中的@Value和@PropertySource注解詳解
這篇文章主要介紹了Spring中的@Value和@PropertySource注解詳解,@PropertySource:讀取外部配置文件中的key-value保存到運(yùn)行的環(huán)境變量中,本文提供了部分實(shí)現(xiàn)代碼,需要的朋友可以參考下2023-11-11idea下載svn的項(xiàng)目并且運(yùn)行操作
這篇文章主要介紹了idea下載svn的項(xiàng)目并且運(yùn)行操作,具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-09-09SpringBoot服務(wù)設(shè)置禁止server.point端口的使用
本文主要介紹了SpringBoot服務(wù)設(shè)置禁止server.point端口的使用,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2024-01-01