ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MAT 实战:从 2GB hprof 中定位内存泄漏与 OOM 根因

MAT 实战:从 2GB hprof 中定位内存泄漏与 OOM 根因

简介:这份资源是面向Java开发与性能调优人员的MAT(Memory Analyzer Tool)堆内存分析工具包,用于解析JVM生成的hprof文件,排查内存泄漏、内存占用过高等问题。压缩包共约2000个文件,以1891个html帮助文档、217个png界面截图、183个jar核心组件为主,辅以xml、css、properties等配置与样式文件,以及少量class、dll、exe运行支撑文件,整体约75.44MB,属于可直接运行的完整工具发行包。已有6625人学习下载,说明其在内存分析场景中具有较高的实用参考价值。借助包内文档与示例hprof,读者可系统了解支配树、泄漏嫌疑人报告、Shallow Heap与Retained Heap对比、OQL自定义查询等核心视图的使用方式,掌握从生成快照到定位问题对象的完整分析思路,适合需要深入理解Java内存模型、提升线上问题排查能力的中高级开发者查阅实践。

1. 线上 OOM 之后,那个 2GB 的 hprof 到底怎么读

凌晨两点被告警叫醒,堆内存打满,服务重启后留下一个 2GB 的java_pid12345.hprof。很多人第一反应是jmap -histo看个对象计数,或者干脆加内存重启了事,但真正能定位到「谁在持有这个对象、为什么没释放」的,还是得把 dump 文件拖进 MAT(Memory Analyzer Tool)里跑一遍。MAT 是 Eclipse 基金会下的堆转储分析工具,专门啃 hprof 这类二进制快照,核心能力是支配树(Dominator Tree)、直方图(Histogram)、泄漏 suspects 报告和 OQL 查询。它适合两类人:一是线上排查内存泄漏、OOM 的后端工程师,二是想把「对象引用链」这件事真正搞明白、不再靠猜的开发者。这篇不聊虚的,从拿到 hprof 到跑出泄漏点,把参数、命令和翻车点一次讲透。

2. 拿到 hprof 之前:dump 的三种姿势与 MAT 的读取边界

2.1 先搞清楚 hprof 是怎么来的

MAT 本身不生产 dump,它只消费 dump。所以第一步不是打开 MAT,而是确保你手里的 hprof 是「完整且可分析」的。常见获取方式有三种,选哪种直接决定后面 MAT 能不能读出有用信息。

第一种是 JVM 参数触发,在启动脚本里加:

# 堆 OOM 时自动 dump,路径按需改 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/heap_%p.hprof

%p是进程号占位符,避免多实例互相覆盖。这种方式的好处是「事发即存」,坏处是 dump 瞬间会 STW,大堆可能卡几秒到几十秒,生产上要评估。

第二种是运行时手动触发,用jmap:

# 先确认 pid,再 dump jmap -dump:live,format=b,file=/data/dump/manual.hprof 12345

live表示只 dump 存活对象,会先触发一次 Full GC,文件更小、更贴近「真实泄漏」,但 Full GC 本身有停顿代价。去掉live则包含已死未回收对象,文件更大,分析时噪音多。

第三种是容器/云环境下的托管 dump,很多运行时会在 OOM 时自动落盘,你只需要去指定目录捞文件。这里要注意:容器里 dump 的路径是容器内路径,别在宿主机上找错地方。

2.2 MAT 能读什么、读不了什么

MAT 支持标准 hprof(binary format),也支持 IBM 的 PHD 等格式,但对「非标准裁剪过的 dump」经常报Unknown HPROF Version或解析到一半失败。血泪经验是:用 OpenJDK 系jmap或HeapDumpOnOutOfMemoryError产出的文件最稳;某些商业 JVM 或经过压缩工具处理过的 dump,MAT 可能读不全,表现为直方图对象数明显偏少。

另一个边界是版本。MAT 对高版本 JDK 的 dump 支持依赖其内置的 hprof 解析器,太老的 MAT 打开新 JDK 的 dump 会提示不兼容。常见做法是 MAT 版本不低于目标 JDK 大版本,拿不准就用较新的 MAT 打开。还有一点:MAT 分析时会把 dump 索引成一批文件,索引体积可能接近原 dump 的 1/3 到 1/2,磁盘要留够,否则索引到一半报No space left on device,前面白跑。

