:Java OOM堆轉(zhuǎn)儲與內(nèi)存泄漏根因定位指南)
簡介Eclipse MATMemory Analyzer Tool完整軟件包適用于Java開發(fā)與性能優(yōu)化人員用于解析JVM堆轉(zhuǎn)儲文件定位內(nèi)存泄漏、分析對象引用關系。壓縮包共5442個文件約133.56MB以HTML幫助文檔、PNG圖解、JAR組件為主同時包含Windows可執(zhí)行程序、批處理啟動腳本、XML/Properties配置以及Hprof格式示例堆轉(zhuǎn)儲覆蓋工具獨立運行或在Eclipse中使用的完整目錄結(jié)構(gòu)便于離線查閱與二次開發(fā)。已有367人學習下載適合后端開發(fā)、運維與性能調(diào)優(yōu)場景。借助包內(nèi)文檔和示例讀者可直接搭建分析環(huán)境實踐快照導入、Leak Suspects報告、支配樹、直方圖、重復對象檢測、引用鏈追蹤等核心功能系統(tǒng)掌握從生成堆轉(zhuǎn)儲到確認內(nèi)存瓶頸的排查流程從而有效提升Java應用性能優(yōu)化能力。1. 從OOM到真相Eclipse MAT為什么是排查內(nèi)存問題的第一選擇線上服務每隔幾小時就OOM重啟后能撐一會兒但始終找不到元兇。這是很多Java后端都經(jīng)歷過的場景抓了線程棧、翻了GC日志什么都看不出來。直到我習慣性地用Eclipse MAT打開heap dump十幾分鐘就看到了從GC Roots到泄漏對象的完整引用鏈。Eclipse MAT這個工具就是專門干這個的——它不分析業(yè)務日志而是分析JVM在內(nèi)存溢出時留下的“案發(fā)現(xiàn)場”堆轉(zhuǎn)儲文件用支配樹、泄漏報告和歷史對象把“哪塊內(nèi)存大、誰在引用它、為什么沒被回收”講清楚。適合Java服務端、Android開發(fā)、中間件維護的人。這篇筆記把生成dump、配置環(huán)境、讀報告、定位代碼、避坑到自動化整個流程拆開。2. 堆轉(zhuǎn)儲獲取三招拿到dump并配好MAT運行環(huán)境2.1 三種常見方式jmap、JVM參數(shù)、Arthas工具再強沒有高質(zhì)量的dump也白搭。所謂高質(zhì)量dump指的不是文件有多大而是能真實還原“OOM那一刻的內(nèi)存狀態(tài)”。不同方式抓到的dump偏差很大。最常用的是jmap。先用jps -l找到Java進程PIDjps -l # 輸出類似 # 23345 /app/service.jar拿到PID后執(zhí)行jmap -dump:live,formatb,file/tmp/heap.hprof 23345live表示只導出存活對象會先觸發(fā)一次Full GC不加live則是原始堆的全部對象包括很多已經(jīng)不可達但還沒被GC的“垃圾”。對于定位泄漏我通常不加live因為Full GC會把那些本來可以被回收但被錯誤持有的對象清掉反而讓引用鏈斷掉。比如一個靜態(tài)Map里放了海量緩存對象如果先觸發(fā)Full GC這些對象因為可達不會被清掉但很多臨時對象被裁掉后更大的問題是我們可能看不到“泄漏對象增長前的完整背景”。不過live模式產(chǎn)出的dump小方便傳輸。JVM參數(shù)方式適合“守株待兔”java -Xmx2048m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/app/logs/ \ -jar my-service.jar一旦進程拋出OutOfMemoryErrorJVM會在崩潰前把堆寫入指定目錄文件名類似java_pid23345.hprof。這個dump代表的是“溢出的瞬間”里面的對象分布最有說服力也是我排查線上問題的首選。補充一點如果進程不是為了預防OOM而加的參數(shù)而是已經(jīng)正在頻繁O(jiān)OM那么只能先重啟再加參數(shù)等下一次爆。Arthas的heapdump命令適合主動采樣heapdump /tmp/arthas-hprof.hprof它不會觸發(fā)Full GC也不影響業(yè)務適合懷疑內(nèi)存緩慢上漲但還沒到極限的時候。它會拿到當下一整份堆對象包括大量死對象分析時需要用直方圖/支配樹里按字節(jié)篩。下表是我平時選型的參考方式觸發(fā)時機優(yōu)點缺點jmap手動隨時簡單直接能控制是否live大堆時可能阻塞JVM線上慎用JVM參數(shù)OOM自動還原現(xiàn)場無人工干預要提前配置靠一次OOM等待Arthas heapdump手動臨時在線操作無需重啟沒有OOM上下文dump偏大無論哪種方式拿到文件后先看大小。如果文件比你的堆內(nèi)存小很多比如-Xmx2G但hprof只有幾百MB很可能抓的時候已經(jīng)Full GC過或者用了live選項不要指望它還原最真實的問題現(xiàn)場。2.2 打開超大dump改ini、選對JVM架構(gòu)MAT本質(zhì)是一個Eclipse RCP程序它分析dump時會把整個堆構(gòu)建成圖模型內(nèi)存開銷不容小覷。默認MemoryAnalyzer.ini里的-Xmx往往只有1GB遇到2GB以上的dump會立刻報錯。打開安裝目錄下的MemoryAnalyzer.ini修改或添加-vmargs -Xmx6144m -XX:UseParallelGC-Xmx給到dump文件大小的80%120%比較穩(wěn)妥。例如dump是8GB就設-Xmx8g或-Xmx9g。注意如果本機內(nèi)存不夠?qū)幙蛇x小dump或者在MAT里用“分塊讀取”的思路但實際那只是調(diào)整分析粒度并不能真正繞開內(nèi)存上限。還有一個容易忽略的架構(gòu)問題MAT必須用64位版本配合64位JDK否則-Xmx超過2GB無效。確認方式是java -version輸出有64-Bit字樣。如果裝了多個版本在ini里顯式指定-vm /path/to/jdk8/bin/javaw在Windows上這行要寫在文件開頭慢一步又會用錯的啟動器。參數(shù)合理解釋UseParallelGC只是給MAT進程用的垃圾回收器對分析本身沒太大影響主要是減少GC停頓也有人用UseConcMarkSweepGC但新JDK里CMS被移除了所以推薦用UseParallelGC或G1GC。2.3 臨時目錄與dump完整性磁盤滿是一切玄學的起點MAT解析時會生成大量中間文件默認寫到系統(tǒng)臨時目錄比如Linux的/tmp。一個5GB的dump解析過程可能需要額外510GB臨時空間。如果/tmp所在的磁盤滿了MAT往往不會直接提示“磁盤滿”而是拋奇怪的OutOfMemoryError或FileNotFoundException讓人誤以為內(nèi)存不夠。我通常會在啟動腳本里改掉臨時目錄export JAVA_TOOL_OPTIONS-Djava.io.tmpdir/data/mat-tmp mkdir -p /data/mat-tmp或者直接在MemoryAnalyzer.ini里加-Djava.io.tmpdir/data/mat-tmp保證該目錄有足夠空間。另外解密后的dump文件本身建議放在本地固態(tài)盤放在NFS或網(wǎng)絡盤上會讓解析速度慢到人崩潰。拿到dump后驗證完整性也可以用jhat或MAT自己來不過更簡單的是看文件頭二進制hprof以JAVA PROFILE開頭注意可能不是純文本。如果文件是從FTP上下載到本地的先對比md5不然分析結(jié)果會莫名缺類。3. MAT報告解讀從Leak Suspects到支配樹3.1 Leak Suspects報告真正告訴你什么打開MAT等待分析完成默認會彈出Leak Suspects視圖標題是“Hello, World”之類的話都別管直接看下面幾條帶紅色圖標的信息。它把“最嫌疑的泄漏點”按內(nèi)存占用從大到小列出來每一項包含一句話簡介、關鍵字、原始堆棧。注意這里的“Suspect”是一種基于“保留集大小”的啟發(fā)式判斷不是指業(yè)務上的泄漏。它會估算某個Root對象鏈上累計存活對象的大小如果一個集合占了60%堆空間它就會列為頭號嫌疑對象。來看一個例子。某線程池場景MAT報告第一條寫著Class xxx.thread.WorkerThread 0x7f2d... dominates 2.1GB (62.5%) of the heap這說明了該線程棧上的局部變量持有了一塊巨大的對象圖通常就是這里面的業(yè)務結(jié)構(gòu)沒有釋放。點擊進去可以查看“從一個根到該對象的完整引用鏈”從線程對象開始經(jīng)過AbstractExecutorService然后是你的業(yè)務類字段一層層下去。我一般不會只盯第一條會把所有Suspects都看一遍因為大對象圖的根不一定只有一個。有時候兩條不同引用鏈同時指向同一個共享緩存Leak Suspects會分開列但根源是同一個。3.2 支配樹從大對象回溯GC Roots如果只看Suspects不夠打開Dominator Tree支配樹。它按每個對象“支配”的對象大小排序這里的支配是指“如果A被回收那么B也一定會被回收”A就是支配者。它不關心線程和GC Roots關系只關注大塊對象在堆里的歸屬。操作路徑是Histogram上方小圖標切換到Dominator Tree然后按Retained Heap列從大到小排序。你會看到最頂層一般是java.util.HashMap$Node[]或char[]之類這是因為集合底層的數(shù)組就是大塊內(nèi)存的物理載體。點開每個節(jié)點能往下展開子引用找出誰持有了這些數(shù)組。舉一個白盒排查HashMap的Entry數(shù)組占1.5GB展開后發(fā)現(xiàn)它的key全部是String而且這些String的value長度都在幾十KB。再順著value看發(fā)現(xiàn)value是byte[]最終業(yè)務類是“發(fā)送MQ消息時同時把整個message body塞進了靜態(tài)ThreadLocal”。這種從物理大對象倒推業(yè)務類的路徑是MAT最核心的價值。3.3 直方圖與路徑追蹤不只看總量還要看GC RootsHistogram視圖按類匯總實例數(shù)和Shallow Heap/Retained Heap占用。它不僅告訴你哪類對象多還能配合右鍵菜單做“歸因”分析。常用動作篩選類名包含業(yè)務包關鍵字的類右鍵Merge Shortest Paths to GC Roots選擇exclude all phantom/weak/soft references就能看到從GC Roots到該類的強引用路徑。這里要區(qū)分“強引用”和“軟引用”排查內(nèi)存泄漏時過濾掉弱引用才能看到誰真正不讓對象死。如果路徑顯示只有Thread和ThreadLocal那基本說明是線程持有了對象如果路徑走的是靜態(tài)成員比如Cache.instance那就去業(yè)務代碼里檢查這個靜態(tài)字段為什么沒清理。這一步非常依賴包名定位所以dump里的對象類型名稱足夠干凈的話就像查SQL一樣定位代碼。再補充一個細節(jié)在路徑追蹤時如果結(jié)果是System Class說明對象被JDK類直接引用比如作為類加載器數(shù)據(jù)的一部分。這種情況往往不是業(yè)務泄漏但可能和自定義類加載器有關。我的做法是看它下面的子路徑找離業(yè)務類最近的那個節(jié)點。4. 定位代碼問題把MAT結(jié)果映射到業(yè)務模塊4.1 OQL像SQL一樣篩出可疑集合MAT自帶OQLObject Query Language可以精確計算符合條件的對象實例。比如業(yè)務上有個ConcurrentHashMap想問它當前有多少個keySELECT m.keySet() FROM java.util.concurrent.ConcurrentHashMap m也可以查所有長度超過1000的字符串SELECT s.value, s.count FROM java.lang.String s WHERE s.value.length 1000執(zhí)行后在結(jié)果窗口能看到具體值和實例ID再右鍵跳到某個實例在References里查引用它的對象。這個方法非??炷鼙荛_“看大圖找花眼”。我常用OQL去驗證懷疑如果看到一堆String并且它們的value內(nèi)容都是某個接口的請求參數(shù)就能馬上定位到是緩存沒移除還是日志框架把請求體串成字符串存在內(nèi)存里。再舉個例子懷疑某個線程池的隊列塞滿了任務可以用OQL直接看隊列里有哪些對象SELECT t.workQueue FROM java.util.concurrent.ThreadPoolExecutor t如果返回的workQueue不是空再配合直方圖看隊列長度占用。這種方法比在GUI里一遍遍翻對象快得多。4.2 線程棧與局部變量把對象和調(diào)用棧對上很多時候泄漏的根在活躍線程的局部變量里。在MAT的Thread Overview視圖能看到每個線程棧上引用的對象大小。點進去可以看到線程的棧幀(StackFrame)對應的類名和方法以及對應局部變量、動態(tài)變量、靜態(tài)變量的保留堆。常見翻車現(xiàn)場一個循環(huán)創(chuàng)建任務的任務隊列每輪任務都往一個鏈表中塞數(shù)據(jù)但鏈表卻被一個工作線程的字段引用線程一直不退出。從Thread Overview里會看到該線程棧的引用路徑指向你的阻塞隊列實現(xiàn)類再順藤摸瓜找到那個“Listbyte[]”。這里提醒一下MAT展示的“棧幀”不是你業(yè)務方法的所有行號很多是經(jīng)過JIT優(yōu)化的底部棧幀但至少能知道是哪一類方法持有的。如果某個棧幀的保留堆異常大基本上這個方法的入?yún)⒒蚓植繉ο缶陀袉栴}。4.3 核心邏輯找出“保留集”最大且持續(xù)增長的根定位代碼的核心不是看瞬間大而是看“誰在持續(xù)變大”。我看到很多同事只查了一次dump發(fā)現(xiàn)某個List很大就斷定它泄漏。但要知道如果這個List本來就是設計成緩存大是正常的。真正的問題是它是否無限增長、沒有淘汰機制。所以排查時要結(jié)合兩份dump對比同一服務運行不同時間點的dump用MAT的Compare功能。勾選兩份dump文件做歷史對比在直方圖里能看到哪些類實例數(shù)翻倍、哪些穩(wěn)定。翻倍的那個類就是嫌疑最大的。代碼定位的手段三件套一看引用路徑二看GC Root類別三看對象字段業(yè)務含義。把這三樣拼起來基本能寫出對應的修復代碼。比如把static List改成按時間淘汰的Guava Cache或把ThreadLocal在finally里remove。實際操作時我習慣先把兩份dump的直方圖導出成CSV然后用腳本對比這樣能自動列出增長比例超過50%的類。MAT的Compare功能只能看兩個列表數(shù)據(jù)量大的時候容易看漏導出后處理反而更直觀。注意Mat導出CSV的默認列可能不含Retained Heap要自己勾選。5. 避坑/常見問題那些讓我白熬夜的MAT坑5.1 現(xiàn)象dump打不開報錯“Java heap space”或直接崩原因MAT默認啟動堆太小或者本機物理內(nèi)存不足。也可能是dump文件本身損壞或非標準hprof格式。解決先改MemoryAnalyzer.ini里的-Xmx再確認64位JDK。如果仍然失敗檢查文件頭是否是JAVA PROFILE。Android的dump要先跑hprof-conv命令JRockit的dump則要用-Dcom.ibm.jvm.hprof.format1之類但實際項目中我很少碰到直接換工具更省心。還有一次我遇到打開dump一直轉(zhuǎn)圈后來發(fā)現(xiàn)是殺毒軟件在掃描臨時文件把MAT的索引文件當惡意軟件隔離了。這種情況在Windows上比較容易出現(xiàn)把MAT的臨時目錄加到白名單就能解決。5.2 現(xiàn)象Leak Suspects報出來的對象不是真正的泄漏原因啟發(fā)式算法只論大小不論業(yè)務。一個占用幾十MB的靜態(tài)緩存也會被列為嫌疑但它可能設計如此。解決看“Suspects”不要只信第一屏配合History和Compare兩份dump。真正泄漏的對象在兩次dump之間實例數(shù)應該呈增長趨勢。如果一份dump里對象大但另一份同樣大說明是穩(wěn)定占用不是泄漏。我自己就曾經(jīng)把一個大緩存翻來覆去排查最后發(fā)現(xiàn)它就是正常的預加載數(shù)據(jù)浪費了半天。另外Leak Suspects里的百分比是基于“保留集”估算的它會把一個集合下所有可達對象都算進去。有些對象雖然占大但它是為了支撐某個核心功能比如權限表全量緩存、業(yè)務配置項這些不能算泄漏。所以看到第一條嫌疑不要急著動刀先雙擊看引用路徑是不是從系統(tǒng)類出發(fā)的。5.3 現(xiàn)象明明用-XX:HeapDumpOnOutOfMemoryError但OOM時沒生成dump原因OOM類型可能是堆外內(nèi)存溢出DirectBuffer、棧溢出、或者寫dump時磁盤沒空間。如果是在容器里跑也可能是寫dump的目錄不被允許。解決確認HeapDumpPath目錄存在并且有寫權限用jcmd pid GC.heap_dump手動觸發(fā)試一下同時監(jiān)控物理內(nèi)存是否被元空間或直接內(nèi)存占滿。經(jīng)驗是如果錯誤是OutOfMemoryError: Direct buffer memory這個參數(shù)不生效需要單獨排查Netty或NIO的DirectMemory使用。還有種情況是OOM發(fā)生前JVM已經(jīng)進入“內(nèi)存耗盡”狀態(tài)沒有足夠內(nèi)存來寫dump。這時可以在啟動參數(shù)里加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/tmp之外再加-XX:ExitOnOutOfMemoryError讓進程在寫失敗時直接退出避免半死不活地卡在那。不過這參數(shù)生產(chǎn)要慎用會影響可用性。5.4 現(xiàn)象用jmap導出時JVM卡頓或停止響應原因jmap導出需要暫停應用線程遍歷堆服務線程多、堆大時會造成明顯STW。解決優(yōu)先使用jmap -dump:live雖然它先做Full GC但后續(xù)遍歷快或者使用帶-F選項強制導出。但-F對運行中的進程很危險不推薦生產(chǎn)用更好的做法是提前加OOM參數(shù)自動dump或者通過JMX端點觸發(fā)HotSpotDiagnostic不過本質(zhì)上同樣會STW。所以線上建議用自動dump來規(guī)避主動抓導致的長時間停頓。另外jmap在JDK 11以后跟jcmd合并了部分版本里jmap不帶路徑參數(shù)會有交互式提示寫腳本時要注意。我一般寫腳本用jcmd pid GC.heap_dump /tmp/dump.hprof這個命令更穩(wěn)不會因為交互卡住。5.5 現(xiàn)象分析出來的對象引用鏈指向JDK內(nèi)部類看不到業(yè)務類原因業(yè)務代碼可能已經(jīng)通過反射或者Unsafe引用或者對象本身是在庫中創(chuàng)建的且你的業(yè)務類沒有強持有它。解決在引用路徑里展開Reference部分或者使用OQL搜索字段中是否含有業(yè)務類名的對象。比如SELECT * FROM com.example.Foo f WHERE f.data ! null。如果實在找不到查看這個對象的類型以及它的生成工廠往往是框架代碼如Netty的PooledByteBuf造成的不一定是業(yè)務Bug而是分配策略問題。這種情況我最常遇到的是java.nio.DirectByteBuffer它本身占用的堆外內(nèi)存MAT并不直接顯示但引用了它的堆內(nèi)Cleaner對象會出現(xiàn)在堆里。如果DirectByteBuffer數(shù)量很大先去查Netty的分配器參數(shù)是否合理而不是去業(yè)務代碼里找。6. 進階讓MAT分析自動化在出問題前拿到報告6.1 命令行批處理headless生成報告每次手動打開GUI等很久很煩MAT也提供了headless模式??梢杂霉俜降腜arseHeapDump腳本生成Leak Suspects報告而不打開界面。常見做法./ParseHeapDump.sh /path/heap.hprof org.eclipse.mat.api:TopComponents \ /path/report/其中org.eclipse.mat.api是內(nèi)置查詢集也可以替換為LeakSuspects、DuplicateClasses等。生成結(jié)果是一個report目錄包含Text和HTML格式的分析報告。我一般周末掛個定時任務把每天凌晨的dump自動分析了第二天直接看報告。這里注意ParseHeapDump的第二個參數(shù)是查詢集名稱不是任意寫。你可以先用GUI里跑一次看到Window Preferences Memory Analyzer Reports里有哪些模板再拿模板名去命令行。不同MAT版本模板略有差異以實際為準。6.2 與CI集成讓“內(nèi)存超標”變成構(gòu)建失敗真正有用的做法是把MAT檢查和發(fā)布流程綁定。寫一個腳本在壓測場景穩(wěn)定復現(xiàn)后自動跑MATjava -jar mat_demo.jar -reporter heap.hprof -template LeakSuspects -out report.txt這里只是示意實際用的還是ParseHeapDump帶參數(shù)。生成的結(jié)果里如果存在OutOfMemoryError線索或者某個類的dominatedSize超過閾值比如堆的30%就讓構(gòu)建失敗。這樣能讓內(nèi)存泄漏盡早暴露在測試環(huán)境而不是等線上炸了再補救。我在某個模擬項目X里就是把這次分析腳本寫成了Jenkins流水線的一步壓測結(jié)束后抓heap dump跑MAT解析報告里的dominatedSize最大值超過閾值就發(fā)告警。后面有一次提前發(fā)現(xiàn)了一個緩存的Key設計不合理避免了上線后兩小時OOM的翻車事故。最后說個習慣我把每份dump都存了文件名帶時間戳目錄按日期和版本歸檔。這樣不管什么時候排查都能拿同一服務在不同版本下的歷史數(shù)據(jù)做對比。從那以后每次線上OOM我都強制走一遍固定流程拿自動dump、對比兩份、過濾弱引用、再上OQL確認十分鐘內(nèi)基本能判斷是泄漏還是配置問題。希望這篇筆記能幫你在下一次面對內(nèi)存報警時少走那些我曾走過的彎路。本文還有配套的精品資源點擊獲取