资讯动态

MAT实战:从堆转储到GC Roots揪出Java内存泄漏元凶

发布时间:2026/10/18 21:56:28 来源:尧图企业网站定制
前一阵排查一个内部系统的内存问题老年代每次 Full GC 之后不但没降下来反而稳定上浮照这个趋势深夜必然 OOM。当时手头能用的工具不多最终是 MATEclipse 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,formatb,file/data/dumps/heap.hprof pid这是排查缓慢内存增长的主力方式。注意-dump:live会先触发一次 Full GC然后再 dump 存活的强引用对象这样文件小一些方便传输。但副作用也很明显因为做完 Full GC原本可能被 GC Roots 之外弱引用、虚引用持有的对象已经被清理了你拿到的只是“当前还活着的人”对于定位强引用泄漏来说够用。有时候为了保留更多现场信息也可以用-dump:all,formatb,file...不带 livedump 出包括不可达对象在内的完整堆信息全但文件非常大分析工具也可能被撑爆。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.HashMap0x1234abcdRetained 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.HashMap0x1234abcd - 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 MapQueryCondition, 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 日志判断是分配速率问题还是回收效率问题JFRJava 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 把引用关系捋一遍。工具只是工具真正的功夫在于你能不能辨认那条“不该存在的强引用”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