前一阵排查一个内部系统的内存问题,老年代每次 Full GC 之后不但没降下来,反而稳定上浮,照这个趋势,深夜必然 OOM。当时手头能用的工具不多,最终是 MAT(Eclipse Memory Analyzer)帮我从几百万个对象里揪出了那个不该存在的强引用。今天就把这一套分析流程完整写下来。
内存泄漏这个话题总是被讲得玄乎,但落到实际排查时,无非是两件事:拿到一份合格的堆转储,然后顺着引用链找到那个不该活着的对象。MAT 在这两件事上都足够顺手,而且免费开源,社区资料也多。无论你是刚接触 JVM 调优的初级开发,还是已经处理过几轮线上事故的运维,这篇文章给出的路径大概率都能直接复现。
我会分成几个部分:先讲判断泄漏的指标,再讲堆转储怎么抓、MAT 怎么装,接着用一个小案例完整走一遍从 Dominator Tree 到 GC Roots 的排查链路,最后聊聊 MAT 误报和容易踩的工具坑。全程用我实际踩过的坑说话。
1. 先搞清楚:什么样的现象才算内存泄漏
1.1 泄漏和内存耗尽的本质区别
很多人看到内存涨就喊泄漏,其实真不一定。JVM 里面有两种典型情况:一种是“一次性大对象”导致的内存暴涨,比如某个时间段批量加载了几百万条记录,Young GC 或 Full GC 之后内存马上回落,这种叫“内存压力过大”或“瞬时峰值”,不是泄漏;另一种是对象被不该存在的引用一直霸占着,垃圾回收清理不掉,堆使用率随着业务运行越来越高,这种才叫泄漏。
用生活里的事打比方:前者像是有人一次性往水桶里倒了一大盆水,水面短暂上升,但把水放掉就恢复了;后者是桶底有个针眼大的洞,水一直在漏,你一边加水它一边漏,永远回不到初始水位。JVM 里那个“针眼”就是强引用链:一个对象只要还被 GC Roots 强引用,哪怕它已经毫无用处,也会一直存活。
判断是不是泄漏,不能只看一次堆转储,也不能只看内存用了多少。标准动作是看趋势:在业务稳定的情况下,堆使用率持续向上走,而且每次 Full GC 都无法把它压回原来的水平,基本就能确诊了。
1.2 通过GC日志确认泄漏趋势
我在实际排查中,很少直接拿着 MAT 上来就瞎点,第一步永远是翻 GC 日志。如果不是垃圾回收日志已经证明“回收不掉”,后面所有分析都可能做无用功。怎么看 GC 日志?重点看老年代(Old Generation)和 Full GC 前后的空间变化:
假设你的堆是 4G,老年代 2G。一开始 Full GC 之后老年代占用稳定在 1.2G,但运行几个小时后,Full GC 之后占用变成了 1.5G,再过几个小时变成 1.8G,而且每次 Full GC 触发间隔越来越短。这种“老年代水位不断上移”的曲线,就是最直接的泄漏信号。
如果是正常的大对象处理,Full GC 之后老年代应该能清到一个比较稳定的水位。当然,有些系统里确实存在合法的长生命周期缓存,比如静态配置、连接池、线程池相关对象,它们在 Full GC 后仍会保留,但只要水位恒定,就不构成泄漏。只有当水位持续上升,才需要启动 MAT 级别的深度排查。
注意:Full GC 频率升高并不一定等于泄漏,也可能是分配速率太快。判断顺序应该是:先看 Full GC 后堆占用能否回到之前的水平,再看堆占用随时间是否单调递增。两者都满足,再抓转储。
另外,如果你的 JDK 版本较新,建议在启动参数里加上-Xlog:gc*或者旧版的-XX:+PrintGCDetails -XX:+PrintGCDateStamps,把 GC 日志落盘。别等到出事了才想起来日志没开。我现在经手的服务,凡是核心应用,默认都会加-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log,并配合滚动压缩。
2. 准备堆转储文件:从哪里拿、什么时候拿最准
2.1 两种常见的堆转储获取方式
MAT 分析的对象是堆转储文件(heap dump / hprof),所以问题来了:文件从哪来?我常用的有三种途径,大家可以根据场景选:
- 启动参数自动导出:JVM 进程发生
OutOfMemoryError时,自动把当时的堆快照写到指定路径。推荐在启动参数中加入:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps/这个方案最大的好处是自动、不需要人工介入,适合线上出了 OOM 之后拿到现场的“死亡快照”。但缺点是如果你要分析的是缓慢泄漏,等到 OOM 才抓,可能已经太晚了,进程可能已经被容器编排系统或宿主机重启,文件来不及落盘。
- 手动导出:用
jmap或jcmd主动抓取当前进程的堆快照。比如:
jmap -dump:live,format=b,file=/data/dumps/heap.hprof <pid>这是排查缓慢内存增长的主力方式。注意-dump:live会先触发一次 Full GC,然后再 dump 存活的强引用对象,这样文件小一些,方便传输。但副作用也很明显:因为做完 Full GC,原本可能被 GC Roots 之外弱引用、虚引用持有的对象已经被清理了,你拿到的只是“当前还活着的人”,对于定位强引用泄漏来说够用。有时候为了保留更多现场信息,也可以用-dump:all,format=b,file=...,不带 live,dump 出包括不可达对象在内的完整堆,信息全但文件非常大,分析工具也可能被撑爆。
jcmd动态导出:这是我在 JDK 8+ 环境下更喜欢的命令:
jcmd <pid> GC.heap_dump /data/dumps/heap.hprof这个命令和 jmap 类似,并且不会强制 Full GC(跟具体实现有关),速度也快。平时可以先用jcmd <pid> GC.heap_dump抓,如果文件太大再退回到 jmap 的 live 模式。两者选哪个,取决于你想保留多少“犯罪现场”。
| 获取方式 | 是否强制 Full GC | 文件大小 | 适用场景 |
|---|---|---|---|
| jmap -dump:live | 是 | 较小 | 定位强引用泄漏,快速分析 |
| jmap -dump:all | 否 | 很大 | 需要分析不可达对象,极少用 |
| jcmd GC.heap_dump | 通常不会 | 较大 | 缓慢泄漏现场抓取 |
| -XX:+HeapDumpOnOutOfMemoryError | 发生时自动 | 看现场 | OOM 后的最后现场 |
2.2 什么时候抓才能抓到泄漏现场
有一次我遇到一个服务内存每 3 天涨破阈值,OOM 落地文件总是很大,可每次 MAT 打开后都是满屏的缓存和连接池对象,根本分不清谁是主谋。后来才意识到,是抓堆转储的时机不对。如果泄漏速度很慢,OOM 时整个堆已经被合法的大缓存占满了,你反而看不出增量。
正确做法是:在内存还在稳步增长、离 OOM 还有一段时间的时候,连续抓多份快照。比如观察到老年代从 1.2G 涨到 1.5G 且没有回落,这时在业务高峰期抓第一份;隔半小时或一小时,确认老年代又涨了一些,再抓第二份。两份对比之后,把两份快照里“增量都集中在同一个类/同一颗对象树”的部分拎出来,基本就是泄漏元凶。
具体采集步骤我一般这么走:
jps -l找到目标 Java 进程 PID。- 连续两次执行
jcmd <pid> GC.heap_dump /data/dumps/h1.hprof和/data/dumps/h2.hprof,间隔 30 分钟以上。 - 如果担心转储时间过长影响业务,尽量选择低峰期抓取。
- 从两个 hprof 里分别提取关键类对象数量,在 MAT 的 Histogram 里做对比。
这里有个小经验:抓完之后,一定要记录抓取时刻的业务状态。比如“正在跑批量任务”“刚发过版本”“高峰期”。否则你拿着一个转储文件,不知道当时的业务上下文,很容易把正常的数据加载误判成泄漏。
3. MAT打开转储文件的正确姿势与关键视图
3.1 安装与内存参数调整
MAT 是一个独立桌面工具,官网下载对应平台的压缩包即可,解压即用。很多人忽略的是,MAT 分析大堆文件时自身也需要足够的堆内存,否则它会先给你来一个 OOM,让你哭笑不得。
默认的配置在MemoryAnalyzer.ini里,打开后你会看到类似-Xmx1024m的行,把它调大。我一般按堆转储文件大小的 1.2~1.5 倍来设置分析器内存。比如分析一个 4G 的 hprof,我会给 MAT 分配-Xmx6g;如果是 8G 的转储,就要给到 10G~12G,并且建议放在内存充足的机器上跑。同时可以开启并行 GC:
-vmargs -Xmx6g -XX:+UseParallelGC -XX:+UseParallelOldGC注意路径中不要有中文和空格,否则 MAT 可能起不来。Windows 下如果遇到“Failed to create the Java Virtual Machine”这种报错,基本就是-Xmx给得超过机器内存了,调小即可。
3.2 第一次打开转储:先看Overview而不是直接找Leak Suspects
很多人打开 MAT 后的第一反应是点开 “Leak Suspects”,然后就一头扎进那个所谓的“猜测”报告里。我觉得这个习惯不好。Leak Suspects 确实会帮你自动找嫌疑对象,但它基于启发式规则,误报率不低。尤其是大型项目里,它常常把“对象数量特别多”当成“泄漏”,结果你照着查了半天,发现那是正常的缓存池。
我推荐的路径是:先在 Overview 页面上停留 30 秒,看三块内容:
- Total heap size:确认转储文件里堆总大小,和历史观测值对不对得上。
- Actions:这里有 Histogram、Dominator Tree、Leak Suspects、Top Consumers 等入口,后面按需进入。
- Bottom consumers:底部有几个大对象列表,能快速感知堆里哪些东西吃得最多。
Overview 相当于 x 光片的整体扫描,先建立宏观印象。接着再进入具体视图。
3.3 Histogram与Dominator Tree:两个视图的分工
经常有人问,Histogram 和 Dominator Tree 有什么区别?我用一句话说清:Histogram 是按“类”统计对象数量和大小,回答“哪一类对象最多/最大”;Dominator Tree 是按“对象引用关系”组织,回答“哪个对象持有最多东西”。
- Histogram 入口:
Actions -> Histogram。按对象数量或保留内存排序,能看到类似byte[]占了 2G、java.lang.String占了 800M。这个视图适合快速锁定“大头”。 - Dominator Tree 入口:
Actions -> Dominator Tree。树形的每个节点代表一个对象,节点下挂着它“支配”的子对象。如果一个对象被某个 GC Root 强引用,它的整个子树都无法回收,这就是泄漏分析的主战场。
Histogram 告诉你嫌疑对象的“种类”,Dominator Tree 告诉你“通过谁长成这么大”。两者要配合:先用 Histogram 找出最占内存的类,然后右键选择 “List objects -> with outgoing references”,或者直接在 Dominator Tree 里按 Retained Heap 排序,一路展开到具体实例。
这里必须提醒一个细节:当你在 Histogram 里看到一个byte[]对象特别大时,不要急着怀疑它本身,要把它当作证据线索,右键去找它被谁引用。大 byte[] 往往只是某条引用链底部的受害者,真正的问题对象是持有它的那个业务对象、容器或者缓存实现。
4. 核心排查链路:从Dominator Tree到GC Roots的实战案例
4.1 案例背景:某报表查询模块的内存异常
为了让你能跟着思路走,我构造一个虚拟但非常典型的场景。某内部后台系统有一个“报表查询”模块,业务逻辑是:用户每次查询某个维度报表,服务端把查询条件封装成一个对象,去数据库里查一批结果,并把结果集做二次处理。上线两周后,运维发现老年代占用每天稳定上涨 100M 左右,全天 Full GC 次数从 3 次涨到了 7 次。
第一反应肯定是看 GC 日志,果然 Full GC 后老年代水位一次比一次高。于是我们决定抓堆转储。此时还没到 OOM,老年代大概 1.6G,整个堆 3G。用jcmd抓了一份 hprof,用 MAT 打开。
打开后,Overview 显示Total heap size约 2.8G。进入 Dominator Tree,按 Retained Heap 排序,第一名是一个java.util.HashMap@0x1234abcd,Retained Heap 大小约 1.1G,这明显不正常。
4.2 顺着Dominator Tree展开:HashMap为什么会这么大
点开这个 HashMap 节点,MAT 会把它内部结构可视化。HashMap 本身由一个table数组(Node[])组成,每个有效槽位是一个HashMap$Node。我们在树里展开,发现这个 HashMap 里有 8000 多个 entry,其中大量 value 是byte[],每个大小从几 KB 到几十 KB 不等。往下一层层展开,还能看到这些 byte[] 其实是一份报表导出文件的字节内容。
看到这里,直觉告诉我:这个 HashMap 不是普通的数据临时对象,而是被某个“缓存”持有。为了看清是谁在持有整个 HashMap,右键该节点,选择“Path to GC Roots -> exclude weak references”。
为什么排除弱引用?因为弱引用、软引用都不足以构成“顽固泄漏”,真正的泄漏根因基本都在强引用链上。排除它们可以把干扰降到最低。如果排除后找不到 GC Root,再用 include all references 试试。
最终结果路径大致长这样:
java.util.HashMap@0x1234abcd <- ReportQueryService.instance (static field) <- ReportQueryService.class (Class对象) <- 系统类加载器核心结论已经很清楚了:ReportQueryService是一个单例 Bean,里面定义了一个static Map,用来缓存“查询条件 -> 查询结果”的映射。而查询条件对象是动态拼出来的,每次用户点的按钮不同,key 都不同,于是缓存无限增长,里面的 value 又包含大量报表字节数据,直接把老年代撑爆。
4.3 用Path to GC Roots确定真实持有者
为了确认到底是哪个字段导致的,我在 Dominator Tree 的 HashMap 节点上右键,用“List objects -> with outgoing references”查看了它持有的 key 和 value。结果发现 key 是一个自定义的QueryCondition对象,value 是ReportResult对象,里面包含byte[] data。
然后我在代码里找到了这个 static map 的声明,大概长这样:
public class ReportQueryService { private static final Map<QueryCondition, ReportResult> REPORT_CACHE = new HashMap<>(); public ReportResult query(QueryCondition condition) { ReportResult result = REPORT_CACHE.get(condition); if (result != null) { return result; } result = loadFromDatabase(condition); REPORT_CACHE.put(condition, result); return result; } }问题全部对上号了:QueryCondition没有重写equals/hashCode,每次请求都是新对象,HashMap 永远 get 不到已有 key,只会一直 put。就算有重写,如果条件维度无限多,缓存命中率也会极低,内存照样涨。这类“自以为加了缓存,实际变成内存杀手”的代码,在线下很难发现,线上直接爆给你看。
4.4 修复方案与验证
修复方案不复杂:把 static 缓存去掉,或者改成有界缓存。我当时的处理是直接去掉缓存,因为数据库查询有一层 Redis,根本不需要这台 JVM 内再搞一个不可控的 HashMap 缓存。如果确实需要本地缓存,就用 Caffeine 并设置最大容量和过期策略,不要自己用 HashMap 硬锤。
修复上线后,观察了三天:
- 老年代 Full GC 后水位稳定在 1.0G~1.1G,不再持续上涨;
- Full GC 次数回落到原来的 2~3 次/天;
- MAT 重新抓转储,HashMap 节点消失了,Dominator Tree 里最大的对象来自连接池等正常组件。
这个案例刻意放大了“缓存”问题,因为它在内存泄漏里排名第一。实际项目里,还有线程池任务队列、监听器未注销、ThreadLocal 未清理、动态类加载等常见原因,排查思路完全一致:找到 Retained Heap 最大的对象,沿 Path to GC Roots 看谁牢牢攥着它。
5. 真假泄漏的鉴别:MAT误报与工具本身的坑
5.1 不要看到大对象就喊泄漏
MAT 是一个静态分析工具,它只告诉你“当前堆里哪些对象占内存多”,不会判断这些对象“应不应该存活”。如果一张缓存表里缓存了 1G 的用户信息,但业务上就是要缓存这么多,那它对内存占用就是合理的。只有它持续增长且没有上限时,才能算泄漏。
典型的容易误判对象包括:
- 线程对象:每个线程有自己的栈和 ThreadLocal,线程池里的线程老年代占用高是常态,不要一看到
Thread大就觉得泄漏。要结合线程数量是否异常增加、ThreadLocal 里的值是否被清理来判断。 - 框架缓存:某些框架的本地缓存或元数据缓存,比如 ORM 框架的查询缓存、注册表、监听器列表,天然就是全局强引用。如果大小稳定,没问题;如果随业务无线增长,才需要怀疑。
- Socket/IO 缓冲区:连接未关闭时,底层 buffer 会被 GC Roots 持有。此时真正的问题可能是连接泄漏,而不是对象本身。
我见过有人花了两天查一个“HashMap 泄漏”,最终发现那是某个 SDK 内置的配置中心缓存,容量有上限,满后会淘汰,根本不是泄漏。所以拿到 MAT 结果后,务必回到代码和业务上下文中判断:这个对象是什么生命周期?它应该持续增长吗?它有没有淘汰机制?
5.2 MAT自身内存不足的应对方法
前面提到 MAT 分析大堆文件时容易 OOM。除了调大-Xmx之外,还有两个实用技巧。
第一,用 MAT 的 headless 模式生成报告。如果你在服务器上不方便打开图形界面,可以用命令行方式直接输出 HTML 报告。更常用的是自带的ParseHeapDump工具,在mat/目录下执行:
./ParseHeapDump heap.hprof org.eclipse.mat.api:suspects org.eclipse.mat.api:overview org.eclipse.mat.api:top_components执行完成后会生成多个索引文件和 zip 报告,不需要打开 GUI 就能看到 Leak Suspects 和 Overall 摘要。这个在远程排查和超大转储场景下非常实用。
第二,降低 hprof 体积。有些堆转储动辄十几 G,根本原因是不必要的不可达对象太多。如果不用分析弱引用场景,尽量用jmap -dump:live抓取,文件能小一大半;或者分析前用zip压缩,MAT 支持直接打开 zip 里的 hprof(文件名要保持一致)。
5.3 分析时要结合的其他工具
MAT 可以给你“是谁”的答案,但它不能告诉你“为什么”。要回答“为什么”,通常还需要:
- GC 日志:判断是分配速率问题还是回收效率问题;
- JFR:Java Flight Recorder 能记录对象分配、锁竞争、GC 等事件,和 MAT 互相印证;
- jstat:观察
YGC、FGC、E/O等区域占用趋势。
举个例子:MAT 告诉你byte[]占了 70% 内存,但 GC 日志显示 Young GC 非常频繁,说明应用中可能有大量瞬时对象产生或数组拷贝过于频繁。这时候与其说是泄漏,不如说是“分配风暴”。两者虽然表现都是内存紧张,但解决方向完全不同:泄漏要切断引用链,分配风暴要优化代码或调整对象大小。
6. 从MAT到日常预防:排查之外的几点体会
6.1 几个容易埋雷的代码模式
排查完这个报表缓存案例之后,我养成了在代码审查阶段就盯几个模式的习惯:
- static 集合:静态字段持有 Map/List,且没有容量上限,几乎所有严重泄漏都从这里来。凡见 static new HashMap(),我都会追问:谁来 remove?会不会无限增长?
- 监听器注册/反注册:组件注册了事件监听器,销毁时没有移除,整个组件就被监听器列表间接引用,无法回收。
- ThreadLocal 使用:线程池中的线程复用,ThreadLocal 如果没有在 finally 中 remove,上一个请求的数据会一直留在当前线程里,越积越多。这个用 MAT 也很好查,通常在 Thread 节点下能看到大量 ThreadLocalMap 数据。
- 外部资源未关闭:InputStream、Socket、Channel 等,在没有 try-with-resources 的年代,一直打开会让底层内存缓冲区持续累积。
这些模式有个共同特点:代码单次执行看起来无害,但反复运行后就成了定时炸弹。
6.2 把排查前置到监控和测试环节
线上内存问题越早发现,排查成本越低。现在我做 JVM 调优,已经不满足于“OOM 了再去 MAT”。更有效的姿势是这样的:
- 在监控平台里配置堆使用率、Full GC 次数、Full GC 耗时三个核心指标;老年代使用率一旦连续 3 个时间点没有回落,就触发告警。
- 每次发版前,对核心服务做一次压测,观察压测 30 分钟以上,Full GC 后老年代水位是否回归。
- 准备一个通用脚本,出问题后 5 分钟内能完成 GC 日志和堆转储的自动收集,不要等到人工上手才发现没开 dump 参数。
只要你把HeapDumpOnOutOfMemoryError先开到线上,把 GC 日志打出来,事后分析就成功了一半。否则等事故结束,现场没了,MAT 再强也巧妇难为无米之炊。
这次之后,我把这套流程固定成了自己排查内存问题的标准动作。如果你也遇到老年代稳步上涨、Full GC 后不回落的情况,与其反复重启,不如静下心来抓一份堆转储,用 MAT 把引用关系捋一遍。工具只是工具,真正的功夫在于你能不能辨认那条“不该存在的强引用”。