提示:dump 文件属于敏感数据,里面可能有用户信息、密钥字符串,别随手传到公共网盘或第三方在线分析站点,本地 MAT 跑最稳妥。

3. 用 MAT 跑出泄漏点:从直方图到支配树的操作链

3.1 打开 dump 与首屏那几个关键数字

MAT 打开 hprof 会先做索引(Parsing heap dump),大文件几分钟很正常,期间别关。索引完成后首屏是 Overview,重点看三块:Heap Size(总占用)、Classes(类数量)、Objects(对象数)。如果 Heap Size 和你-Xmx设置接近,说明 dump 时确实接近打满,泄漏判断有意义;如果远小于上限,可能是别的原因触发的 dump,别急着往泄漏上套。

首屏还有一个Leak Suspects报告入口,MAT 会自动跑一遍启发式分析,给出「疑似泄漏点」和「最大对象占比」。新手容易直接信这个报告,但它只是启发式,误报不少。我的习惯是:先看 Leak Suspects 给方向,再自己用 Histogram 和 Dominator Tree 验证,两边对上了才下结论。

3.2 Histogram:先看「哪类对象最多」

Histogram 按类聚合,列出每个类的实例数和浅堆(Shallow Heap)。操作路径是工具栏的 Histogram 图标,或菜单里找。看的时候别只按实例数排序,要按 Shallow Heap 或 Retained Heap 排序——实例多不代表占内存多,一堆小对象可能不如一个大byte[]狠。

典型信号:某个业务类实例数几百万,或者char[]、byte[]、HashMap$Node异常膨胀。比如看到java.util.HashMap$Node占了 40% 堆,基本可以判断有超大 Map 在累积。Histogram 只能告诉你「是什么」,不能告诉你「谁持有它」,所以下一步必须进支配树。

3.3 Dominator Tree:找到「谁真正占着内存」

Dominator Tree 的核心概念是「支配」:如果从 GC Root 到对象 B 的所有路径都经过 A,那 A 支配 B,A 的 Retained Heap 包含 B 占的内存。这比引用链更接近「干掉谁能释放多少」的真实问题。

操作上,在 Histogram 里右键某个可疑类,选Merge Shortest Paths to GC Roots→exclude weak/soft references,就能看到从 GC Root 到这些对象的引用路径。排除弱引用/软引用很关键,否则路径里全是WeakReference、Finalizer这类噪音,看不到真正的强引用持有者。

一个真实场景:某服务堆里byte[]持续增长,Histogram 看到大量byte[],但不知道谁持有。进 Dominator Tree 按 Retained Heap 排序,发现一个自定义缓存类的 Retained Heap 占了 70%,展开引用链发现它内部ConcurrentHashMap的 key 是「请求 ID + 时间戳」且永不过期,缓存只增不减。这就是典型的「以为是框架泄漏,其实是业务缓存没设上限」。

3.4 OQL:把「找对象」变成写查询

MAT 内置 OQL(Object Query Language),语法类似 SQL,能直接对堆里的对象做条件查询。入口在工具栏的 OQL 图标。比如想找所有长度超过 1MB 的byte[]:

-- 查大数组,按长度倒序 SELECT s, s.length FROM byte[] s WHERE s.length > 1048576

再比如查某个业务类的所有实例里,字段status为特定值的对象:

SELECT o FROM com.example.cache.CacheEntry o WHERE o.status == 1

OQL 的价值在于「精确打击」:当你知道可疑类名和字段,用 OQL 比在几万个对象里翻引用链快得多。注意 OQL 里类名要写全限定名,数组类型写byte[]这种形式;字段访问用点号,但只对非静态字段有效。查询结果可以直接右键看引用链,衔接回 Dominator Tree 的分析流程。

3.5 一个可复现的最小分析流程

把上面的动作串成一条固定链路,每次排查照走,能省很多来回:

  1. 打开 dump,等索引完成,记下 Heap Size 与-Xmx的比值。
  2. 跑 Leak Suspects,记下它点名的类和占比,作为假设。
  3. 开 Histogram,按 Retained Heap 排序,找 Top 5 类,和假设对照。
  4. 对可疑类右键Merge Shortest Paths to GC Roots,排除弱/软引用,看强引用持有者。
  5. 若持有者是集合,展开看元素类型和数量,判断是否无界增长。
  6. 用 OQL 验证元素特征(长度、状态、时间字段),确认增长模式。
  7. 回到代码,定位该集合的写入点和清理逻辑,确认缺失的淘汰策略。

