资讯详情

MAT 与 hprof 堆分析实战:从 Full GC 卡顿到内存泄漏定位

📅 2026/10/27 20:38:48 | 华诺云谱 👁 阅读
MAT 与 hprof 堆分析实战:从 Full GC 卡顿到内存泄漏定位
简介面向 Java 内存问题排查场景的 MATMemory Analyzer Tool工具包适合需要解析 hprof 堆转储的开发者、测试与运维人员使用。它围绕内存泄漏定位、对象占用核算与生命周期追踪展开可借助支配树、Leak Suspects 报告、Heavy Hitters 视图及 OQL 查询把堆内对象与引用关系整理成可读视图帮助判断哪些类、静态字段或单例造成异常占用。压缩包共 2000 个文件约 75.44MB以 html 帮助文档和 css 样式页面为主体配合 jar 插件、png 与 gif 图标、xml 配置及 properties 等元数据另含 hprof 示例与 class 文件便于离线查阅手册并验证分析流程。已有 6621 人学习下载。对于希望从堆快照入手理解 Shallow Heap 与 Retained Heap 差异、逐步建立排错思路的读者这份资料提供了较完整的工具与文档组合可用于日常内存诊断练习。1. 从一次 Full GC 之后的卡顿说起MAT 与 hprof 各自管哪一段线上服务老年代冲到 98%Full GC 从半小时一次变成两分钟一次每次停三秒日志里躺着一条内存溢出异常堆栈指向某个批量查询方法——照着改完确实好了两天内存曲线又慢慢爬回原点。堆栈只能告诉你“最后压垮它的那一下在哪”回答不了“谁一直占着不放”。要回答后一个问题手上得有 hprof 文件它是某一时刻全部对象、类、线程栈和引用关系的快照MAT 这个老牌堆分析器干的就是把快照翻开不做采样、不挂 agent只对静态数据做可达性计算算出每个对象的自身开销和被它牵连住的内存总量。被内存溢出追着跑的 Java 后端、需要离线复盘现场的值班同学、判断“到底是缓存没管住还是类加载器没释放”的中间件维护者都用得上它。2. 先有 dump 再谈分析hprof 的三种拿法与打开前的内存配置拿到一份能分析的 hprof比分析本身更容易翻车。很多“MAT 打不开”的问题根子在生成阶段就埋下了有的 dump 是 live 模式触发过一次 Full GC 才写盘的线上直接执行会把一次小抖动放大成一次停顿有的是容器里写的文件落在临时层Pod 一重启就没了还有的是新版 JDK 生成、MAT 自带运行时版本偏低解析器直接报 HPROF 版本不认识。这一章把生成、启动、裁剪三个环节拆开。2.1 三种 dump 触发方式代价差别很大手动抓、溢出自动抓、Full GC 前后抓这三种的适用场景完全不同。手动抓适合现场还在、能连上进程的时候溢出自动抓适合不可复现的偶发问题代价是要提前挂参数并接受一次写盘Full GC 前后抓适合盯“某次 GC 之后内存没降下去”的病态增长但它会在每次 Full GC 前都写一份文件磁盘和 IO 压力很大生产上基本只在临时排障窗口开。# 方式一手动抓。live 表示只 dump 存活对象代价是会先触发一次 Full GC # 先用 jstat 看一眼老年代水位确认这次停顿可以接受再执行 jstat -gcutil 12345 1000 5 jmap -dump:live,formatb,file/data/dump/heap-$(date %Y%m%d%H%M).hprof 12345 # 方式二jcmd 等价写法JDK 7u4 之后都可用容器里更推荐这条 jcmd 12345 GC.heap_dump /data/dump/heap.hprof # 方式三提前挂参数让进程自己在溢出时落盘 # -XX:HeapDumpOnOutOfMemoryError 是开关-XX:HeapDumpPath 必须指向有空间的挂载盘 java -Xms4g -Xmx4g \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/dump \ -jar app.jarformatb表示二进制格式不加这个参数在老版本上会写出文本格式MAT 也能读但体积大得多。live这个开关是血泪经验的分水岭带上它抓之前先 GC 一次文件里只有活对象体积小、分析干净但那一次 Full GC 的停顿要算在你的账上不带它文件里会混进大量已经死掉但还没被回收的对象MAT 打开时默认会提示“discard unreachable objects”选错了就会把真正的泄漏对象当成垃圾丢掉。HeapDumpPath指向的目录一定要确认剩余空间够经验值是堆上限的 1.2 倍左右容器里尤其容易踩到临时存储层的配额上限。还有一种容易被忽略的触发参数-XX:HeapDumpBeforeFullGC和-XX:HeapDumpAfterFullGC。它们适合做前后对照看一次 GC 到底释放了哪批对象但会连续产出多份文件务必配合脚本定期清理否则磁盘先满。2.2 MAT 的启动参数堆给不够一切白搭MAT 默认的启动内存往往只有 1G 上下而解析一份 4G 的 dump 光索引结构就要吃掉不少。表现是打开到一半进度条卡死或者直接弹一个内存不足的对话框。改法在安装目录下的配置文件里直接调大堆上限顺便确认用的是 64 位运行时。# MemoryAnalyzer.ini 关键几行数值按机器内存和 dump 大小调 -startup plugins/org.eclipse.equinox.launcher_xxx.jar --launcher.library plugins/org.eclipse.equinox.launcher.xxx -vm /opt/jdk17/bin/java -vmargs -Xmx8g -XX:UseG1GC -Dorg.eclipse.mat.parser.maxMemory80-Xmx的建议值是 dump 文件大小的 1~1.5 倍8G 的 dump 配 8G 到 12G 的 MAT 堆机器内存不够就只能走裁剪路线硬扛会拖到系统换页界面卡成幻灯片。-vm这一段最容易被跳过MAT 内置的运行时版本有时比被测进程低遇到高版本 JDK 生成的 dump 会报 HPROF 版本不识别显式指到高版本 JDK 的bin/java就能解决注意-vm必须写在-vmargs之前。最后那个maxMemory属性是解析器的软限制给它留出余量别和-Xmx顶死。还有个小代价要提前知道MAT 第一次打开 dump 时会生成同名索引文件.index这一步很慢几 G 的文件等上十几分钟是常态。第二次打开就快得多所以别在第一次卡住的时候反复强杀进程索引写坏之后下次还得重来。2.3 大文件打不开时的裁剪顺序真遇到“机器内存装不下这份 dump”按下面这个顺序退让从代价小到大顺序手段操作位置代价1压缩后直接打开把 hprof 打成 gzMAT 支持直接读无只是加载慢一点2丢弃不可达对象打开对话框里勾选保留策略丢失已死对象泄漏分析不受影响3加大 MAT 堆改 ini 的-Xmx受机器物理内存限制4导出子集分析后用导出功能另存为较小的 dump只能离线做二次分析多数情况走到第 2 步就够了。真正要警惕的是第 2 步的反面如果你怀疑的是“对象被意外持有”那就不能丢弃不可达对象否则证据链会被一起清掉。判断标准很简单——查泄漏看可达对象查内存浪费重复字符串、空集合、过大数组可以丢。3. 四个视图读下来Histogram 到 Path to GC Roots 的推理路径MAT 打开一份 dump 会给你一堆视图新手最容易犯的错是挨个点一遍然后什么结论都没有。合理的路径是固定的四步先看 Histogram 找异常类再进 Dominator Tree 看谁真正占着接着用 Leak Suspects 拿线索最后用 Path to GC Roots 验证引用链是否成立。每一步的输出都是下一步的输入顺序反了就会绕圈。3.1 Histogram 的两列数字Shallow 和 Retained 不是一回事Histogram 按类聚合每行两列Shallow Heap 是这个类的所有实例自身占的字节数Retained Heap 是“把这些实例全回收掉能释放多少”。前者看对象自身多大后者看它牵连了多少。java.lang.String的 Shallow 通常只有几十兆看起来很无辜但如果它被几十万个 Map 节点引用、每个节点还挂着一个大数组Retained 那一列能到几个 G。反过来某个业务类的实例只有 200 个Shallow 完全不起眼Retained 却是全表最高——这通常意味着它是某个缓存的根节点。所以扫 Histogram 的正确姿势是先按 Retained 降序排一次再按实例数量降序排一次两个榜单的前十名交叉看。只按对象数排序会把大量小对象顶上来白白浪费注意力。有一个细节Retained 是精确计算的不是估的代价是首次点击那一列会卡一阵。别在卡的时候以为程序挂了。3.2 Dominator Tree 的百分比基准决定了你误判的方向支配树回答的是“谁被谁支配”如果回收对象 A 就必然回收 B那 A 支配 B。这个视图里每一行的百分比是相对整个堆的不是相对父节点的这一点弄错了结论会完全反过来。比如某一行显示 35%你以为是“占父节点的 35%”于是判断父节点还有很大优化空间实际上它占了全堆的三分之一父节点本身可能只是个薄薄的容器。正确的读法是从根往下逐层展开只在百分比明显高于同级兄弟的节点上停住然后看它的 Shallow 与 Retained 差距。差距越大说明它持有的东西越多往下追的价值越大差距接近说明它自己就是那个大对象去查它为什么这么大就行了。配合“累计百分比”这一列更有用展开到某个深度时累计超过 80%剩下的分支基本可以先放一边。3.3 Leak Suspects 只当线索不能当结论概览页里那张“可疑泄漏点”列表是启发式算出来的它挑的是支配树里占比高、又不像是正常业务容器的子树。它给出的结论通常是“某类加载器加载的某批对象占了 40%”然后附一条最短引用链。它能帮你快速缩小范围但直接照抄结论会翻车。原因在于启发式不认识业务语义一个正常的内存缓存在它眼里和泄漏长得一模一样反过来一个真正的泄漏如果分散在几千个小对象里它可能一个都不报。我一般把它当“起点”而不是“答案”拿到它给的类名之后回到 Histogram 和 Dominator Tree 里自己走一遍引用链。概览页里还有两个被低估的视图一个是 Top Consumers按包路径和类加载器聚合能快速看出是哪个模块在膨胀另一个是 Duplicate Classes当同一个类名出现两份以上基本可以确定是类加载器泄漏这个信号比任何引用链都直接。3.4 Path to GC Roots 的排除项决定结论对不对在任意对象上右键查“到 GC Root 的路径”弹窗里有一组排除选项排除弱引用、排除软引用、排除虚引用、排除 Phantom 之类。默认往往是排除弱引用和软引用这个默认值对“查泄漏”是对的对“查缓存为什么不释放”就是错的。弱引用和软引用的语义是“内存紧张时可以被回收”它们挂着的东西本来就不该算强引用泄漏。如果你正在查的是一个软引用缓存把软引用排除掉引用链会直接断在缓存容器上看起来像是“没有强引用持有它”于是得出错误结论。所以查缓存相关的问题时要把排除项全部关掉看清楚完整链路里到底有没有一条强引用把它钉住。引用链读法上还有个小技巧一条链太长时重点看中间那几跳里有没有ThreadLocal、static字段、连接池容器、以及各种Map的 entry。静态集合持有是最常见的一类链路上会出现java.lang.Class到静态字段再到集合节点。4. OQL 与控制台把“翻树”换成“查询”视图适合建立整体印象精确查找还得靠 OQL。它是一门类 SQL 的对象查询语言跑在 dump 快照上能按属性、长度、类名过滤也能分组统计。加上命令行报表和对比功能就能把一次性的手工分析变成可复用的流程。4.1 三条能直接抄的 OQL 查询第一条用来揪大数组字符串底层是char[]大数组往往意味着一份没被切分的响应体或者一个没分页的查询结果-- 列出长度超过 1MB 的 char 数组按保留大小倒序 SELECT x FROM char[] x WHERE x.length 1048576 ORDER BY x.retainedHeapSize DESClength是数组对象的长度属性retainedHeapSize是保留大小这两个前缀带的属性是 MAT 内置的。char[]这种写法直接用类型名不加包名也认。第二条用来查重复字符串批量导入、缓存 key 拼装、日志上下文都是重复字符串的重灾区-- 统计内容相同的 String 出现次数超过 500 次的列出来 SELECT s.value.toString() AS content, COUNT(*) AS cnt FROM java.lang.String s GROUP BY s.value.toString() HAVING COUNT(*) 500 ORDER BY cnt DESCGROUP BY的分组键必须是一个可比较的表达式这里用value.toString()把底层字符数组转成字符串HAVING过滤聚合后的计数。这条查询在大堆上跑得慢建议先用WHERE s.value.length 200把短字符串滤掉再分组能快一个数量级。第三条用来数容器节点判断某个 Map 是不是在无限增长-- 统计 HashMap 节点数量并按所属的 Map 实例分组 SELECT m, COUNT(n) AS nodeCount FROM java.util.HashMap m JOIN java.util.HashMap$Node n ON n.objectId IN m.table.referenceArray GROUP BY m ORDER BY nodeCount DESC这条用到了objectId和数组属性语法在不同版本上可能有细微差异跑不通就先退化成最简形式SELECT * FROM java.util.HashMap$Node先数总量再用 Histogram 找哪个 Map 实例的 Retained 最大。总节点数几百万而业务上不该有这么多时基本可以确认是没设上限或者过期策略失效。OQL 的边界也要清楚它不能调任意方法会改变堆状态只支持有限的聚合函数查询期间整个界面是阻塞的。写复杂查询前先在SELECT *上加WHERE缩小范围别一上来就全表扫。4.2 命令行报表把一次性分析变成可以反复跑的活MAT 装好之后带了一套命令行入口可以在没有图形界面的服务器上直接产出报告。对外挂在值班机上定时跑一遍比每次人工点开要靠谱。# 无头模式生成报表可疑泄漏点 概览 大对象 # 插件标识的前缀以安装目录 plugins 下的实际包名为准不同构建可能略有差异 ./ParseHeapDump.sh /data/dump/heap.hprof \ org.eclipse.mat.api:suspects \ org.eclipse.mat.api:overview \ org.eclipse.mat.api:top_components执行完会在 dump 同目录生成一个 zip里面是可离线打开的 HTML 报告包含泄漏嫌疑、概览饼图和最大对象列表。参数就是 dump 路径加若干个报表标识写几个出几个。无头模式同样吃内存脚本文本里的堆参数要和 ini 里保持一致否则跑到一半会被系统杀掉。这套流程的价值在于把“抓 dump → 出报告 → 归档”串成一条线事后复盘时不用再等索引。4.3 两份 dump 对比找的是增量而不是绝对值单份 dump 能告诉你“现在谁最大”但很难告诉你“是谁在涨”。内存缓慢爬升这类问题只有两份间隔一段时间的 dump 对比才有说服力。操作路径是两份都打开各自在 Histogram 上右键加入 Compare Basket然后在对比视图里看差值列。重点不是看谁的绝对值大而是看“新增了多少个实例、新增了多少字节”。一个类在两小时内实例数从 1 万涨到 50 万即使总量还没登顶也已经是明确的信号。对比时有个前提两次 dump 的生成方式要一致都带live或者都不带否则一份包含死对象、一份不包含差值全是噪声。5. 避坑与排查五条踩过的记录5.1 生成与打开阶段的三个坑坑一打开时报 HPROF 版本不认识。现象是对话框直接拒绝加载或者进度条走到 30% 后报解析失败。原因是 MAT 内置的运行时版本低于生成 dump 的 JDK 版本。解决是在 ini 文件里用-vm显式指向一个版本不低于被测进程的 JDK位置必须放在-vmargs之前写成 Windows 路径时注意反斜杠。坑二解析到一半卡死内存监控显示系统在换页。现象是进度条半天不动机器风扇狂转。原因是-Xmx给得不够解析器在构建索引时反复触发 GC。解决是把 MAT 的堆调到 dump 文件大小的 1 倍以上如果物理内存不够改走丢弃不可达对象的路线不要硬提参数。顺带确认磁盘至少有 dump 文件两倍的空闲空间因为索引文件也不小。坑三抓完 dump 之后服务直接挂了。现象是jmap执行完进程跟着退出或者长时间无响应。原因是live模式那次 Full GC 在堆已经接近满的时候会把停顿拉得非常长容器健康检查探针超时后杀了 Pod。解决是线上优先用不带live的重定向方式或者干脆提前挂HeapDumpOnOutOfMemoryError让它在溢出那一刻自己写必须手动抓的时候先把实例从负载均衡摘掉。5.2 分析结论阶段的两个坑坑四Leak Suspects 报了一个占 40% 的类照着改完一周后复现。现象是报告里那棵子树确实最大但清理之后内存曲线照旧。原因是那个“嫌疑人”是个正常的本地缓存被启发式误判真正的增量藏在几千个小对象里。解决是把可疑列表当起点回到两份 dump 的对比视图里看增量按实例数量而不是字节数排序往往能翻出真正在涨的那一类。坑五引用链里全是软引用得出“没有强引用持有”的错误结论。现象是排查缓存类对象时Path to GC Roots 显示的链条很短看起来随时可回收。原因是查询时勾选了排除软引用和弱引用链路被人为截断。解决是处理缓存相关问题时把排除项全部关掉看完整链路里是否存在一条来自静态集合或者线程上下文的强引用同时用 Duplicate Classes 视图确认是不是类加载器层面的问题。6. 把 MAT 接进日常一套固定的看数与验证顺序用得久了会发现MAT 的价值不在某个视图有多强而在每次都用同一个顺序看避免被最显眼的那个数字带跑。我现在的固定动作是Histogram 按 Retained 降序扫前十记下可疑类名Dominator Tree 从根展开到累计 80%Leak Suspects 只看它给的类名不看结论最后拿两份间隔 dump 的对比差值做交叉验证。四步里超过两步指向同一个类才动手改代码。步骤看什么判据常见误判1Histogram 按 Retained 降序前十里是否有意料之外的类只按对象数排序2Dominator Tree 逐层展开累计到 80% 停把百分比当成相对父节点3Leak Suspects只取类名当线索直接照抄结论4两份 dump 对比实例数增量两次生成方式不一致验证阶段有两条硬标准我一般会强制执行一是找到的可疑对象必须在引用链上能指到一个具体的、可以改的代码位置指不到就说明还没查到底二是改完之后按同样的间隔再抓一份 dump用对比视图确认实例数不再增长只看监控曲线回落不算数因为那可能只是扩容或者重启带来的假象。还有一个能省很多时间的习惯每次抓完 dump 顺手重命名成应用名-实例-时间戳.hprof并在旁边放一个几十行的说明文件记下当时的 QPS、堆参数、有没有刚发版。半年后再翻出来复盘这份说明比 dump 本身还值钱没有它就只能靠猜。从那以后我每次做内存排查都会在抓 dump 之前先把这几个字段写进文件名和说明里再动手分析——希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