JVM 堆转储分析实战——用 MAT 找出藏在业务代码里的内存泄漏
JVM 堆转储分析实战——用 MAT 找出藏在业务代码里的内存泄漏
适读人群:遇到过 OOM 或内存持续增长问题的 Java 工程师 | 阅读时长:约18分钟 | 核心价值:掌握用 Eclipse MAT 分析堆转储文件的完整方法论,能独立定位业务代码里的内存泄漏根因
一、那次让我彻夜排查的内存泄漏
2021年,我接到一个棘手的任务:某个数据处理服务,每运行 6 小时,内存就从 2G 涨到 6G,触发 OOM 重启,重启后又开始涨,周而复始。
初步观察:JVM 堆在持续增长,Full GC 之后也只能回收到 5.5G,不能回到初始的 2G 水平。说明堆里有大量对象无法被 GC,有内存泄漏。
我的判断是:需要做 Heap Dump,用 MAT(Memory Analyzer Tool)分析对象分布和引用链,找出是谁持有了大量不该存在的对象。
这次排查花了将近一整天,最终找到了根因:一个 EventListener 在注册时没有对应的注销,导致对象引用链越来越长,大量业务对象无法被 GC 回收。
这篇文章我把这次排查的完整过程拆解出来,教你用 MAT 做堆分析。
二、获取 Heap Dump
在分析之前,首先要获取堆转储文件(.hprof)。
方法1:OOM 时自动 dump(推荐生产环境)
# JVM 启动参数
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heap-dump/这样 OOM 发生时自动生成 heap.hprof,不需要人工干预,也不会错过现场。
方法2:手动 dump(用于定期分析或问题复现时)
# jmap 方式(会触发一次 Full GC,STW)
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# Arthas 方式(推荐,对生产环境友好)
heapdump --live /tmp/heap-arthas.hprof
# jcmd 方式
jcmd <pid> GC.heap_dump /tmp/heap-jcmd.hprof注意:-dump:live 参数表示只 dump 存活对象(先触发一次 Full GC),dump 出的文件更小,分析更准确。不加 live 会包含 GC 还没回收的垃圾对象,文件更大,干扰信息更多。
三、MAT 基础操作
Eclipse MAT 下载地址在 Eclipse 官网,是一个独立的 IDE,专门用于分析 hprof 文件。
3.1 打开文件
将 hprof 文件拖入 MAT,等待解析(大文件可能需要几分钟)。解析完成后 MAT 会自动打开 Overview 页面,显示堆大小、对象总数等基本信息。
3.2 Leak Suspects Report(泄漏嫌疑报告)
这是 MAT 最智能的功能。在 Overview 页面点击 "Leak Suspects",MAT 会自动分析哪些对象占用内存异常多,并给出嫌疑报告,通常直接就能告诉你问题在哪里。
对于我的案例,Leak Suspects 报告显示:
Problem Suspect 1:
One instance of "com.example.EventBus" loaded by "sun.misc.Launcher$AppClassLoader"
occupies 3,456,789 (57.23%) bytes.
The memory is accumulated in one instance of
"java.util.concurrent.CopyOnWriteArrayList",
loaded by "sun.misc.Launcher$AppClassLoader".这就锁定了 EventBus 类的 CopyOnWriteArrayList 是大头。
3.3 Dominator Tree(支配树)
Dominator Tree 展示按 Retained Heap(保留堆大小)排序的对象,"保留堆"指的是如果这个对象被 GC,能释放多少内存。
Retained Heap 最大的对象,通常就是内存泄漏的根因或者直接原因。
3.4 OQL(Object Query Language)查询
MAT 支持类似 SQL 的对象查询,可以精确过滤和分析特定类型的对象:
-- 查找所有 EventListener 实例,按 Retained Heap 排序
SELECT * FROM com.example.EventListener ORDER BY retainedHeapSize() DESC
-- 查找 size > 1000 的 ArrayList
SELECT * FROM java.util.ArrayList a WHERE a.size > 1000
-- 查找所有持有某类对象引用的对象
SELECT * FROM INSTANCEOF com.example.UserCache四、引用链分析——找到谁"拿着"不放
找到嫌疑对象之后,需要分析为什么它没有被 GC 回收——即找到从 GC Roots 到这个对象的引用链。
在 MAT 里,右键点击目标对象 → "Path to GC Roots" → "with all references",就能看到完整的引用路径:
GC Root: Thread "main"
└── Thread.locals
└── RequestContextHolder.inheritableRequestAttributesHolder
└── AttributeMap (key=...)
└── ServiceContext (com.example.ServiceContext)
└── listeners (CopyOnWriteArrayList)
└── [大量 OrderEventListener 实例]这条引用链说明:ServiceContext 里的 listeners 列表持有大量 OrderEventListener 实例,而 ServiceContext 被 ThreadLocal 持有,所以只要线程存活,这些 listener 就不会被 GC。
结合代码排查,发现了根因:每次 HTTP 请求进来,都会 new OrderEventListener() 并注册到 EventBus,但请求结束时没有注销,listener 越积越多。
五、完整的代码级排查和修复
找到根因之后,修复很简单:在请求结束时注销监听器。
有泄漏的代码:
// 这段代码有内存泄漏——每次请求都注册,没有注销
@Service
public class OrderService {
private final EventBus eventBus;
public void processOrder(OrderRequest request) {
// 每次调用都创建并注册一个新的 listener,泄漏!
OrderEventListener listener = new OrderEventListener(request.getOrderId());
eventBus.register(listener); // CopyOnWriteArrayList 无限增长
try {
// 处理订单逻辑
doProcess(request);
} finally {
// 这里忘记写 eventBus.unregister(listener); ← 根因
}
}
}修复后的代码:
package com.example.service;
import com.example.event.EventBus;
import com.example.event.OrderEventListener;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final EventBus eventBus;
public OrderService(EventBus eventBus) {
this.eventBus = eventBus;
}
/**
* 修复内存泄漏:使用 try-with-resources 或 try-finally,
* 确保 listener 在方法退出时(无论正常还是异常)都被注销。
*/
public void processOrder(OrderRequest request) {
OrderEventListener listener = new OrderEventListener(request.getOrderId());
eventBus.register(listener);
try {
doProcess(request);
} finally {
// 保证一定执行,不再泄漏
eventBus.unregister(listener);
}
}
private void doProcess(OrderRequest request) {
// 实际业务逻辑
}
public static class OrderRequest {
private Long orderId;
public Long getOrderId() { return orderId; }
}
}六、踩坑实录
坑1:dump 文件太大,MAT 内存不足
现象:尝试打开 12G 的 hprof 文件,MAT 报 OutOfMemoryError,无法加载。
原因:MAT 自身也是 Java 程序,默认只给自己 1G 内存,装不下 12G 的 dump 文件。
解法:修改 MAT 安装目录下的 MemoryAnalyzer.ini,增大 MAT 自身内存:
-Xms4g
-Xmx16g另外,如果 dump 文件是整个堆(不含 -live),可以先用 jmap -histo:live 做初步分析,只 dump 真正需要的。这个坑我也踩过,当时服务器内存 32G,MAT 默认 1G 直接炸了。
坑2:看了 Dominator Tree 但找不到业务代码
现象:Dominator Tree 里 Top 10 全是 byte[]、char[]、HashMap$Node 等 JDK 内置类,看不到业务类。
原因:这些基础类型是很多业务对象的"叶子节点",Retained Heap 大是因为它们是 byte 数组(字符串内容),真正的根因是持有这些数组的上层业务对象。
解法:用 Group by Class 视图,按类型聚合,找到 Instance 数量异常多的业务类(比如 OrderDO 有 100 万个实例,正常应该只有几千个),再从这个类出发找引用链。
坑3:引用链分析发现是框架对象持有,但不知道如何修
现象:引用链追溯到 Spring 的 DefaultListableBeanFactory,怀疑是框架 bug。
原因:Spring 的 BeanFactory 里确实会持有所有单例 Bean,这是正常现象。但如果发现某个 Bean 里的 Map 持有了大量对象,问题通常是这个 Bean 的代码逻辑,而不是 Spring 框架本身。
解法:继续展开 BeanFactory 里的具体 Bean,找到具体哪个 Bean 里的哪个字段在持有大量对象。通常是 Map 或 List 类型的实例变量没有做淘汰清理。
七、内存泄漏的高频场景总结
根据我的排查经验,Java 应用内存泄漏的高频原因:
- 静态 Map/List 无限增长:静态变量生命周期等于 JVM,存入的对象永不释放
- 监听器/回调注册不注销:本文案例的场景
- ThreadLocal 使用不当:线程池里的 ThreadLocal 不清理,对象随线程长期存活
- 不合适的对象缓存:缓存没有容量限制,一直增长
- 内部类持有外部类引用:内部类实例的生命周期比外部类长,导致外部类无法被 GC
每种场景都能用 MAT 的引用链分析找到,关键是要能看懂引用链,理解每一层"是谁持有了谁"。
