中的內(nèi)存泄漏排查思路與實戰(zhàn)案例)
內(nèi)存泄漏在Java世界里從來不是一道顯性的報錯而是一場緩慢的失血。當GC日志顯示堆占用曲線像臺階一樣只升不降當應(yīng)用在運行數(shù)天后從秒級響應(yīng)退化到持續(xù)Full GC你才會意識到那些看不見的對象引用已經(jīng)像野草一樣纏住了新生代和老年代。真正的排查不是背誦八股文而是沿著引用鏈一寸一寸地挖出那個本該被回收卻始終存活的對象??此啤氨会尫拧钡膬?nèi)存其實還攥在手里很多開發(fā)者以為只要把對象置為null或者讓局部變量走出作用域內(nèi)存就會安全歸還。這種直覺在絕大多數(shù)情況下成立但偏偏在集合、緩存、線程池、類加載器這些“容器型”結(jié)構(gòu)中失效。一個最經(jīng)典的場景是靜態(tài)集合持有短生命周期對象有人為了方便把用戶Session塞進一個static HashMap卻忘了在會話銷毀時remove。這個Map自己永遠不會被回收于是它里面的所有鍵值對都成了老年代的釘子戶。你去看堆dump能發(fā)現(xiàn)成千上萬個看似無關(guān)的對象被同一個Map的Entry引用著根節(jié)點是一條靜態(tài)字段。還有一種隱蔽的形態(tài)出現(xiàn)在ThreadLocal的使用中。ThreadLocalMap的生命周期依附于Thread而線程池里的線程是復用的不會退出。如果你在請求處理中往ThreadLocal里寫入了數(shù)據(jù)又沒有調(diào)用remove那么下一次請求復用這個線程時上一次的數(shù)據(jù)仍然掛在線程上。更嚴重的是ThreadLocalMap的Entry是弱引用key、強引用value一旦value指向了一個重量級對象比如數(shù)據(jù)庫連接、業(yè)務(wù)上下文即使key已經(jīng)被GC回收value依然被強引用鏈釘死。線程池不死ThreadLocal里的殘留對象就永不超生。如果抱著“內(nèi)存泄漏就是對象太多”的想法去排查你可能會試圖調(diào)大堆內(nèi)存但那只會把失血變成一次更漫長的崩潰。真正有效的做法是先承認泄漏點一定藏在一個不經(jīng)意的“強引用”里然后借助工具把每一個存活對象的GC根路徑畫出來。從GC日志里嗅出異常的信號排查的第一步從來不是馬上抓dump而是先觀察現(xiàn)象。打開GC日志如果發(fā)現(xiàn)老年代占用在每次Full GC后并沒有回落到接近基線水位而是呈鋸齒狀緩慢爬升同時Full GC的頻率越來越高那么基本可以判定存在持續(xù)的對象堆積。另一個信號是堆內(nèi)存使用率即使在高并發(fā)低谷期也降不下來——正常情況下Minor GC過后新生代應(yīng)該有明顯騰空而老年代應(yīng)該保持穩(wěn)定。如果老年代在低峰期的占用依然高于啟動初期的兩倍以上先別調(diào)堆去找誰在持有它們。接下來需要確認是“真正泄漏”還是“內(nèi)存增長”。兩者在GC日志上的區(qū)別是內(nèi)存增長往往隨著流量波動而起伏高峰期上去低谷期能恢復真正的泄漏則是“只升不降”的單調(diào)趨勢。為了讓特征更明顯你可以故意讓系統(tǒng)在低負載下運行一段時間連續(xù)做幾次Full GC然后比較堆占用。如果Full GC之后老年代占用依然紋絲不動地維持在高位那泄漏就實錘了。拿到這個判斷后不要急著上去抓dump因為大數(shù)據(jù)dump在復雜生產(chǎn)環(huán)境中往往超過幾個GB分析起來極其痛苦。更優(yōu)雅的姿勢是先利用jstat實時觀測各區(qū)間的變化確認哪個區(qū)域在持續(xù)膨脹。比如jstat -gcutil pid 1000每一秒打印一次Eden、Survivor、Old區(qū)的使用比例。如果你看到Old區(qū)不斷上升而Eden區(qū)上下波動正常說明大量長期存活對象正從新生代晉升到老年代但這些對象本不該長壽。結(jié)合GC日志中的晉升耗時和暫停時間能進一步鎖定“晉升潮”發(fā)生的時段再回去追溯那個時段內(nèi)到底處理了什么類型的業(yè)務(wù)請求。抓dump要講究時機和姿勢抓heap dump有幾種方式各自適用不同場景。如果進程還活著并且JVM還沒完全卡死建議用jmap -dump:live,formatb,fileheap.hprof pid。其中的-live參數(shù)會先觸發(fā)一次Full GC再dump存活對象——這樣一來臨時垃圾都被過濾掉剩下的都是“真正存活”的對象分析時噪聲更小。但要記住使用live模式會觸發(fā)一次完整的STW暫停在高峰期操作極有可能引起線上告警所以最好安排在流量低峰或者維護窗口。另一種思路是用jcmd GC.heap_dump代替jmap因為jcmd不經(jīng)過JDI接口在某些高版本JVM上更穩(wěn)定。如果你懷疑泄漏與堆外內(nèi)存或DirectBuffer有關(guān)還得額外抓NMTNative Memory Tracking數(shù)據(jù)先啟動時加-XX:NativeMemoryTrackingsummary然后通過jcmd pid VM.native_memory對比運行前后的差值。拿到dump后分析工具首選Eclipse MAT。打開一個幾個GB的hprof文件時你第一眼看到的是Histogram但別急著點類名排列。最關(guān)鍵的視圖是“Histogram - List objects - with outgoing references”它能展示某個類的所有實例以及它們各自引用了什么。比如你發(fā)現(xiàn)一個DTO對象有100萬個實例每個實例都指向一個業(yè)務(wù)Service這明顯不正?!驗镾ervice應(yīng)該是單例不該被DTO持有。順著outgoing reference往下鉆你會看到一條引用鏈GC根 - 線程對象 - ThreadLocalMap - Entry - DTO。至此元兇浮出水面。一個真實的內(nèi)存泄漏排查記錄以下案例來自一個保險平臺的核保服務(wù)現(xiàn)象是每運行約36小時接口平均響應(yīng)延遲從30ms惡化到3秒Full GC從一天兩次暴增到每小時15次。首先用jstat觀察發(fā)現(xiàn)老年代使用率從啟動時的800MB持續(xù)攀升即使深夜無人使用也保持每小時200MB的速度增加。值班團隊一度認為是規(guī)則引擎加載的費率表太多但關(guān)閉了一部分規(guī)則后攀升趨勢并未改變。通過MAT分析live dump發(fā)現(xiàn)一個名為UnderwritingContext的類占據(jù)了堆內(nèi)存的42%實例數(shù)量高達19萬。它內(nèi)部有一個MapString,Object attributes這個Map的鍵是保單號值是龐大的核保因子對象。正常情況下一個保單處理完這個Context就應(yīng)該被丟棄。為什么會積攢這么多查看該實例的GC根引用MAT顯示每個UnderwritingContext都被一個java.lang.ThreadLocal所引用而Thread的引用鏈指向了一個名為threadPool-rule-engine-3的線程。可以斷定代碼在規(guī)則引擎的執(zhí)行線程里用ThreadLocal緩存了Context來避免重復傳參但在任務(wù)結(jié)束時的finally塊中只清空了業(yè)務(wù)主流程里顯式持有的一個引用卻忘了執(zhí)行ThreadLocal對象的remove方法。由于線程池復用了核心線程每個線程的ThreadLocalMap中始終殘留上一次任務(wù)塞進去的Context而ThreadLocalMap的value持有強引用于是每處理一單內(nèi)存里就多一粒永存種子。修復方式很簡單在finally塊里調(diào)用contextThreadLocal.remove()而不是僅僅設(shè)置為null。注意先remove再置null才是正確姿勢因為remove會同時清理ThreadLocalMap的Entry而置null只是斷開了你手上的引用線程內(nèi)部還持有它。上線后觀察48小時老年代曲線變得平滑F(xiàn)ull GC回歸正常。那類你永遠猜不到的泄漏類加載器與字符串駐留不是所有內(nèi)存泄漏都那么“符合直覺”。有一種高難度的泄漏發(fā)生在應(yīng)用服務(wù)器或熱部署場景下每一次重新部署JVM都會創(chuàng)建一個新的類加載器而如果你不慎讓舊加載器加載的對象被全局靜態(tài)變量引用那整個類加載器連同它加載的所有類就永遠無法被卸載。用MAT排查這類問題時類名會成為最顯眼的線索——你會看到同一份業(yè)務(wù)類的全限定名出現(xiàn)了成百上千個不同版本每個版本后面帶著不同的ClassLoader地址。如果你還同時使用了反射/字節(jié)碼增強CGLib或ASM動態(tài)生成的類這些東西又反過來引用了觸發(fā)定義它的那個類加載器泄漏就會滾雪球一樣越滾越大。另一個隱蔽場景是String.intern()的濫用。JDK 8及以后字符串常量池保存在堆內(nèi)。如果業(yè)務(wù)代碼對無窮多的UUID、訂單號或長文本執(zhí)行了intern()這些字符串就進入常量池并永遠不會被GC回收。一次查詢誤用了intern就可能讓堆在一天內(nèi)多出數(shù)GB無法回收的數(shù)據(jù)。排查這種問題時MAT的Histogram里String數(shù)量異常龐大且每個String的邏輯值五花八門但沒有任何非String對象引用它們——因為它們被常量池這個內(nèi)置數(shù)據(jù)結(jié)構(gòu)所持有。針對類加載器泄漏建議直接用jmap -clstats pid打印出所有類加載器的存活數(shù)量、加載類數(shù)量如果發(fā)現(xiàn)某個第三方插件的加載器活躍對象持續(xù)增長而業(yè)務(wù)不增長就重點看這個加載器。針對intern泄漏沒有捷徑只能通過Code Cache的異常膨脹反推或者通過jcmd pid VM.stringtable查看字符串表的統(tǒng)計觀察數(shù)量是否單調(diào)遞增。如何把排查變成一種條件反射如果你問十年經(jīng)驗的JVM調(diào)優(yōu)專家內(nèi)存泄漏排查有沒有固定的方法論他多半會告訴你真正重要的是形成一套“懷疑順序”。拿到一個可疑的堆形態(tài)先問三個問題有沒有大量生命周期應(yīng)該很短的對象晉升到了老年代誰在引用它們引用者的期望生命周期是多長第一個問題讓GC日志和jstat回答第二個問題讓MAT回答第三個問題則需要你自己梳理業(yè)務(wù)代碼邏輯。排查時千萬不要被業(yè)務(wù)復雜性牽著走??吹揭粋€對象實例數(shù)很多就以為是業(yè)務(wù)量本身過大——那只是表象。內(nèi)存泄漏與高并發(fā)本質(zhì)區(qū)別在于高并發(fā)下對象雖多但年輕代可以回收老年代曲線保持水平泄漏時老年代上升曲線跟業(yè)務(wù)流量無關(guān)。你可以做一個簡單壓力實驗用固定QPS打半小時半小時后停止流量等三次Full GC后看老年代是否回落到實驗前水平?;芈淞司筒皇切孤┎换芈湓倏磀ump。如果你使用的框架是Spring Boot還要額外檢查CGLIB生成的代理類數(shù)量以及Async線程池中的提交隊列是否無界。一個無界的線程池隊列本身就是一種結(jié)構(gòu)性內(nèi)存泄漏因為所有排隊中的Callable和Runnable都持有完整業(yè)務(wù)對象圖。這種情況下dump不會給你任何指向明確的GC根你看到的是大量Task對象排成一列。修復方式不是換GC而是改造成有界隊列并設(shè)置合理的拒絕策略。最后別忘了為你的診斷動作留痕。在排除問題后建議用Arthas或Byteman在線上臨時對某個類的getter方法做一次耗時采樣通過觀察調(diào)用鏈上對象創(chuàng)建頻次來驗證修復效果。真正的內(nèi)存泄漏排查不是一次冒險而是一場系統(tǒng)性考證你必須交替使用工具、日志和代碼推理直到引用鏈上的每一個環(huán)都有了確切的解釋。每當拿到一個看起來不可能的存活對象不要急著懷疑JVM的GC實現(xiàn)先問自己它到底被誰攥著而答案往往就藏在你某個習以為常的靜態(tài)字段或線程局部變量中。