ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Memory Analyzer实战:hprof堆转储分析与OOM内存泄漏排查

Memory Analyzer实战:hprof堆转储分析与OOM内存泄漏排查

简介:MemoryAnalyzer 1.6.1(2016年11月25日发布)Windows x86_64 压缩包,是一款面向Java开发、测试与运维人员的堆内存分析工具,主要用于排查内存泄漏、OOM异常、对象占用过高及垃圾回收效率下降等问题。它基于Eclipse MAT,可读取heap dump,并通过支配树、泄漏嫌疑报告、直方图、引用链与线程概览等视图,定位大对象、异常增长的类以及无法回收的引用路径,适合开发联调、测试压测和生产故障复盘等场景。压缩包共463个文件,约57.99MB,以174个jar为核心运行库,辅以html帮助文档、xml与properties配置、png/gif界面资源、exe与bat启动文件等,目录结构完整,解压后即可独立运行。已有303人学习。借助内置帮助文档和完整运行时文件,读者可快速搭建本地分析环境,结合堆转储完成泄漏点筛查、内存分布核查与引用关系追踪,并据此优化对象生命周期和缓存策略,为代码调优与稳定性排查提供可复用的诊断依据。

1. 一个 40MB 的 zip 包,为什么排查内存问题的人都留着一份

