简介:MAT(Memory Analyzer Tool)完整工具包,面向 Java 后端开发、运维与性能调优人员,用于分析 JVM 导出的 hprof 堆转储文件,定位内存泄漏、高占用对象及对象引用关系等问题。压缩包为 zip 格式,大小 75.44MB,共 2000 个文件:除 MAT 运行所需 jar、class、dll、exe 与 Eclipse 插件配置外,还包含 1891 个 HTML 帮助文档、326 个 PNG/GIF 示意图,以及 XML/Properties 等配置文件和 1 个可直接体验的 hprof 示例文件,目录结构完整,便于离线安装和学习。借助支配树、Leak Suspects 报告、Shallow/Retained Heap 对比和 OQL 自定义查询等核心功能,使用者能快速识别内存瓶颈并追踪根因;示例 hprof 文件与内置文档则降低了上手门槛,适合刚接触堆分析的开发者按步骤练习。目前已有 6613 人学习/下载,是 Java 内存故障诊断和性能优化实践中的实用工具。
1. MAT 到底在分析什么:一张 hprof 堆快照能救回一次线上事故
线上服务的内存曲线像心电图一样一路走高,重启后恢复,过几天再犯。最气人的是,JVM 不会告诉你到底是谁把内存偷走了。解决这类问题,唯一可靠的办法是在出事时留一份 hprof 堆转储文件,然后交给 MAT 分析。MAT(Memory Analyzer Tool)是分析 .hprof 文件的行业标准工具,它把堆里的对象还原成一张带引用关系的大图,算出每个对象占多少「保留内存」、被谁通过什么引用链留在堆里,最终把怀疑范围缩小到具体类和字段。这篇文章面向 Java 后端开发、中间件维护者和移动端开发者,目标是让你拿到一份 hprof 之后,能按固定流程定位到代码行,而不是靠玄学和猜。
2. 从获取堆快照到跑通 MAT:三种转储方式与第一个泄漏报告
在打开 MAT 之前,先得有一份合格的 hprof 文件。很多人在这一步就出问题:要么 dump 时机不对,要么文件不完整,导致后面所有分析都在做无用功。这一章先讲清楚三种最常用的转储方式,再解释 MAT 解析堆快照的核心原理,最后给出跑通首个泄漏报告的最小操作。
2.1 三种产生 hprof 文件的方式:jmap、jcmd 与 OOM 自动转储
生产环境产生堆快照,我常用的有三种方式,按场景选择。第一种是 jmap,最经典但注意它可能触发一次完整的 Stop The World;第二种是 jcmd,JDK 8 起自带,对生产影响更可控;第三种是启动参数里配置 OOM 自动转储,属于「后悔药」,出事时自动留证。
# 方式一:jmap 导出堆转储,PID 是 JVM 进程号 # live 表示只保留可达对象,格式必须是二进制(format=b) jmap -dump:live,format=b,file=/data/dump/app-2024-06-01.hprof 12345 # 方式二:jcmd 导出,JDK 8+ 推荐日常使用 jcmd 12345 GC.heap_dump /data/dump/app-2024-06-01.hprof-dump:live这个参数很多人不加,随手就丢掉了大量本可以当证据的对象;但反过来,如果你怀疑的是「不可达对象无法被回收」这类问题,加 live 反而会过滤掉关键信息。所以线上取证,我一般不加 live,直接导全量,宁可文件大一点,也不漏线索。format=b一定要带,这是二进制格式,MAT 只认这个;不带的话 jmap 默认输出的是文本格式,MAT 会直接拒绝。
# 方式三:OOM 自动转储,线上服务必须开 java -Xms4g -Xmx4g \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/dump \ -jar app.jar这两个参数配合使用:OOM 发生时 JVM 先把堆快照写盘,再抛异常。HeapDumpPath 指定目录即可,文件名会自动带上 PID 和时间戳。这里有个血泪教训:HeapDumpPath 指向的目录必须预先确认可写、空间足够。堆 4G 的实例,dump 文件通常也是 4~5G,磁盘写满会导致快照生成到一半就失败,等于白搭。另外建议配上-XX:+ExitOnOutOfMemoryError,让实例在 OOM 后直接退出,避免带病继续运行把现场污染掉。
2.2 MAT 解析堆快照的原理:为什么它能看见「谁把对象留住了」
hprof 文件里存的不只是对象列表,还包括对象之间的引用关系:实例字段、数组元素、静态变量、线程栈上的局部变量。MAT 做的第一件事,是把这些关系还原成一张有向图,JVM 的 GC Roots(静态字段、活跃线程栈帧、JNI 引用等)作为图的入口,然后从入口做可达性遍历。
在这张图上,每个对象能算出两类大小。Shallow Heap 是对象自身占的内存,包括对象头和字段引用,不包含它指向的其他对象;Retained Heap 则是「这个对象被回收时,连带能释放掉的所有对象的总内存」。后者依赖支配关系:如果对象 A 到 GC Roots 的所有路径都必须经过对象 B,B 一旦被回收,A 必然也被回收,就说 B 支配 A。MAT 把全图算成一棵支配树,每个节点的 Retained Heap 就是它支配的所有子孙节点的 Shallow Heap 之和。
这个数字的意义非常大:它直接回答「如果干掉这个对象,能省下多少内存」。所以定位泄漏,一律先看 Retained Heap,而不是对象个数或 Shallow Heap。理解了这一点,MAT 对你来说就不是黑匣子——Leak Suspects 报告能自动找出嫌疑,本质上就是扫描 Retained Heap 特别大、且被少数 GC Roots 引用链拉住的那些对象。
2.3 首次运行 MAT:GUI 打开与批处理生成泄漏报告
下载解压之后,我习惯把启动脚本软链到 PATH,后面所有命令都用mat这个别名,省得每次敲全路径。
# 解压后把启动脚本软链到 PATH,之后直接用 mat 命令 ln -s /opt/mat/MemoryAnalyzer /usr/local/bin/mat # GUI 方式打开堆快照,首次会要求设置工作区目录 mat -data /data/mat_workspace /data/dump/app-2024-06-01.hprof-data指定工作区目录,MAT 会把解析后的索引文件写在这里。同一份 dump 第二次打开时会快很多,因为索引已经落盘。注意工作区目录要放在剩余空间充足的磁盘上,解析大堆的临时索引可能跟 dump 文件差不多大。
如果需要自动化,比如定时对凌晨的 dump 跑一次泄漏报告,MAT 提供了无头批处理模式:
# 无头模式直接生成 Leak Suspects 报告,适合定时巡检 mat -data /data/mat_workspace -consolelog \ -application org.eclipse.mat.api.parse \ /data/dump/app-2024-06-01.hprof \ -report org.eclipse.mat.api.suspects-application后面填的是 MAT 的批处理解析入口,-report指定要生成哪类报告。不同小版本的入口 ID 可能有差异,跑之前先执行mat -consolelog -help 2>&1 | grep report确认你手里这个版本支持的写法。生成的报告是 zip 包,里面包含 HTML 版本的 Leak Suspects 和系统概览,可以直接发给同事看而不需要对方也装 MAT。
首次在 GUI 里打开完成后,MAT 会自动弹出 Leak Suspects 报告,界面分为三块:堆总体概览(总大小、类数量、类加载器数量)、嫌疑对象列表、以及附带的直方图摘要。每个嫌疑对象都有一条「最短引用链」指向它,列出了从 GC Roots 到聚集点的每一步引用。多数情况下第一个嫌疑就是根因,但别急着下结论——先翻到第二个嫌疑,有些时候第一个只是被某个大对象连带拉住的旁观者,真正的持有者在第二条链上。
3. Histogram、Dominator Tree、Leak Suspects:三个视图的定位逻辑
MAT 里定位泄漏,靠的不是某一个视图,而是三个视图交叉验证。只看 Histogram 会迷失在大数组里,只看报告会漏掉非典型泄漏。这一章把每个视图的读法和适用场景讲透。
3.1 Histogram:先找量大的类,但别只盯着 Shallow Heap
Histogram 是打开 dump 后最先看到的视图之一,它按类汇总实例数和内存占用。操作上,打开后直接点顶部的 Histogram 标签,然后右键任意列头选择 Sort By → Retained Heap,让最大的「保留内存」排到最前面。各列含义如下:
| 列名 | 含义 | 定位价值 |
|---|---|---|
| Class Name | 类名 | 判断对象属于哪块业务 |
| Objects | 实例数量 | 数量大可能意味着对象堆积 |
| Shallow Heap | 对象自身内存 | 价值有限,别在这列上纠结 |
| Retained Heap | 支配子树总内存 | 定位泄漏的核心指标 |
看 Histogram 时最容易翻车的地方,是 byte[]、char[] 这类基础数组常年霸榜。它们本身只是载体,一个 100MB 的 byte[] 可能只是一张正常的图片缓存或者一次大查询的批处理结果。正确姿势是右键这个数组 → List objects → with incoming references,看它到底被谁持有。如果持有者是业务实体,再进那个实体的支配链继续查。
我一般会把 Histogram 切换成按 package 分组:右键 → Group by → package。这一步能把几百万个散落的对象归拢到业务包下,一眼就能看出是订单服务、用户中心还是某个中间件包在吃内存。如果某个业务包下的对象数量和 Retained Heap 明显异常,基本可以确认问题方向,接下来就要看支配关系。
3.2 Dominator Tree:从支配者一层层剥到持有者
Dominator Tree 视图按对象间的支配关系组织成树,根是虚拟的,下面挂着被 GC Roots 直接或间接支配的对象。每个节点的子节点都被该节点支配,也就是说父节点一旦被回收,整个子树都会被释放。操作上,工具栏点 Dominator Tree,然后盯着每一层的 Retained Heap 占比往下钻。
这个视图解决的核心问题是:找出谁在为这堆对象「兜底」。举个例子,Histogram 里发现某个业务对象有 200 万个实例占 1.5G,但你直接在这些实例上右键查 Paths to GC Roots,看到的引用链会非常长,中间夹着各种内部类名,几乎不可读。正确的做法是先切到 Dominator Tree,从根节点逐层往下,找到第一个挂了大量业务对象的中间层——通常是一个集合、一个上下文对象或者一个线程——再从这个中间层出去查它的持有者,链路就短得多,认知负担也小得多。
这是一个被很多人忽略的习惯:Dominator Tree 看的是「谁在宏观上把这块内存钉住了」,Paths to GC Roots 看的是「具体的强引用边」。两者结合的顺序应该是「先树后链」,而不是反过来。另外,右键节点选择 Paths to GC Roots → exclude weak references,可以滤掉弱引用路径,只看强引用,这对判断泄漏至关重要,因为弱引用路径在 GC 时本来就会被清理,构不成泄漏。
3.3 Leak Suspects 报告:嫌疑对象与最短引用路径一起读
Leak Suspects 是 MAT 自动化程度最高的视图,它会主动列出若干个嫌疑点,每个嫌疑点包含三部分信息:描述文字(说明是什么类型的对象聚集了大量堆内存)、从 GC Roots 到聚集点的最短引用路径、以及聚集点内的对象明细。打开方式是在 Overview 视图里点 Leak Suspects 链接,或者通过菜单 Window → Open View → Leak Suspects。
读这份报告,重点看两个东西。第一是嫌疑占比,报告会说明这个嫌疑的 Retained Heap 占整个堆的百分比,超过 30% 的嫌疑基本可以按泄漏处理;占比只有百分之几的,先放到一边,可能是正常的活跃数据。第二是最短引用路径的形态,这条链上如果出现了 ThreadLocalMap、静态集合、ClassLoader 这类字眼,方向基本就定了。
报告默认只展示前几个嫌疑,点 Details 展开才能看到完整的嫌疑清单。还有一类情况是 Leak Suspects 什么都报不出来,比如内存分散在几万个互相引用的对象里,没有明显的聚集点。这时候别死磕报告,回到 Histogram 按 package 分组手工排查。反过来说,如果第一个嫌疑已经报了 70% 的堆占比,就不要再浪费时间去翻其他视图了,直接按下一条的定位流程去实锤。
4. 从怀疑到实锤:一个池化对象泄漏的完整定位路径
理论讲完,进入实战。某中间件维护团队接过一个内部模拟项目:任务处理服务每隔三四天内存占满 4G 堆,重启恢复,周而复始。配置了 OOM 自动转储之后,拿到了一份完整的 hprof。按上一章三个视图交叉定位,嫌疑锁定在一个业务任务对象上:实例数 120 万,Retained Heap 约 2.8G,而它的正常数量应该只有几百。这一章走完整个定位路径。
4.1 用 Paths to GC Roots 把「嫌疑」变成「实锤」
流程分三步。第一步,在 Histogram 里按 Retained Heap 降序,确认嫌疑类以及它的实例规模。第二步,在该类上右键 → Merge Shortest Paths to GC Roots → exclude all weak/soft references。第三步,看合并后的引用链列表里,是否出现成规模的同一模式链路。
定位后看到的引用链大致是下面这个形态:
TEST-pool-3-thread-12 └─ ThreadLocal.ThreadLocalMap$Entry └─ taskContext (TaskContext) └─ tasks : ArrayList └─ TaskItem (... 88 MB)TEST-pool-3-thread-12这个线程名暴露了很多信息:这是一个池化线程,线程名里的pool-3说明它来自一个默认命名的线程池。整条链路都是强引用:线程是 GC Root,ThreadLocalMap 里有一个 Entry 的 key 是 TaskContext 类型,value 或者关联对象是 ArrayList,里面堆了几十万个 TaskItem。
怎么确认这不是单个线程的偶发现象?看合并后链路列表里线程名的分布。如果几十条链路分别挂在TEST-pool-3-thread-xx的不同线程上,而且 ThreadLocalMap 里的 key 都是同一个 TaskContext 类型,说明这是池化线程的普遍持有,而不是某一次请求的异常。到这里,嫌疑已经变成实锤:TaskContext 挂在工作线程的 ThreadLocal 上,线程被池子长期复用,上下文只存不清理,每个线程都揣着一串历史任务。
4.2 ThreadLocal、静态集合与缓存:三类最常翻车的持有者
上面的案例属于 ThreadLocal 未清理。实际排查中,持有者模式无非那么几类,提前对号入座能省一大半时间。我按出现频率整理了一张对照表:
| 持有者模式 | 泄漏特征 | 判断方法 | 常规修法 |
|---|---|---|---|
| ThreadLocal 未清理 | 池化线程的 ThreadLocalMap 里堆积业务对象 | GC Roots 链路出现 ThreadLocalMap.Entry,线程名来自线程池 | 业务 finally 中显式 remove,或在池包装层统一清理 |
| 静态集合当缓存 | 静态 Map/List 只增不减,业务对象被 key 长期强引用 | 引用链顶端是某个类的静态字段,且集合 size 巨大 | 给缓存加容量上限和过期策略,改用弱引用 key 的缓存组件 |
| 池化对象持有会话 | 连接池、线程池中的长生命周期对象引用着大批业务数据 | 最大 Retained Heap 的对象挂在池对象的字段下面 | 归还时显式置空字段,缩短对象最长占用时间 |
ThreadLocal 是这三类里最容易出现的,而且很多情况下不是业务代码主动作恶,而是框架内部用 ThreadLocal 存了上下文——事务信息、traceId、登录态——在异步线程池里复用线程时没有触发清理。判据就是一条:引用链穿过了 ThreadLocalMap,且线程来自线程池。修复动作也不复杂:在代码仓库里全局搜 ThreadLocal.set,逐个确认是否成对出现了 remove,尤其是放在 finally 块里的 remove,别只删 value 不删 key。
静态集合当缓存也很典型,常见于工具类里写死了一个static Map,初始设计的容量很小,后来业务量上来,只 add 没有淘汰,最终把整个堆撑住。这种持有者的特征非常明显:引用链顶端是一个类的静态变量,而且这个集合里对象的 Retained Heap 占比非常大。修法不只是「换缓存组件」,先确认谁在写、有没有删除入口,再决定加容量上限还是老化策略。
4.3 回到代码行:用对象内容反推业务入口
引用链只能回答「谁持有」,回答不了「这些对象是从哪条业务路径进来的」。要反推业务入口,得看对象内部字段的内容。MAT 里最简单的做法是右键关键对象 → Copy → Values,把对象字段值复制出来;也可以直接用 OQL 把多个对象的关键字段批量导出:
SELECT toString(o.id), toString(o.taskType), o.@retainedHeapSize FROM com.sim.proj.task.TaskItem o ORDER BY o.@retainedHeapSize DESC这条查询把 TaskItem 的 id、taskType 和保留内存一起打出来,按内存倒序排。拿到 id 之后,去日志系统里反查这几个任务是在哪个接口、哪个时间点创建的,业务入口瞬间就浮出水面。这里有两点要注意:一是 MAT 的 OQL 默认能直接访问对象的私有字段,所以不用加反射式的特殊语法;二是如果对象的 toString 没重写,打印出来是一串类名加 @hash 地址,没有可读性,建议用toString(o.字段名)逐个字段取。
整个定位路径到这里闭环:堆快照里的内存证据 → 引用链锁定持有者 → 对象字段反查业务入口 → 代码层修复。这套流程比任何监控告警都确定,因为堆快照是静态事实,不会漏报也不会误报。唯一要做的就是把 OOM 自动转储先打开,没有快照,后面一切都白搭。
5. MAT 使用避坑与常见问题排查:解析失败、自身 OOM 与误判现场
工具用顺手之后,坑往往不在分析本身,而在数据准备和环境配置。这一章整理五条最常踩的坑,每条按「现象 → 原因 → 解决」来写,都是能直接落地的排障记录。
5.1 打不开 hprof:截断、权限与格式转换
现象:打开文件时提示 Failed to parse,或者直接说不认识这个文件格式。原因:最常见的是 dump 文件没传完。生产环境导出的 hprof 动辄几个 G,用传输工具拉到本地时中断过,文件不完整;第二种是文件权限不对,MAT 没有读权限时不会明确报 Permission denied,而是报解析失败。解决:先ls -l确认文件大小和源端机器上的一致,再head -c 20 <文件>看文件头,标准 HPROF 文件以二进制序列开头,包含JAVA PROFILE字样。移动端导出的 heap dump 有的是私有格式,MAT 不能直接识别,需要先用对应 SDK 自带的转换工具转成标准 HPROF 再喂给 MAT。
5.2 分析大堆时 MAT 自身 OOM:改 Xmx 的正确姿势
现象:打开一个 8G 的 hprof,解析进度条走到一半卡住,用-consolelog模式跑时日志里报 OutOfMemoryError。原因:MAT 要把对象图索引放进自己的 Java 堆里,快照越大,它自己需要的内存越大,默认配置经常不够。解决:编辑安装目录下的 MemoryAnalyzer.ini,macOS 装在 .app 包内 Contents/Eclipse 目录下,改-Xmx参数。我用的参考值如下:
| 堆快照大小 | 建议 MAT 的 -Xmx | 备注 |
|---|---|---|
| 4G 以下 | 2G ~ 4G | 默认配置基本够用 |
| 4G ~ 16G | 8G ~ 12G | 机器物理内存要顶得住 |
| 16G 以上 | 快照大小的 1.2 ~ 1.5 倍 | 建议用批处理模式,不要开 GUI |
注意别把 MAT 的-Xmx调到物理内存的上限,解析期间还要留内存给操作系统和文件缓存。另外临时索引占用磁盘空间,工作区目录建议放到 SSD 上。还有一点:不要用 32 位 JDK 去跑 MAT 分析大堆,地址空间根本不够,换 64 位。
5.3 打开后堆里找不到嫌疑对象:Dump 时机错了
现象:Histogram 里没有明显的大对象,Leak Suspects 报告一片空白,或者报告里列的嫌疑对象在代码里根本找不到对应业务。原因:dump 的时机不对。最常见的是内存水位还没涨起来就抓了快照,自然看不出问题;另一种是用jmap -dump:live导出,live 选项会先做一次 Full GC,把那些「本该被回收但被错误持有」的对象标记成弱可达甚至不可达,直接过滤掉了核心证据。解决:dump 时机要和监控报警联动,内存达到警戒水位时立刻抓,而不是人工上去再抓。如果怀疑对象是短暂聚集后就消失的类型,缩短抓取间隔,同一时段多抓几份,用时间维度上的对比来确认增长趋势。
5.4 Shallow Heap 排名误导:别把数组当泄漏元凶
现象:在 Histogram 里按 Shallow Heap 排序,byte[]、char[] 排在最前面,所以一看到大数组就追查它,折腾半天发现是正常的网上传缓冲或文件读取缓冲。原因:byte[] 是所有 IO 操作的通用载体,一个 byte[] 可能被几十个对象共同引用,单独看它的 Shallow Heap 没有意义,它既不代表泄漏,也不代表 Java 堆里实际可以被释放的对象。解决:不要从数组出发反查持有者,而是从业务类出发查它支配了什么。把视图切到按 package 分组,先定位业务包,再在那个包的对象上右键查支配关系。判断内存占用要始终看支配者的 Retained Heap,而不是被支配数组的 Shallow Heap。
5.5 版本与 JDK 兼容:老版本 MAT 解析新 JDK 快照翻车
现象:用几年前下载的 MAT 打开 JDK 17+ 导出的 hprof,解析报错,或者 Histogram 里出现类名怪异的 record 类、某些类显示 0 实例却占着大量内存。原因:JVM 的对象布局和堆元数据格式随版本演进,老版本 MAT 的解析器没有跟上新格式。另外 jmap 和 jcmd 导出的文件在头部信息上有差异,某些版本组合下只有其中一种能正常解析。解决:优先下载最新版的 MAT,并且把 MAT 的升级纳入你的环境维护清单,跟 JDK 一起升级。一份 dump 解析失败时,换 jcmd 再导一次,兼容性通常会更好。千万别用「能用就不动」的心态对付这类工具,堆分析工具的版本敏感度比一般软件高得多。
6. 进阶技巧:用 OQL 与堆对比把内存分析时间砍半
6.1 OQL 三条最常用查询
排查熟悉之后,点鼠标的效率太低,我会直接用 OQL 视图(Window → Open View → OQL)跑查询,三条语句覆盖大部分场景。
-- 找出 Retained Heap 最大的前 20 个对象,定位堆里真正的大头 SELECT toString(o), o.@retainedHeapSize FROM java.lang.Object o ORDER BY o.@retainedHeapSize DESC LIMIT 20-- 按业务字段聚合,看某个状态下的对象占了多少内存 SELECT toString(t.status), count(t), sum(t.@retainedHeapSize) FROM com.sim.proj.task.TaskItem t GROUP BY toString(t.status) HAVING sum(t.@retainedHeapSize) > 1048576-- 找出超过 1MB 的大字符串,通常是可疑的缓存或日志堆积 SELECT toString(s), s.@retainedHeapSize FROM java.lang.String s WHERE s.@retainedHeapSize > 1048576 ORDER BY s.@retainedHeapSize DESC@retainedHeapSize是 MAT OQL 的内置属性,表示对象支配子树的保留内存;@usedHeapSize表示该对象实际占用的堆内存。注意LIMIT和HAVING的写法跟 SQL 一致,但聚合函数是count()、sum()这种小写形式,跟标准 SQL 略有差异,报语法错时先检查这里。
6.2 堆对比:两个时间点的快照才是终审判决
单张快照只能告诉你「现在谁大」,回答不了「谁在涨」。判断是否泄漏,最硬的证据是时间维度上的增长。做法是:在 Navigator 里选中同一实例两个时间点的 hprof,右键 → Compare,生成对比报告;也可以分别打开两份的 Histogram,右键其中一份 → Add to Compare。对比结果里每行是一个类,列出两次快照的实例数和 Retained Heap 差值。只看 Delta 增量排在前面的类,增量最大且持续增长的目标就是泄漏点,排查效率比单看绝对值高一倍不止。
我现在的习惯是:只要线上服务出现两次内存报警,就确认 OOM 自动转储已开启、HeapDumpPath 目录可写,然后等一份真实快照,按这套流程半小时内给出结论。与其在上线前纠结那些调优玄学,不如把「抓快照 → 跑 MAT → 看支配链 → 反查字段」这条路径练成肌肉记忆,这是内存问题最不需要运气的解法。希望帮到你。
本文还有配套的精品资源,点击获取