这条链路里,第 4 步和第 6 步是最容易出结论的地方,也是新手最容易跳过的地方——很多人停在 Histogram 看到「byte[] 很多」就结束了,其实那只是症状。

4. 避坑与排查:MAT 分析里最容易翻车的五件事

4.1 打开就报 Unknown HPROF Version

现象:MAT 加载 dump 直接弹错,索引都进不去。原因通常是 dump 由非标准工具生成,或 JDK 版本远高于 MAT 内置解析器支持范围。解决:换用目标 JDK 自带的jmap重新 dump 一次;或升级 MAT 到较新版本再试。别在原文件上反复折腾,重 dump 往往更快。

4.2 索引到一半磁盘满了

现象:Parsing 进度卡住后报No space left on device。原因:MAT 默认把索引文件写在 dump 同目录或临时目录,索引体积可达原文件一半。解决:把 dump 放到大磁盘分区再打开,或在 MAT 的MemoryAnalyzer.ini里调整临时目录与堆参数(-Xmx给大点,否则大 dump 索引阶段自己先 OOM)。

4.3 Leak Suspects 指了个「假凶手」

现象:报告说某个框架类占 60%,但代码里根本没直接用。原因:启发式把「被大量对象共享的容器」误判为泄漏源,真正的增长点在其内部元素。解决:别停在报告页,按 3.5 的链路进 Dominator Tree 看 Retained Heap 和引用链,确认增长的是容器本身还是容器里的元素。

4.4 引用链里全是 WeakReference 噪音

现象:Merge Shortest Paths to GC Roots出来的路径一堆WeakReference、Finalizer,看不到业务代码。原因:没排除弱引用/软引用,MAT 把非强引用路径也算了进来。解决:右键菜单里选带exclude weak/soft references的那一项;若仍不干净,手动在路径里跳过java.lang.ref包下的节点继续往上找。

4.5 用jmap -dump把线上服务拖垮

现象:dump 期间接口大面积超时,甚至触发健康检查失败。原因:live模式先 Full GC,大堆停顿长;dump 本身写盘也占 IO。解决:优先用HeapDumpOnOutOfMemoryError让 JVM 在 OOM 时自己落盘;必须手动 dump 时避开高峰,评估堆大小和停顿预算,必要时先摘流量。这条是生产红线,别在核心链路上随手执行。

5. 进阶:用 MAT 的对比视图锁定「增长的那部分」

单份 dump 只能看到「现在有什么」,两份 dump 对比才能看到「什么在长」。这是 MAT 里被低估的功能,也是定位缓慢泄漏最有效的手段。做法是:在疑似泄漏的时间点 T1 和 T2 各 dump 一次(间隔按业务周期定,比如半小时或一个完整批次),用 MAT 打开两份,走Compare Basket或直方图对比。

具体操作:分别对两份 dump 生成 Histogram,把两个 Histogram 都加入 Compare Basket(右键 Histogram 结果里有加入对比的入口),然后执行对比。MAT 会给出每个类的实例数差值和堆占用差值,按差值排序,增长最猛的那几类就是嫌疑对象。相比单份 dump 里「本来就很大」的类,差值排序能直接过滤掉那些「一直很大但没变化」的常驻对象,信噪比高很多。

一个实操细节:两次 dump 之间如果发生过 Full GC,存活对象集合会变化,对比结果里可能出现「负增长」,这是正常的,说明那部分被回收了,重点看正增长且持续的那几类。另外,对比时尽量保证两次 dump 的触发条件一致(都用live或都不用),否则口径不同,差值会失真。

我一般还会配合 OQL 做二次确认:对增长最猛的类,用 OQL 查它的关键字段分布,比如时间戳字段的最小值/最大值,判断是不是「老对象一直没被清理」而不是「新对象疯狂涌入」。这两种情况的修复方向完全不同——前者是淘汰策略缺失,后者是写入速率失控。

从那以后我每次处理内存问题,都强制自己先做「两份 dump 对比」再下结论,单份 dump 的直觉判断翻车太多次了。希望这套流程能帮到你,下次再看到那个 2GB 的 hprof,至少知道从哪下手。

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

RELATED READING

延伸阅读

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