线上服务每隔两天被 OOM Killer 干掉一次,重启就好,第二天照旧;dump 躺在磁盘上 8 个 G,jmap -histo排第一的永远是[B和java.lang.String,看不出是谁在背后捂着不放。这时候缺的不是再猜一轮,而是能把堆快照拆开、指出这 8 个 G 被哪条引用链拴住的工具。Memory Analyzer(下称 MAT)就干这件事:读 hprof、建索引、出支配树和 Leak Suspects 报告、支持 OQL 查询。手上这个MemoryAnalyzer-1.6.1.20161125-win32.win32.x86_64.zip是 Windows 64 位免安装包,解压即用,包名里的 20161125 是构建日期,属于 JDK 8 时代的版本。适合手上有老 dump、机器是 Windows、不想为一次分析装整套 IDE 插件的人。

2. 打开 dump 之前先理解 MAT 的解析模型

很多人拿到 MAT 的第一反应是双击 exe 然后把 8G 的 hprof 拖进去,进度条走到 92% 卡住,风扇狂转十分钟,最后弹一个红叉。这不是工具坏了,是它内部的工作方式和你以为的不一样。MAT 不是"流式读取 dump 然后边读边显示",它要做的是把一份扁平的二进制记录流重组成一张可以双向遍历的对象图,再在这张图上算出支配关系和保留大小。搞清这套模型,你才知道该给多少内存、该等多久、哪些结论不能信。

2.1 一次解析、多次秒开:索引文件到底存了什么

hprof 文件本质上是一串顺序记录:一条记录描述一个类,一条记录描述一个实例,记录里的字段值是对象 ID 而不是真正的引用指针。想回答"这个 char[] 被谁引用"这种反向问题,只靠顺序扫描是做不到的,必须建立从对象 ID 到文件偏移、再到引用者集合的映射。MAT 第一次打开 dump 时做的就是这件事——全量扫一遍,把结果写回 dump 同目录的一组索引文件。以heap.hprof为例,解析完成后目录里会多出这些文件:

文件名作用
heap.hprof.index对象 ID 到文件偏移的主映射,定位任意实例的入口
heap.hprof.o2hprof.index对象 ID 到 hprof 原始记录的偏移,读字段值时用
heap.hprof.a2s.index数组对象到其元素内容的映射
heap.hprof.dominator_tree预计算的支配树,retained heap 依赖它
heap.hprof.inbound反向引用索引,Path to GC Roots 靠它
heap.hprof.threads线程栈快照的解析结果

这几个文件加起来往往和 dump 本身差不多大,所以准备磁盘空间时别只按 hprof 的大小算。好处是只付一次代价:第二次打开同一个 dump,MAT 直接读索引,8G 的文件也能在一两分钟内出首页报告。反过来,如果你每次都把索引删掉重来,那每次都要等全量解析,时间全耗在重复劳动上。

解析阶段还有一个容易被忽略的开关:默认情况下 MAT 会丢弃不可达对象以缩小索引体积。这在排查"已经泄漏但还没被回收"的场景下没问题,因为泄漏对象一定是可达的;但如果你要验证"这批对象是不是本来就该死",就得让解析器把它们留下来,命令行下对应的是保留不可达对象的参数。我的习惯是第一次解析走默认,索引小、速度快;只有当结论指向"这个对象应该被回收了却还在"时,才删掉索引重新解析一遍带完整对象的版本。

2.2 浅堆、深堆与支配树:三把尺子的分工

shallow heap 是对象自身占的字节数,一个String大约 24 字节,一个HashMap的空壳也就 48 字节左右。retained heap 是"把这个对象干掉之后,能连带释放多少内存",它包含对象自己以及只有它能到达的所有下游对象。支配树的定义更拗口一点:如果从 GC Roots 到对象 B 的所有路径都必须经过 A,那么 A 支配 B,B 的 retained heap 就记在 A 头上。

这个定义带来的直接后果是,两个对象共享的下游只算给它们最近的共同支配者,不重复计数。所以支配树给出的数字是可以直接相加的——你按 retained 排前几名的对象加起来,基本就是这堆内存的大头,不会出现重复统计。

举个实际会遇到的例子。一个HashMap里塞了 100 万个 Entry,每个 Entry 的 shallow 是 32 字节,HashMap自己的 shallow 只有 48 字节。在直方图里按 shallow 排序,这个HashMap排到几千名开外,你根本看不见它;按 retained 排序,它可能是 3 个 G,稳居第一。同样的道理,直方图里byte[]永远排前面,但它只是"账面数字",真正把内存攥住的是持有这些数组的业务对象。新手最常干的事就是盯着byte[]一路点下去,点到最后发现是几百个不同的类,然后得出"没有明显泄漏点"的结论。

支配树之外还有一把尺子叫 inbound 引用,也就是"谁引用了我"。它不关心层级,只列直接的引用者,用来回答"这个对象为什么还没被回收"。三者的分工可以这么记:直方图看类型分布,支配树找内存大户,inbound 查引用来源,Path to GC Roots 则把 inbound 一路串到根,给你一条完整的证据链。

2.3 索引与 dump 的绑定关系:改名、搬目录、换版本

索引文件是按 dump 的文件路径和大小绑定的。把heap.hprof改名成heap-2024.hprof,或者把整个目录从 D 盘挪到 E 盘,MAT 下一次打开时会发现索引对不上,要么重新解析,要么直接报错。这不是 bug,是它没法确认你给的文件还是不是当初那个文件。

所以正确的操作顺序是:先把 dump 放到最终位置、改成最终名字,再开始解析。中间要换机器分析,就把整个目录连同索引一起拷走,别只拷 hprof。

用 PowerShell 确认索引是不是已经生成过,比在资源管理器里数文件快:

$dump = "D:\dumps\heap.hprof" $dir = Split-Path $dump $name = Split-Path $dump -Leaf Get-ChildItem $dir -Filter "$name.*" | Select-Object Name, @{n='SizeMB';e={[math]::Round($_.Length/1MB,1)}}, LastWriteTime | Sort-Object Name

这段只是列目录,但能一眼看出索引齐不齐:dominator_tree和inbound缺失说明上次解析没跑完,直接重新解析比硬开更省时间。输出的LastWriteTime也有用,如果索引时间比 hprof 早,那基本可以确定它对不上当前这份 dump。

还有一个版本维度的绑定:索引是 MAT 自己的序列化格式,不同大版本的 MAT 之间不保证能互相读。用 1.6.1 解析出来的索引,交给另一个版本的 MAT 打开,可能提示索引无效然后重来一遍。分析团队里多人协作时,把 MAT 版本统一了,能省掉很多"我这边打不开"的来回。

3. 解压即用的启动流程:从 hprof 到 Leak Suspects 报告

免安装包的好处是没有安装程序、不写注册表,坏处是所有配置都得自己动手,而配置写错的表现往往是一个没有任何信息量的弹窗。这一章按顺序走一遍:先确认 Java 环境对得上,再改启动参数,最后用命令行批量出报告。每一步都给出可以照抄的命令,跑完你应该拿到一个不依赖 GUI 的 zip 报告。

3.1 先核对 JRE:64 位包配 32 位 JDK 是最常见的翻车点

文件名里的win32.win32.x86_64说明这是 64 位构建。它会去找 64 位的 JVM,如果系统 PATH 里第一个 java 是 32 位的,双击MemoryAnalyzer.exe的结果通常是闪退,或者弹一句 "Failed to create the Java Virtual Machine",然后什么都没发生。

先确认当前 java 到底是什么:

java -version (Get-Command java).Source

java -version的输出里如果带64-Bit Server VM,说明是 64 位;没有这行就是 32 位。第二行告诉你这个 java 从哪来,很多时候会发现是某个旧软件自带的 JRE,被写进 PATH 排在了前面。这种情况下不必去动全局 PATH,直接在 MAT 的配置文件里指定 JVM 更干净。

MemoryAnalyzer.ini是纯文本,用记事本就能改。指定 JVM 的写法是加-vm和路径,位置很关键:

-vm C:\Program Files\Java\jdk1.8.0_202\bin\javaw.exe -vmargs -Xmx1024m

-vm必须写在-vmargs之前。写到后面去,Eclipse 启动器会把它当成 JVM 参数而不是启动器参数,结果就是要么被忽略,要么直接报参数非法。这个顺序问题我在不同机器上踩过不止一次,血泪经验是:改完 ini 先别急着双击,用命令行启动一次,报错能直接打在控制台里,比看弹窗快得多。

注意,路径里有空格时不要加引号,-vm的参数是逐行解析的,加了引号反而会把引号当成路径的一部分。

3.2 改 MemoryAnalyzer.ini 的 -Xmx:按 dump 大小倒推

-Xmx是这个工具里唯一真正重要的参数。解析和打开 dump 的过程中,对象图、索引、各种中间结构都堆在 JVM 堆里,-Xmx给小了,表现就是进度条走到一大半然后OutOfMemoryError: Java heap space,前面等的时间全白费。

倒推公式不复杂:取 dump 文件大小的 1.2 到 1.5 倍,同时不超过物理内存的 60% 到 70%。8G 的 dump 配 10G 到 12G,机器得有 16G 以上内存。低于这个比例不是一定失败,但失败概率随 dump 里对象数量上升而上升,赌这个没必要。

手动改一行就够,但如果你经常换不同大小的 dump,写个小脚本自动算更省事:

# 按 dump 实际大小重算 -Xmx,写回 MemoryAnalyzer.ini $ini = "D:\mat\MemoryAnalyzer.ini" $dump = "D:\dumps\heap.hprof" $sizeGB = (Get-Item $dump).Length / 1GB $heapGB = [math]::Max(2, [math]::Ceiling($sizeGB * 1.3)) $content = Get-Content $ini if ($content -match '^-Xmx') { $content = $content -replace '^-Xmx.*$', "-Xmx${heapGB}g" } else { # 没有 -Xmx 行就补在 -vmargs 后面 $idx = [array]::IndexOf($content, '-vmargs') $content = $content[0..$idx] + "-Xmx${heapGB}g" + $content[($idx+1)..($content.Length-1)] } Set-Content -Path $ini -Value $content -Encoding ASCII "dump=${sizeGB}GB -> Xmx=${heapGB}g"

逻辑很直白:读文件大小、乘 1.3、向上取整、替换或插入-Xmx行。[math]::Max(2, ...)是兜底,避免小 dump 算出 1G 以下导致 MAT 自己起不来。-Encoding ASCII是为了不让 PowerShell 写出带 BOM 的文件,启动器读带 BOM 的 ini 有时会解析失败。

改完一定要验证,别假设脚本写对了:

Select-String -Path "D:\mat\MemoryAnalyzer.ini" -Pattern '^-Xmx|^-vm'

看到-vm在-vmargs上方、-Xmx在-vmargs下方,顺序就对了。

3.3 用 ParseHeapDump.bat 出报告:不开 GUI 也能交付

GUI 适合自己看,但很多时候你需要把结论发给别人,或者在一台没有图形界面的分析机上批量跑。MAT 自带命令行入口ParseHeapDump.bat,跑完直接产出几个 zip 报告。

先切到 MAT 根目录,这个 bat 内部用的是相对路径找MemoryAnalyzer.exe,在别处调用会找不到:

cd /d D:\mat ParseHeapDump.bat D:\dumps\heap.hprof ^ org.eclipse.mat.api:suspects ^ org.eclipse.mat.api:overview ^ org.eclipse.mat.api:top_components

三个参数对应三份报告:suspects是泄漏嫌疑分析,overview是总体概览含 Top Consumers,top_components是按组件聚合的内存占用。后面还可以接org.eclipse.mat.api:system之类,但常用的就这三个。跑完在 dump 同目录下会看到Leak_Suspects.zip、System_Overview.zip、Top_Components.zip,每个 zip 里是 HTML,双击就能在浏览器里看,不需要装 MAT。

如果只是想先把索引建好,省掉出报告的时间,把后面三个参数全部去掉即可:

cd /d D:\mat ParseHeapDump.bat D:\dumps\heap.hprof

这条命令在批处理脚本里特别有用:白天抓 dump,晚上批量建索引,第二天上班直接开 GUI 看,省掉等待。注意命令行和 GUI 用的是同一个 ini,-Xmx的配置对两者都生效,别以为改的是 GUI 就不会影响批处理。

配套的抓取命令也一并记下,避免现场手忙脚乱:

jmap -dump:format=b,file=D:\dumps\heap.hprof 12345

12345换成目标进程 PID,-dump:format=b表示二进制 hprof。加:live会先触发一次 Full GC 再 dump,文件更小、更接近"存活对象真实占用",代价是那一次 Full GC 对线上是实打实的停顿,业务高峰期不要用。

4. 直方图与 OQL:把"谁在占内存"变成可复现的查询

GUI 点几下能出结论,但结论很难复现,也很难交给别人验证。真正让分析过程站得住脚的做法是把判断写成查询:同样的 OQL 在同样的 dump 上跑,谁跑都是同一个结果。这一章讲三件事——直方图怎么过滤才有意义、OQL 怎么写才不白写、Path to GC Roots 为什么要排除弱引用。

4.1 直方图的三个开关:正则、retained、class loader

直方图默认按类名排序,几千行里混着大量 JDK 内部类,直接看等于没看。第一个开关是正则过滤,在表头上方的输入框里填java\.lang\..*之类,先把不关心的包排除掉。常见做法是先看业务包前缀,比如com\.example\..*,把范围缩到自己的代码里,再逐步放开。

第二个开关是切换排序维度。直方图默认列的是 shallow heap,这个数字对定位问题几乎没有价值。切到 retained heap 之后,排序结果会完全不一样,前面说的HashMap例子就是这个差别。切换按钮在表格右上角,两个来回点一下就能感受到差距。

第三个开关是 group by class loader。一个容器里跑多个应用时,同名类可能被不同 class loader 各加载一份,静态字段也就各有一份。不按 class loader 分组,你会看到两个FooService的实例数加在一起,误以为是泄漏,实际上是两个隔离的上下文。这个坑在插件化、热部署场景里特别常见。

4.2 OQL:把怀疑写成可执行的查询

OQL 的语法接近 SQL,但查询对象是堆里的实例。选中一个 dump 之后在菜单里打开 OQL 视图,输入框里敲语句,回车执行。下面几条是我常用的起手式。

查大字符串,先看有没有明显的文本堆积:

SELECT s.toString(), s.value.@length AS len FROM java.lang.String s WHERE s.value.@length > 5000

s.value是String内部的字符数组,@length是 OQL 访问数组长度的写法。这条语句会把长度超过 5000 的字符串连内容一起列出来,很多时候一眼就能看到重复拼接的 SQL 或者没截断的响应报文。

查大数组本身:

SELECT * FROM char[] c WHERE c.@length > 100000
SELECT * FROM byte[] b WHERE b.@length > 1048576

第一条找超过 10 万字符的字符数组,第二条找超过 1MB 的字节数组。后者在排查缓冲区、序列化结果、文件读取残留时很有效。

查集合膨胀:

SELECT * FROM java.util.ArrayList a WHERE a.size > 5000
SELECT * FROM java.util.HashMap h WHERE h.size > 10000

size是私有字段,OQL 可以直接读,不用管访问修饰符。这两条能快速捞出那些"本以为是缓存,实际上是只进不出"的容器。

按线程名过滤,定位到具体业务线程持有的对象:

SELECT * FROM java.lang.Thread t WHERE t.name.toString() LIKE ".*order-sync.*"

LIKE后面是正则,.*不能省。拿到线程对象之后,可以在 GUI 里对它做 Path to GC Roots,把整条链子拉出来。

写 OQL 有两个容易白写的点。一是结果集太大直接把界面拖死,加WHERE限制条数是必须的,别上来就SELECT * FROM java.lang.Object。二是 OQL 不支持任意方法调用,能读字段、能调toString()这类少数方法,业务方法基本调不了,所以查询要围绕字段展开。

4.3 Path to GC Roots:为什么必须排除弱引用

拿到可疑对象之后,右键 → Path to GC Roots → 选带exclude weak/soft references的那一项。这个选项不是可选的礼貌,是必须。

原因在于弱引用和软引用不阻止对象被回收。一个对象如果只被WeakHashMap的 key 或者某个缓存框架的SoftReference引用着,它其实随时可以被回收,不构成泄漏。但如果查询时把弱引用也算进路径,你会得到几十条通往各种缓存管理器的链子,看起来每条都像元凶,实际全是噪音。

排除之后剩下的路径才是"强引用链",也就是真正让对象活下来的原因。常见的收敛结果有三类:静态集合字段(static final Map只 put 不 remove)、线程池里的ThreadLocal没清理、监听器注册了没反注册。这三类占了实际排查里的大头,看到路径终点是static字段,基本就可以往下查代码了。

还有一个小技巧:路径太长的时候,在路径视图里对中间节点再右键一次 Path to GC Roots,能一段一段往前推,比盯着一条几十层的链子找重点快得多。

5. 避坑与排查:五个把 MAT 用废的常见操作

这套工具本身不复杂,但配置和输入数据的边界很硬,踩中一个就是几小时的无效等待。下面五条都是我或者身边人真实翻过的车,按"现象 → 原因 → 解决"写,对照着排查能省不少时间。

5.1 双击闪退,没有任何报错

现象:解压后双击MemoryAnalyzer.exe,窗口一闪就没了,任务管理器里也看不到进程。或者弹一句 "Failed to create the Java Virtual Machine" 就结束。

原因:64 位构建找不到 64 位 JVM。系统里可能装了多个 Java,PATH 里排第一的是某个旧软件自带的 32 位 JRE;也可能是-Xmx被设成了 8g,而实际用上的是 32 位 JVM,地址空间根本给不出来。

解决:先跑java -version确认位数,再在MemoryAnalyzer.ini里用-vm显式指向 64 位的javaw.exe,并且保证-vm写在-vmargs之前。改完别双击,用命令行启动一次,错误信息会打在控制台里,比看弹窗有效得多。

5.2 解析到 90% 报 Java heap space

现象:进度条走到一大半,弹窗 "An internal error occurred during: Loading heap dump",详情里是java.lang.OutOfMemoryError: Java heap space。

原因:-Xmx小于 dump 大小。解析阶段要把对象图和索引结构都放进堆里,8G 的 dump 配 4G 的-Xmx,中途必然撑爆。

解决:按 dump 大小的 1.2 到 1.5 倍重设-Xmx,同时确认物理内存够。物理内存确实不够时,别在原机器上硬扛,把 dump 拷到内存更大的分析机上做离线分析;或者先在命令行只建索引不出报告,减少同时驻留的中间结构。删掉半成品索引再重来,别指望它断点续传。

5.3 dump 文件本身是残的

现象:打开时报 "Invalid HPROF file header",或者解析到一半抛 EOF 异常,进度条直接归零。

原因:抓取时进程被 kill、磁盘写满、或者文件在传输过程中被当成文本模式处理过(换行符被改写,二进制就废了)。也有可能是jmap执行到一半被中断,留下一个长度不对的文件。

解决:先验文件头。hprof 的前十几个字节是可读的 ASCII:

$fs = [System.IO.File]::OpenRead("D:\dumps\heap.hprof") $buf = New-Object byte[] 32 $fs.Read($buf, 0, 32) | Out-Null $fs.Close() [System.Text.Encoding]::ASCII.GetString($buf)

正常应该看到JAVA PROFILE 1.0.2开头。看不到就是文件坏了,重抓。重抓之前先看磁盘余量,再挑业务低峰期,别在高峰期用:live触发 Full GC 把问题放大。

5.4 把 shallow heap 当成泄漏判据

现象:直方图里byte[]、char[]、String排前三,点进去发现来源分散在几十个类里,于是得出"没有集中泄漏点"的结论,问题搁置。

原因:byte[]和char[]是承载数据的容器,不是持有者。它们的 shallow 大是因为数据本身大,但让它们活着的强引用在上层业务对象那里。按 shallow 排序永远只能看到容器。

解决:切到 Dominator Tree 按 retained heap 排序,找真正的大户;或者对可疑的byte[]做 Path to GC Roots 并排除弱引用,看能不能收敛到同一个业务类。收敛不到就说明确实分散,这时候该看的是对象增长速率,用两个时间点的 dump 做对比,而不是在单个快照里死磕。

5.5 改名、换版本导致索引错位

现象:明明解析过了,再打开却提示重新解析;或者打开后类名乱码、实例数和之前对不上。

原因:两种情况。一是 hprof 被改名或移动过,旧索引还留在原目录,MAT 按路径找不到对应关系;二是用不同版本的 MAT 打开同一份索引,序列化格式不兼容。

解决:改变 dump 位置时,把同名的.index、.dominator_tree、.inbound等一组文件一起处理——要么一起移动,要么全部删掉重新解析。团队协作时统一 MAT 版本,别一个人用 1.6.1、另一个人用更新版本,然后互相说对方给的报告有问题。这个版本的构建年份比较早,遇到新 JDK 生成的 dump 打不开时,优先考虑换更新的解析器,不要在旧包上反复折腾。

6. 大堆 dump 的进阶打法:20 分钟内从 8G 收敛到三个类

拿到一份 8G 的 dump,最耗时间的不是分析,是漫无目的地浏览。我现在固定走一条路径,把过程压到 20 分钟以内,你可以直接抄。

第一步看 Overview 里的 Top Consumers,它按 retained 给出前若干个组件,通常是几个业务模块加几个 JDK 内部结构。这一步只花两分钟,目的是建立全局印象:内存是被一个东西吃掉的,还是被几千个中等对象摊掉的。

第二步看 Leak Suspects 的 Problem Suspect 1。它给出的是"某个类的实例占了 X%,被某条引用链持有"。这一条能命中就基本结束了,命不中就往下走,别在 Suspect 2、3 上耗太久,那些多半是同一个问题的碎片。

第三步用直方图正则过滤,把java\.、sun\.、org\.springframework\.这些框架包先排掉,只看自己的业务包前缀。这一步经常能直接看到某个 DTO 的实例数是几十万,而正常量级应该是几千。

第四步对可疑类做 retained 排序的支配树查询,确认它是不是真的持有那么多下游。有时候实例数多但每个都很小,retained 加起来不值一提,那就不是它。

第五步,如果单个 dump 看不出趋势,用两个时间点的 dump 做对比。MAT 里叫 Compare Basket,把两份直方图放进去做差,增长最快的那几行就是嫌疑。这个功能比人眼对比两份表格可靠得多。

下面是我自己用的时间预算,供参考:

步骤目标时间
Top Consumers判断是集中还是分散2 分钟
Leak Suspects抓现成的引用链结论3 分钟
直方图过滤缩到业务包范围3 分钟
支配树 retained 排序确认真实占用5 分钟
Path to GC Roots拿到强引用链证据5 分钟
两快照对比(可选)看增长趋势10 分钟

有两个习惯是踩坑之后才养成的。一是每次分析前先把-Xmx按 dump 大小重算一遍,绝不沿用上次的配置——曾经有一次直接拿 4G 的配置去开 12G 的 dump,等了四十分钟换来一个 OOM,那四十分钟本来可以用来做别的事。二是所有结论都要落到一条可复现的 OQL 或者一条完整的 Path to GC Roots 路径上,截图不算,口头描述更不算,因为下次别人复现不出来,你就得从头再查一遍。

从那以后我每次抓完 dump,都强制走一遍"验文件头、算 Xmx、建索引、出 suspects 报告"这四步,哪怕当时觉得没必要。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进