做Java服务端的朋友,十有八九都遇到过内存溢出的情况:系统跑着跑着CPU飙高、接口超时,甚至直接OOM宕机,重启后过一段时间又复现。这时候MAT——Eclipse Memory Analyzer就是我最先掏出来的工具。别把它想得多玄,它就是一个专门用来分析JVM堆转储文件(heap dump)的可视化工具,帮你把堆里到底存了什么、谁占了内存、泄漏点在哪,一层层扒清楚。这篇文章不打算给你讲一堆晦涩的理论,而是把我自己这几年用MAT排查问题的完整套路、踩过的坑、还有那些通常没人写在文档里的细节,一次性讲明白。适合刚入门的新手,也适合已经用过但不一定用得溜的同学。
1. 工具是什么:MAT的定位与适用场景
1.1 什么时候该掏出MAT
很多人把MAT当成一个“OOM之后才用”的工具,这其实有点把它的能力想窄了。我用下来的经验是,只要跟JVM堆内存相关的问题,基本都能靠它理出头绪:
- 服务频繁Full GC,接口响应越来越慢,你怀疑有对象在堆里堆积,但不知道是什么对象;
- 线上直接OutOfMemoryError,业务方催得急,需要快速定位是哪块业务代码把堆撑爆了;
- 压测之后内存一直降不下来,怀疑有内存泄漏,但通过代码审查一时半会找不到嫌疑对象;
- 怀疑某个全局缓存、静态集合、ThreadLocal把对象长期引用住,需要看GC Roots引用链来确认;
- 想让某个高消耗的存活对象“现出原形”,比如大字符串、大数组、连接池对象数量异常膨胀。
说白了,MAT干的事情就是:把JVM某一时刻的堆快照(heap dump)读进来,然后通过对象数量、占用的浅堆/深堆大小、引用关系等维度,帮你回答三个问题——堆里有什么?谁占得最多?这对象是怎么被到达(引用)的。这三个问题一旦有答案,后面改代码就有了目标。
1.2 先建立直觉:一个典型的OOM排查现场
我举个这几年最常遇到的例子。某个定时任务系统每天凌晨跑批,跑一段时间后半夜必定OOM,运维重启后第二天又复现。这种问题最头疼的地方在于:不是一启动就会挂,而是运行一段时间才爆发,典型的“缓慢泄漏”特征。
我们的排查流程是这样的:先给服务加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/dump.hprof,等第二天OOM发生之后拿到dump文件,然后用MAT打开,直奔Leak Suspects Report。报告里往往直接画出一条主导线:xxxService里的一个静态HashMap,不断往里put数据却没清掉,key还是一个每天新增的日期字符串——这样日积月累,堆就被撑爆了。
这个场景引用了一个关键经验:内存泄漏排查早就不是看代码就能拍板的事情,借用工具对堆对象做定量分析,定位速度会快得多。下面就从安装准备开始,把每一步操作讲透。
2. 准备与连接:下载安装、拿到Heap Dump
2.1 下载、安装与自身内存配置
MAT是基于Eclipse RCP构建的独立工具,官方叫“Memory Analyzer”,现在一般从Eclipse官网下载独立发行版,解压就能用,不需要再额外装Eclipse全家桶。注意两个细节:
- 版本选择:如果你的JDK是17甚至21,建议下载最新版的MAT(2023年以后基本上都是基于Eclipse 2023-06+ 构建的版本),老版本在解析某些新JDK产生的堆转储时可能出现格式不兼容或者直接打不开的情况。
- 运行环境:MAT本身是Java程序,它需要自己的JVM来跑。虽然默认配置就能启动,但在解析几个GB的大堆dump时,默认的内存参数大概率不够用。
这里请务必打开安装目录下的MemoryAnalyzer.ini文件,找到类似这两行的位置:
-Xms1024m -Xmx1024m建议根据你机器的物理内存,把-Xmx改成至少4096m甚至更高。我的经验值是:解析的dump文件大小,最好不要超过MAT自身堆内存的50%~60%。比如你要分析一个4GB的heap dump,那MAT自身-Xmx最好给到8GB,否则后面点击对象、计算保留堆时经常卡死。改完重启MAT。
提示:不要只改
-Xmx不关心-Xms,两者保持一致可以避免运行过程中频繁扩容导致的额外停顿。
2.2 三种拿Heap Dump的方式
MAT分析的第一步,是手里得有一份heap dump文件。拿dump的方式很灵活,我常用的有三类:
方式一:通过jmap手动导出
这是最传统、最直接的方式:
jmap -dump:live,format=b,file=/data/logs/dump.hprof <pid>-dump:live表示先触发一次Full GC,只保留存活对象再导出;format=b指定hprof二进制格式。注意:加了live导出的dump比较小,因为死对象被过滤掉了,但如果你的目的是排查“为什么对象没被回收”,建议不要加live,否则你根本看不到那些本该回收却没回收的对象。
方式二:通过jcmd导出
jcmd是JDK 7之后官方推荐的诊断命令,跟jmap的用法还有一点区别。先通过jcmd <pid> GC.heap_dump /path/dump.hprof导出,注意这里不需要-dump:前缀,格式更加简洁。如果你面对的是容器环境,经常能在镜像里找到jcmd:
jcmd 12345 GC.heap_dump /data/logs/dump.hprof方式三:通过启动参数自动生成
这是我最推荐上生产环境的方案。在JVM启动参数里加上:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/dump-<pid>.hprof这样一旦发生OOM,JVM会自动把当时的堆快照写到指定文件。好处是:抓的是OOM现场的第一手数据,不需要人工介入,而且Dump发生在即将崩溃前,堆状态最接近问题爆发的真实情况。
在线下环境,如果你想在没有OOM的时候“预防性”地抓快照,也可以考虑jhsdb jheap dump --pid <pid>,它是JDK 9之后提供的新工具之一。不过日常排查我还是优先jmap和jcmd,稳字当头。
2.3 生产环境导出dump的时机与注意事项
在生产环境手动导dump是一个非常敏感的操作。因为导出过程会触发一次STW(Stop-The-World)停顿,尤其在堆很大、对象很多的时候,停顿时间可能达到几十秒甚至分钟级别。所以你要提前评估:
- 优先选择业务低峰期,或者在多实例服务中先摘除某一台实例的流量再导出;
- 不要用
jmap -dump对一台几十GB堆的在线服务反复操作,否则很容易把可用性打没; - 如果只是定位普通的内存占用问题,先用
jmap -dump:live缩小体积;如果是怀疑泄漏,最好关闭live过滤,保留完整堆快照; - 导出完成后立刻用
gzip压缩文件,hprof文件压缩率极高,动不动就能压到原来的十分之一,方便你拉回本地慢慢分析。
3. 首次打开Dump:关键视图与第一层判断
3.1 打开dump后如何选择分析维度
用MAT打开一个hprof文件后,第一屏通常是一个向导,里面有:Leak Suspects Report(泄漏嫌疑报告)、Component Report、Overview(概览)等选项。
很多新手会直接点Leak Suspects,这没问题,但我建议第一次打开一个大堆dump时,先点Overview看整体面貌。Overview页面会显示:
- 总堆大小:确认这份dump是不是真的跟问题规模相符;
- 类加载器数量:如果数量异常多,很可能有类加载器泄漏;
- 对象总数、Class总数:这些基础指标能快速帮你建立直觉;
- 还有几个快捷键入口,最常用的是Histogram和Dominator Tree。
这里还藏着一个实用功能:Overview右上角的“Overview”视图右侧,有个“Actions”区域,可以一键打开Leak Suspects Report、Histogram、Dominator Tree、Top Consumers。多花十秒钟看概览,再决定下一步点哪个,属于“磨刀不误砍柴工”。
3.2 Histogram:用“直方图”看对象分布
Histogram是整个MAT里我用得最多的视图。它本质上是一个表格,按Class类型统计对象数量,并给出两个关键列:
- Shallow Heap(浅堆):对象本身占用的内存,不包括它引用的其他对象;
- Retained Heap(保留堆):如果把这个对象当作根来回收,连带能释放多少内存,通俗说就是“这个对象真正撑住的内存吞吐量”。
注意:排序列一定要选Retained Heap,因为浅堆大小很多时候很有欺骗性。比如一个HashMap$Node的Shallow Heap可能只有几十字节,但如果它挂着一条长长的链表,Retained Heap可能是几MB。我曾经排查过一个“看似很小的字符串列表把堆撑爆”的案例,单看Shallow Heap完全找不到问题,按Retained Heap从大到小排序后,问题对象立刻浮到顶部。
还有一个容易被忽略的小技巧:右键任意Class,选择“Merge Shortest Paths to GC Roots”,选择“exclude all weak/soft references”。这一步能把被弱引用、软引用、虚引用“掩盖”的对象路径过滤掉,直接看强引用链,这样能更快区分“真泄漏”还是“假活”。
3.3 Dominator Tree:顺藤摸瓜找支配关系
Dominator Tree(支配树)是按“支配关系”组织的对象视图。什么是支配关系?简单说:从GC Roots出发,如果所有能到达对象B的路径都必须经过对象A,那么A支配B。举个例子,一个线程对象如果被某个连接池对象强引用,而连接池对象又被静态变量引用,那连接池对象就是支配者。
我把Dominator Tree当“排序后的对象依赖关系树”来用:打开之后默认按Retained Heap从大到小排列,你一眼就能看到堆里最大的那几棵“顶树”是什么。如果想看某个大对象背后到底是谁在引用它,右键 ->Path to GC Roots,选择show all paths,就能看到所有引用链条。
这里有个理念性问题:看到一个大对象,不等于找到了泄漏。比如一个很大的byte[]数组,它可能是一个大文件读取缓存,也可能是业务数据本身。真正的判断节点在于“这些对象是否一直存在、是否还在增长、是否被业务强引用着不被释放”。
3.4 Thread Overview:线程角度排查
在怀疑“某个线程栈持有大对象”或者“线程数量异常”的场景里,Thread Overview非常有用。它会把dump时所有线程列出来,每个线程都关联一个Retained Heap,并且能看到线程当前的栈帧信息。
我印象最深的一次:某个服务线程数已经冲到两千多,看起来像是线程泄漏。用Thread Overview一看,大量线程阻塞在java.net.SocketInputStream.socketRead0,Retained Heap各有几百KB。一查代码,原来是一个HTTP调用没有设置连接超时和读超时,上游接口卡住,导致线程池被占满,同时每个线程都持有自己的请求上下文对象,堆内存被这一批批“卡住的线程”吃光。这种问题如果不从线程维度切入,单看Histogram很难想到是线程本身造成的。
Thread Overview还有一种用法:当怀疑“某个任务执行后没释放局部变量”,你可以对比几个工作线程,看哪些线程Retained Heap异常偏高,再逐个点上Thread Dump看栈帧,配合代码就能直观发现哪个方法“留了尾巴”。
4. 深入定位:Leak Suspects、OQL与引用链确认
4.1 Leak Suspects报告的正确打开方式
当你想快速得到“疑似泄漏点”的汇总,Leak Suspects Report确实是第一选择。它会把dump中疑似泄漏的对象聚合成一个个“suspect”,每个suspect给出:
- 占用的保留堆大小;
- 嫌疑对象的类型;
- 引用链的简述(往往直接指向某个类加载器、某个集合类、某个业务对象)。
打开方式很简单:工具栏点“Leak Suspects”,或者从Overview的Actions区进入。报告生成后,通常第一个suspect的Retained Heap就占了整个堆的70%以上,优先看它。
但这里我必须泼一盆冷水:自动报告出来的“嫌疑对象”,并不等于就是“泄漏的发生者”。它很多时候只是告诉你“有一个巨大的对象结构体”,你还要继续追查入口。我见过太多人看到Leak Suspects写了java.util.HashMap,就直接断定是“HashMap泄漏”,结果HashMap其实是某个业务缓存,真正的原因是无界使用,不是Map本身的问题。
所以正确姿势是:用Leak Suspects做初步方向,然后跟到对象本身,右键Path to GC Roots,回到代码逻辑里去确认入口和生命周期。
4.2 从对象到GC Roots引用链:把代码揪出来
真正的“定案”阶段,我几乎每次都走同一条链路:
- 在Histogram里锁定某个Retained Heap偏大的Class;
- 右键打开该Class的实例列表(List objects -> with outgoing references);
- 在实例列表里再选中一个典型对象,右键 -> Path to GC Roots -> show all paths;
- 去掉软引用、弱引用、虚引用以及JNI引用等干扰项;
- 最终观察是否有一条“非常不合理”的强引用链,比如被某个静态集合长期持有。
这一步的核心是想清楚一个问题:对象为什么没有被回收?只有当GC Roots路径上的某个节点是业务代码里的静态字段、常量、缓存容器、线程对象时,你才能确定泄漏源在哪个类里。
一个非常典型的例子:你在Path to GC Roots里发现一个业务对象被ThreadLocal持有,而ThreadLocal又是被某个线程对象持有的。再往上看,这是一个不会被销毁的长生命周期线程,那么“线程池内ThreadLocal变量未remove”就是根因。这种链条不看引用路径,光看对象本身是完全得不出结论的。
4.3 OQL:比界面更灵巧的堆内查询
MAT自带一个类似SQL的查询语法叫OQL,入口在工具栏的“OQL”按钮。它跟SQL很像,但你能直接对堆里的对象做条件过滤,特别适合在几百万个对象里找出特征明显的目标。
我常用的几条OQL,先给你贴上:
-- 找出所有长度超过100万的字符串 SELECT s FROM java.lang.String s WHERE s.value.length > 1000000-- 按Character数组大小降序查看最大数据块 SELECT s.value.length AS len, s FROM java.lang.String s ORDER BY len DESC LIMIT 20-- 找出所有HashMap实例,并显示Size属性(如果有的话) SELECT h FROM java.util.HashMap h-- 找出所有没有eleData的byte[] 长度超大的数组 SELECT a FROM byte[] a WHERE a.length > 1048576还有一些进阶写法,比如查对象引用:
SELECT OBJECTS java.lang.reflect.Field.f FROM java.lang.reflect.Field fOQL的语法文档网上很多,但我的实际建议是:优先掌握三件套——按字段长度/数值过滤、按类型查询、排序输出TOP N。如果连这都不会用,绝大多数场景你切回Histogram右键操作也来得及;OQL的价值在于批量筛选和自动化脚本化,比如一次查询吐出几十个嫌疑对象列表,再去逐一核对。
4.4 结合代码与GC日志验证结论
很多人到这里就收工了,但这恰恰是实战中最容易翻车的一步。
MAT给出的所有结论,都只是堆快照层面的统计学证据,它证明了“此刻堆里有这样一个对象结构”,不直接证明“这就是导致系统的内存问题的根因”。因此在改代码之前,我强烈建议再做两件事:
- 打开同一时段的GC日志。如果
gc.log里显示老年代持续上涨,而年轻代回收后对象年龄一直变化不大,那说明对象大概率是“存活对象”而不是“频繁创建未回收”; - 反查业务代码中的创建和释放逻辑。比如你用MAT看到一个
LinkedList中有上千个订单对象,那就需要确认它的生命周期是不是应该随请求结束而销毁,如果答案是否定的,那这就是根因。
我习惯把MAT的结论当成“嫌疑人名单”,把代码逻辑和GC日志当成“审讯证据”,两相结合才敢在发布会上拍板说“就是这里泄漏了”。
5. 实战避坑建议与排查心得
5.1 常见操作误区和新手易踩的坑
这几条都是我在内部培训或者带新人时反复强调的,属于“文档里不一定写,但实际绊倒过无数人”的细节:
- 别一上来就Leak Suspects,也别完全不信它。先看Overview和Histogram,心里对堆构成有个谱,再用自动报告去验证你的猜测,这样被报告误导的概率低很多。
- Retained Heap不等于“这个对象需要被释放的内存”。它只是一个计算值,不同视角的“Retained Set”定义略有差异,不要拿着两个对象的Retained Heap做绝对精确的加减。
- 导出dump时如果用了live,那Full GC已经把弱引用清掉了,你看不到已经被回收前的临时对象。排查“瞬时大量对象”问题时,需要不带live导出一份完整堆。
- 警惕MAT解析大文件时的内存爆掉。前面说过,
MemoryAnalyzer.ini里的-Xmx一定要按需调整。如果机器内存有限,可以用-Xmx4096m配合-Xms1024m,然后尽量分析压缩后的dump。
5.2 一个大dump解析卡顿或解析失败怎么办
我碰到过几次“文件有8GB,MAT打开后直接白屏”的情况,处理顺序很实用:
- 先确认物理机可用内存确实够大,再检查
MemoryAnalyzer.ini的-Xmx; - 如果仍然卡,尝试用
jhat或者jhsdb jmap histo先跑一个类级别的统计,大致预览哪些类占比最高; - 也可以考虑用Eclipse MAT的“Reduce Dump”功能,新建一个只保留特定包名或特定Class的dump子文件,然后对新文件再做分析;
- 实在不行,用
-XX:+HeapDumpOnOutOfMemoryError重新生成一个较小的dump,配合live参数控制体积。
这里还要说一个土办法:用gzip压缩hprof文件后,MAT其实是支持直接打开gz压缩格式的。你没必要把几GB的原始文件拉到本地,直接在服务器上压缩,再把.gz下载下来,MAT解析时会自动解压。
5.3 对比法:用两个dump定位“增长点”
对于“服务运行一段时间才爆”的渐进式问题,单个dump的参考价值有限,更好的做法是在不同时间点各导出一份dump,然后用MAT的Compare功能做对比。
具体用法:
- 在线下或压测环境中,t1时刻正常业务运行中导一份dump;
- 让服务继续运行,等内存涨到警戒值附近,t2时刻再导一份dump;
- 打开MAT,把两份dump都加载到同一个Session里,右键任意一个类 -> Compare -> 选择另一份dump;
- 看两个dump中各类的
Object Count和Retained Heap差异,增长最快的那个类就是要追查的方向。
这个方法的威力在于:能抹掉“本身就应该占用的大对象”的干扰,只放大“从t1到t2这段时间新增的占用”。有一回我们用一个很不起眼的java.util.LinkedHashMap属性,对比出来它增长了两万多条记录,最后定位到是因为接收了外部消息后没有做幂等去重——这种问题靠静态看代码几乎找不到。
5.4 我常用的一套餐固定步骤
最后归纳一下我现在排查线上OOM的标准动作,供你参考:
- 先加
-XX:+HeapDumpOnOutOfMemoryError部署,等下次OOM自动出dump; - 用jcmd或jmap在业务低峰期采集第二份“内存高峰前”的dump;
- 用MAT打开OOM dump,先看Overview确认总堆规模和对象总数;
- 进Histogram,按Retained Heap排序,圈出前20个嫌疑类;
- 对嫌疑类实例做Path to GC Roots,剔除软/弱引用,找到强引用入口;
- 用OQL进一步筛选特征对象,比如超大数组、超多key的Map;
- 结合GC日志和业务代码确认根因,修复后按同一场景压测复测;
- 顺手用两份dump对比验证内存曲线是否回归正常。
这套流程看着长,熟练之后其实半小时内能跑完一轮。尤其是手头有自动出Dump的机制时,整个排查节奏会非常顺。
6. 最后再分享一个小技巧
用MAT这几年,我最深的体会是:这个工具真正的门槛不在工具本身,而在你怎么定义问题。你只有清楚“我要找的是哪个时间点的哪个对象结构”,才能把Histogram、Dominator Tree、OQL这些功能用到刀刃上。所以我强烈建议你在平时压测时就经常导dump、开MAT看看堆结构,先混个眼熟,等线上真出了OOM,你就不会慌,因为你对整个堆的平时状态已经有了直觉。
另外一个小习惯值得保留:每次分析完一个dump,把关键截图和OQL语句记到团队的wiki或者笔记里。下一次遇到相似问题,翻旧笔记往往比重新分析一遍更快。这就是我在实践中最真实的感觉——MAT只是抓手,真正值钱的是你对自己系统堆内存“正常长什么样”的那份了解。