一次堆外内存泄露的排查过程
轉(zhuǎn)載自??一次堆外內(nèi)存泄露的排查過程
最近在做一個(gè)基于 websocket 的長連中間件,服務(wù)端使用實(shí)現(xiàn)了 socket.io 協(xié)議(基于websocket協(xié)議,提供長輪詢降級能力) 的 netty-socketio 框架,該框架為 netty 實(shí)現(xiàn),鑒于本人對 netty 比較熟,并且對比同樣實(shí)現(xiàn)了 socket.io 協(xié)議的其他框架,這個(gè)框架的口碑要更好一些,因此選擇這個(gè)框架作為底層核心。
任何開源框架都避免不了 bug 的存在,我們在使用這個(gè)開源框架的時(shí)候,就遇到一個(gè)堆外內(nèi)存泄露的 bug,鑒于對 netty 比較熟,于是接下來便想挑戰(zhàn)一下,找出那只臭蟲(bug),接下來便是現(xiàn)象和排查過程,想看結(jié)論的同學(xué)可以直接拉到最后總結(jié)。
現(xiàn)象
某天早上突然收到告警,nginx 服務(wù)端大量5xx
?
我們使用 nginx 作為服務(wù)端 websocket 的七層負(fù)載,5xx爆發(fā)通常表明服務(wù)端不可用。由于目前 nginx 告警沒有細(xì)分具體哪臺機(jī)器不可用,接下來,到 cat(點(diǎn)評美團(tuán)統(tǒng)一監(jiān)控平臺)去檢查一下整個(gè)集群的各項(xiàng)指標(biāo),發(fā)現(xiàn)如下兩個(gè)異常
?
某臺機(jī)器在同一時(shí)間點(diǎn)爆發(fā) gc,同一時(shí)間,jvm 線程阻塞
?
接下來,便開始漫長的 堆外內(nèi)存泄露排查之旅行。
排查過程
階段1: 懷疑是log4j2
線程被大量阻塞,首先想到的是定位哪些線程被阻塞,最后查出來是 log4j2 狂打日志導(dǎo)致 netty 的 nio 線程阻塞(由于沒有及時(shí)保留現(xiàn)場,所以截圖缺失),nio 線程阻塞之后,我們的服務(wù)器無法處理客戶端的請求,對nginx來說是5xx。
接下來,查看 log4j2 的配置文件
?
發(fā)現(xiàn)打印到控制臺的這個(gè) appender 忘記注釋掉了,所以我初步猜測是因?yàn)檫@個(gè)項(xiàng)目打印的日志過多,而 log4j2 打印到控制臺是同步阻塞打印的,接下來,把線上所有機(jī)器的這行注釋掉,以為大功告成,沒想到,過不了幾天,5xx告警又來敲門了,看來,這個(gè)問題沒那么簡單。
階段2:可疑日志浮現(xiàn)
接下來,只能硬著頭皮去查日志,查看故障發(fā)生點(diǎn)前后的日志,發(fā)現(xiàn)了一處可疑的地方
?
在極短的時(shí)間內(nèi),狂打 failed to allocate64(bytes)of direct memory(...)日志(瞬間十幾個(gè)日志文件,每個(gè)日志文件幾百M(fèi)),日志里拋出一個(gè) netty 自己封裝的 OutOfDirectMemoryError,說白了,就是堆外內(nèi)存不夠用了,netty 一直在喊冤。
堆外內(nèi)存泄露,我去,聽到這個(gè)名詞就有點(diǎn)沮喪,因?yàn)檫@個(gè)問題的排查就像 c 語言內(nèi)存泄露一樣難以排查,首先想到的是,在 OOM 爆發(fā)之前,查看有無異常,然后查遍了 cat 上與機(jī)器相關(guān)的所有指標(biāo),查遍了 OOM 日志之前的所有日志,均未發(fā)現(xiàn)任何異常!這個(gè)時(shí)候心里開始罵了……
階段3:定位OOM源
但是沒辦法,只能看著這堆討厭的 OOM 日志發(fā)著呆,妄圖答案能夠蹦到眼前。一籌莫展之際,突然一道光在眼前一閃而過,在 OOM 下方的幾行日志變得耀眼起來(為啥之前就沒想認(rèn)真查看日志?估計(jì)是被堆外內(nèi)存泄露這幾個(gè)詞嚇怕了吧==),這幾行字是 ....PlatformDepedeng.incrementMemory()...。我去,原來,堆外內(nèi)存是否夠用,是 netty 這邊自己統(tǒng)計(jì)的,那是不是可以找到統(tǒng)計(jì)代碼,找到統(tǒng)計(jì)代碼之后我們就可以看到 netty 里面的對外內(nèi)存統(tǒng)計(jì)邏輯了?于是,接下來翻翻代碼,找到這段邏輯,在 PlatformDepedent 這個(gè)類里面
?
這個(gè)地方,是一個(gè)對已使用堆外內(nèi)存計(jì)數(shù)的操作,計(jì)數(shù)器為 DIRECT_MEMORY_COUNTER,如果發(fā)現(xiàn)已使用內(nèi)存大于堆外內(nèi)存的上限(用戶自行指定),就拋出一個(gè)自定義 OOM Error,異常里面的文本內(nèi)容正是我們在日志里面看到的。
接下來,驗(yàn)證一下是否這個(gè)函數(shù)是在堆外內(nèi)存分配的時(shí)候被調(diào)用
?
果然,在 netty 每次分配堆外內(nèi)存之前,都會計(jì)數(shù),想到這,思路開始慢慢清晰起來,心情也開始變好。
階段4:反射進(jìn)行堆外內(nèi)存監(jiān)控
既然 cat 上關(guān)于堆外內(nèi)存的監(jiān)控沒有任何異常(應(yīng)該是沒有統(tǒng)計(jì)準(zhǔn)確,一直維持在 1M),而這邊我們又確認(rèn)堆外內(nèi)存已快超過上限,并且已經(jīng)知道 netty 底層是使用哪個(gè)字段來統(tǒng)計(jì)的,那么接下來要做的第一件事情,就是反射拿到這個(gè)字段,然后我們自己統(tǒng)計(jì) netty 使用堆外內(nèi)存的情況。
?
堆外內(nèi)存統(tǒng)計(jì)字段是 DIRECT_MEMORY_COUNTER,我們可以通過反射拿到這個(gè)字段,然后定期check這個(gè)值,就可以監(jiān)控 netty 堆外內(nèi)存的增長情況。
?
我們通過反射拿到這個(gè)字段,然后每隔一秒打印,我為什么要這樣做?
因?yàn)?#xff0c;通過我們前面的分析,在爆發(fā)大量 OOM 現(xiàn)象之前,沒有任何可疑的現(xiàn)象,那么只有兩種情況,一種是突然某個(gè)瞬間分配了大量的堆外內(nèi)存導(dǎo)致OOM,一種是堆外內(nèi)存緩慢增長,到達(dá)某個(gè)點(diǎn)之后,最后一根稻草將機(jī)器壓垮。這段代碼加上去之后,打包上線。
階段5:到底是緩慢增長還是瞬間飆升?
代碼上線之后,初始內(nèi)存為 16384k(16M),這是因?yàn)榫€上我們使用了池化堆外內(nèi)存,默認(rèn)一個(gè) chunk 為16M,不必過于糾結(jié)。
?
沒過一會,內(nèi)存就開始緩慢飆升,并且沒有釋放的跡象,20幾分鐘之后,內(nèi)存如下
?
到了這里,猜測可能是前面提到的第二種情況,也就是內(nèi)存緩慢增長造成的 OOM,由于內(nèi)存實(shí)在增長太慢,于是調(diào)整機(jī)器負(fù)載權(quán)重為其他機(jī)器的兩倍,但是仍然是以幾K級別在增長,這天剛好是周五,索性就過他個(gè)一個(gè)周末再開看。
過完一個(gè)愉快的周末之后,到公司第一時(shí)間便是連上跳板機(jī),登錄線上機(jī)器,開始 tail -f 繼續(xù)查看日志,輸完命令之后,懷著期待的心情重重的敲下了回車鍵
?
果然不出所料,內(nèi)存一直在緩慢增長,一個(gè)周末的時(shí)間,堆外內(nèi)存已經(jīng)飆到快一個(gè) G 了,這個(gè)時(shí)候,我竟然想到了一句成語:只要功夫深,鐵杵磨成針!雖然堆外內(nèi)存幾個(gè)K幾個(gè)K的在增長,但是只要一直持續(xù)下去,總有把內(nèi)存打爆的時(shí)候(線上堆外內(nèi)存上限設(shè)置的是2G)。
到了這里,我又開始自問自答了:內(nèi)存為啥會緩慢增長,伴隨著什么而增長?因?yàn)槲覀兊膽?yīng)用是面向用戶端的websocket,那么,會不會是每一次有用戶進(jìn)來,交互完之后,然后離開,內(nèi)存都會增長一些,然后不釋放呢?帶著這個(gè)疑問,我開始線下模擬。
階段6:線下模擬
本地起好服務(wù),把監(jiān)控堆外內(nèi)存的單位改為以B為單位(因?yàn)楸镜亓髁枯^小,打算一次一個(gè)客戶端連接),另外,本地也使用非池化內(nèi)存(內(nèi)存數(shù)字較小,容易看出問題),這樣,服務(wù)端啟動(dòng)之后,控制臺打印信息如下
?
在沒有客戶端接入的時(shí)候,堆外內(nèi)存一直是0,在意料之中。接下來,懷著著無比激動(dòng)的心情,打開瀏覽器,然后輸入網(wǎng)址,開始我們的模擬之旅。
我們的模擬流程是:新建一個(gè)客戶端鏈接->斷開鏈接->再新建一個(gè)客戶端鏈接->再斷開鏈接
?
如上圖所示,一次 connect 和 disconnect 為一次連接的建立與關(guān)閉,上圖綠色框框的日志分別是兩次連接的生命周期。我們可以看到,內(nèi)存每次都是在連接被關(guān)閉的的時(shí)候暴漲 256B 然后不釋放,到了這里,問題進(jìn)一步縮小,肯定是連接被關(guān)閉的時(shí)候,觸發(fā)了框架的一個(gè)bug,這個(gè)bug在觸發(fā)之前分配了 256B 的內(nèi)存,然后bug觸發(fā),內(nèi)存沒有釋放。問題縮小之后,接下來開始擼源碼捉蟲!
階段7:線下排查
接下來,我將本地服務(wù)重啟,開始完整的線下排查過程。將目光定位到 netty-socketio 這個(gè)框架的 disconnect 事件(客戶端websocket連接關(guān)閉的時(shí)候回調(diào)用到這里),基本上可以確定是在 disconnect 事件前后申請的內(nèi)存沒有釋放
?
這里,在使用 idea debug的時(shí)候,要選擇只掛起當(dāng)前線程,這樣我們在單步跟蹤的時(shí)候,控制臺仍然可以看到堆外內(nèi)存統(tǒng)計(jì)線程在打印日志。
客戶端連接上之后然后關(guān)閉,斷點(diǎn)進(jìn)入到 onDisconnect 回調(diào),我特意在此多停留了一會,發(fā)現(xiàn)控制臺內(nèi)存并沒有飆升(7B這個(gè)內(nèi)存暫時(shí)沒有去分析,只需要知道,客戶端連接斷開之后,我們斷點(diǎn)hold住,內(nèi)存還未開始漲),接下來,神奇的一幕出現(xiàn)了,我將斷點(diǎn)放開,讓程序跑完
?
debug 松掉之后,內(nèi)存立馬飆升了!!這個(gè)時(shí)候我已經(jīng)知道,這只臭蟲飛不了多遠(yuǎn)了。在 debug 的時(shí)候,掛起的是當(dāng)前線程,那么肯定是當(dāng)前線程某個(gè)地方申請了堆外內(nèi)存,然后沒有釋放,接下來,快馬加鞭,深入源碼。
每一次單步調(diào)試,我都會觀察控制臺的內(nèi)存飆升的情況,很快,我們來到了這個(gè)地方
?
在這一行沒執(zhí)行之前,控制臺的內(nèi)存依然是 263B,然后,當(dāng)執(zhí)行完這一行之后,立馬從 263B漲到519B(漲了256B)
?
于是,bug的范圍進(jìn)一步縮小,我將本次程序跑完,釋然后客戶端再來一次連接,斷點(diǎn)打在 client.send()這行, 然后關(guān)閉客戶端連接,之后直接進(jìn)入到這個(gè)方法,隨后的過程有點(diǎn)長,因?yàn)榕c netty 的時(shí)間傳播機(jī)制有關(guān),這里就省略了,最后,我跟到了如下代碼, handleWebsocket
?
在這個(gè)地方,我看了一處非??梢傻牡胤?#xff0c;在上圖的斷點(diǎn)上一行,調(diào)用 encoder 分配了一段內(nèi)存,調(diào)用完之后,我們的控制臺立馬就彪了 256B,所以,我懷疑肯定是這里申請的內(nèi)存沒有釋放,他這里接下來調(diào)用 encoder.encodePacket() 方法,猜想是把數(shù)據(jù)包的內(nèi)容以二進(jìn)制的方式寫到這段 256B的內(nèi)存,接下來,我跟到這段 encode 代碼,單步執(zhí)行之后,定位到這行代碼
?
這段代碼是把 packet 里面一個(gè)字段的值轉(zhuǎn)換為一個(gè) char,然而,當(dāng)我使用 idea 預(yù)執(zhí)行的時(shí)候,卻拋出類一個(gè)憤怒的 NPE!!也就是說,框架申請到一段內(nèi)存之后,在encoder的時(shí)候,自己GG了,自己給自己挖了個(gè)NPE的深坑,最后導(dǎo)致內(nèi)存無法釋放(最外層有堆外內(nèi)存釋放邏輯,現(xiàn)在無法執(zhí)行到了),然后越攢越多,越攢越多,直到最后一根稻草,堆外內(nèi)存就這樣爆了,這里的源碼有興趣的讀者可以自己去分析一下,限于篇幅原因,這里就不再分析了。
階段8:bug解決
bug既然已經(jīng)找到,接下來便要解決了,這里只需要解決這個(gè)NPE異常,就可以fix掉,我們的目標(biāo)就是,讓這個(gè) subType 字段不為空,我們先通過 idea 的線程調(diào)用棧定位到這個(gè) packet 是在哪個(gè)地方定義的
?
我們找到 idea 的 debugger 面板,眼睛盯著 packet 這個(gè)對象不放,然后上線移動(dòng)光標(biāo),便光速定位到,原來,定義 packet 對象這個(gè)地方在我們前面的代碼其實(shí)已經(jīng)出現(xiàn)過,我們查看了一下 subType 這個(gè)字段,果然是null,接下來,解決bug很容易。
?
我們給這個(gè)字段賦值即可,由于這里是連接關(guān)閉事件,所以,我給他指定了一個(gè)名為 DISCONNECT 的字段(改日深入去研究socket.io的協(xié)議),反正這個(gè)bug是在連接關(guān)閉的時(shí)候觸發(fā)的,就粗暴一點(diǎn)了 !==。
解決這個(gè)bug的過程是:將這個(gè)框架的源碼下載到本地,然后加上這一行,最后,我重新build一下,pom 里改改名字,推送到我們公司的倉庫,這樣,我項(xiàng)目就可以直接使用了。
改完bug之后,習(xí)慣性地去github上找到引發(fā)這段bug的commit
好奇的是,為啥這位 dzn commiter 會寫出這么一段如此明顯的bug,而且時(shí)間就在今年3月30號,項(xiàng)目啟動(dòng)的前夕,真是無比尷尬呀 ?
階段9:線下驗(yàn)證
一切就緒之后,首先,我們來進(jìn)行本地驗(yàn)證,服務(wù)起起來之后,我瘋狂地建立連接,瘋狂地?cái)嚅_連接,觀察堆外內(nèi)存的情況
?
好家伙,不管你如何斷開連接,堆外內(nèi)存一直不漲了,至此,bug 基本fix,當(dāng)然,最后一步,我們把代碼推到線上驗(yàn)證。
階段10:線上驗(yàn)證
這次線上驗(yàn)證,我們避免了比較土的打日志方法,我們把堆外內(nèi)存的這個(gè)指標(biāo)噴射到 cat上,然后再來觀察一段時(shí)間的堆外內(nèi)存的情況
?
發(fā)現(xiàn)過一段時(shí)間,堆外內(nèi)存已經(jīng)穩(wěn)定不漲了,我們的捉蟲之旅到此結(jié)束!最后,我來給本次捉蟲之旅做一次總結(jié)
總結(jié)
1.遇到堆外內(nèi)存泄露不要怕,仔細(xì)耐心分析,總能找到思路,要多看日志,多分析。
2.如果使用了netty 堆外內(nèi)存,那么你可以自行監(jiān)控堆外內(nèi)存的使用情況,不需要借助第三方工具,我這里是使用的反射拿到的堆外內(nèi)存的情況。
3.逐漸縮小范圍,直到bug被你找到。當(dāng)你確認(rèn)某個(gè)線程的執(zhí)行帶來 bug 的時(shí)候,可單步執(zhí)行,可二分執(zhí)行,定位到某行代碼之后,跟到這段代碼,然后繼續(xù)單步執(zhí)行或者二分的方式來定位最終出 bug 的代碼,這個(gè)方法屢試不爽,最后總能找到bug。
4.熟練掌握 idea 的調(diào)試,讓你的捉蟲速度快如閃電,這里,最常見的調(diào)試方式是預(yù)執(zhí)行表達(dá)式,以及通過線程調(diào)用棧,死盯某個(gè)對象,就能夠掌握這個(gè)對象的定義,賦值之類。
總結(jié)
以上是生活随笔為你收集整理的一次堆外内存泄露的排查过程的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Mac电脑的睡眠状态如何设置如何设置电脑
- 下一篇: 路由器要怎样设置网速快路由器如何设置网速