Skip to content

内存泄漏排查实战:jmap + MAT 从入门到精通

线上服务 OOM 了,手头只有 jmap 和 MAT,怎么查?

面试官问这个问题,不是想听你背 jmap -dump 的参数,而是想确认:你有没有真的拿着堆转储文件找过泄漏点,知道每一步该干什么,以及生产环境哪些操作是坑


一、堆转储:获取快照

基础命令

bash
# 方式一:jmap(JDK 8 及以下最常用)
jmap -dump:live,format=b,file=heap.hprof <pid>

# 方式二:jcmd(JDK 11+ 推荐)
jcmd <pid> GC.heap_dump heap.hprof

# 方式三:自动兜底(启动参数里加上)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof

-dump:live 参数会先触发 Full GC,只 dump 存活对象。好处是文件小,坏处是大堆(>8GB)Full GC 会 STW 几十秒,生产环境慎重。

生产环境不背锅的技巧

大堆机器(16GB+)用 jmap -dump:live 等于把服务挂起半分钟。替代方案:

bash
# 方案 A:gcore + jmap(不触发 GC,但文件大,会产生 core dump 文件)
gcore <pid>                    # 生成 core dump,跟堆大小基本一致
jmap -dump:format=b core.dump  # 从 core 转 heap dump

# 方案 B:Arthas 的 heapdump(支持 --live 和 --skip-live 切换)
heapdump --skip-live /tmp/heap.hprof

# 方案 C:async-profiler 采样(不 dump 全堆,只看分配热点)
profiler.sh -e alloc -d 30 -f alloc.html <pid>

踩坑记录:某次 32GB 堆的线上机器,凌晨用 jmap -dump:live,Full GC 导致接口超时雪崩,报警电话打爆。之后改成 gcore + 离线转 dump,虽然文件大了一倍,但服务零影响。

四种方案对比

方案触发 Full GC生成速度(16GB 堆)文件大小影响
jmap -dump:live30-60s2-4GB(仅存活对象)STW,高风险
jmap -dump(不加 live)10-20s16GB(全堆)短暂暂停,风险低
gcore + jmap5-10s dump 本身16GB + 额外 core 文件几乎无影响
Arthas heapdump --skip-live10-20s16GB短暂暂停,风险低

ZGC 下堆转储的特殊性

JDK 17+ 开启 ZGC 后,jmap -dump:live 会强制触发 Full GC 并退化为单线程 GC,耗时比 Parallel GC 更长(32GB 堆可能 2-3 分钟)。因为 ZGC 的并发标记在 dump 期间需要暂停来保证一致性。

推荐做法:ZGC 环境下用 jcmd <pid> GC.heap_dump -all(不加 live),不触发 Full GC,只暂停 Java 线程做 snapshot,几秒内完成。


二、MAT 分析:三步找出泄漏点

拿到 heap.hprof 后,用 Eclipse Memory Analyzer(MAT)打开。

第一步:看 Leak Suspects Report

打开后直接点 Leak Suspects,MAT 会自动给出最可能的泄漏线索。报告会告诉你:

  • 哪个对象占用了最多内存
  • 到 GC Roots 的引用链是什么
  • 这个对象是确凿泄漏还是业务缓存的大对象

原理:MAT 的 Leak Suspects 用的是 Dominator Tree + GC Root 引用链 的统计。它会遍历所有存活对象,对每个 GC Root 路径上的对象做分组累加,挑出 retained heap 最大的几个候选。这一步本质是贪心算法——先捡最大的西瓜,所以 90% 的泄漏问题能在这一步找到。

第二步:看 Histogram 做 Top-Down 分析

如果 Leak Suspects 结果不明确,切到 Histogram

1. 按 retained heap 降序排序
2. 找到占用最大的类(通常是 byte[], HashMap, ThreadLocal, Object[])
3. 右键 → Merge Shortest Paths to GC Roots → exclude weak/soft references
4. 看引用链,锚定业务代码

弱引用和软引用是 GC 可回收的,排查泄漏时一定要排除它们,否则你看到的是 ThreadLocal 弱引用 → ThreadLocalMap,误以为泄漏。

MAT 的支配树算法:MAT 使用 JGraphT 库实现支配树计算。对每个对象 O,它找到所有能到达 O 的 GC Root 路径,取这些路径的公共祖先作为 O 的支配者(dominator)。直观理解:如果回收对象 A 必然导致对象 B 被回收,那 A 就是 B 的支配者。Dominator Tree 就是把这种"回收谁释放谁"的关系画成树,支配树顶部的对象就是你要找的泄漏点

第三步:OQL 精准查询

MAT 的 OQL(Object Query Language)类似 SQL,适合精准过滤:

sql
-- 找所有 HashMap 实例
SELECT * FROM INSTANCEOF java.util.HashMap

-- 找所有 char[] 超过 100KB 的
SELECT * FROM char[] WHERE length > 100000

-- 找某个类的所有实例,看 toString
SELECT toString(t) FROM INSTANCEOF com.example.cache.CacheEntry t

-- 找所有 ThreadLocalMap 实例,看 size 是否异常
SELECT t.table.length, t.thread FROM INSTANCEOF java.lang.ThreadLocal$ThreadLocalMap t

-- 进阶:找所有存活 ServletRequest 实例(排查请求泄漏)
SELECT * FROM INSTANCEOF javax.servlet.ServletRequest

-- 找所有 Finalizer 队列中的对象(finalize 拖慢 GC)
SELECT * FROM INSTANCEOF java.lang.ref.Finalizer

配合 Inspector 面板 看对象字段值,确认是数据堆积(业务正常,只是量大)还是确凿泄漏(本该被释放的对象被一路引用链持有)。

第四步:Dominator Tree 看"谁拖累最大"

Dominator Tree(支配树)展示的是:回收这个对象,能释放多少内存。看支配树比 Histogram 更直接——它告诉你一个对象"支配"了哪些子对象。比如一个 HashMap 在支配树里占了 2GB,说明它引用的所有 Entry 和 key/value 都算在它头上,回收它就能释放 2GB。

典型面试追问:Histogram 和 Dominator Tree 有什么区别?

视图维度用途
Histogram按类分组,统计每个类的实例数/占用找哪个类的实例最多
Dominator Tree按对象引用树,展示支配关系找到"谁是根因"——回收哪个对象能释放最大空间

实战经验:先看 Dominator Tree 的顶层,往下钻到业务包名,定位到具体类名和方法,再切回 OQL 查实例数,整个流程 5 分钟。


三、实战:五种常见泄漏模式

模式 1:ThreadLocal 未 remove()

java
// 泄漏代码
private static final ThreadLocal<Context> ctxHolder = new ThreadLocal<>();

public void process(Request req) {
    ctxHolder.set(buildContext(req));  // set 了,但没 remove
    doSomething();
    // 忘了 ctxHolder.remove();
}

现象:用户线程池(Tomcat 线程池)中的线程复用,每个请求 set 的 context 对象一直在线程的 ThreadLocalMap 里,永远不会被回收,直到线程死亡。

为什么是 ThreadLocalMap 而不是 ThreadLocal 泄漏? ThreadLocal 实例本身是弱引用,get() 返回 null 后,Entry 会被清理。但 value 是强引用,只要线程活着,ThreadLocalMap.Entry 的 value 就不会被回收。所以泄漏的是 value 对象,不是 ThreadLocal 本身。

MAT 确认:Histogram 里看到 ThreadLocalMap$Entry 数量远大于活跃线程数,且 value 是业务对象。

面试追问:为什么 ThreadLocal 用弱引用?换成强引用会怎样?

  • 弱引用:ThreadLocal 对象不再被外部引用时,GC 能回收它,Entry 的 key 变成 null,后续 get/set 会清理这些 stale entry
  • 强引用:ThreadLocal 对象永远不会被回收,即使业务代码不再需要它,Entry 一直存在,等于永久泄漏

生产案例:某电商的订单处理服务,用 ThreadLocal 存储请求追踪 ID,上线一个月后频繁 Full GC。dump 后发现 ThreadLocalMap$Entry 数量是 Tomcat 最大线程数(200)的 15 倍——因为业务代码在 catch 分支里没有 remove,异常发生时 context 一直留在线程里。排查链路:Histogram → 找到 ThreadLocalMap$Entry 2000+ 个 → 看 value 类型是 TraceContext → OQL 查 TraceContext 的实例数 → 确认泄漏。

模式 2:集合类无限增长

java
// 泄漏代码
public class CacheManager {
    private static final Map<String, Object> cache = new HashMap<>();
    
    public void put(String key, Object value) {
        cache.put(key, value);  // 只增不减
    }
}

现象:内存持续增长,Full GC 越来越频繁,最终 OOM。

MAT 确认:Histogram 找到 HashMap 实例,看 size 和 Entry 数。右键 → List Objects → with outgoing references 看 key 列表,确认都是有效数据还是过期数据堆积。

修复方案

  • 加上淘汰策略:LinkedHashMap + removeEldestEntry() 实现 LRU
  • 或用 Caffeine(内存占用可控,支持最大容量/过期时间)
  • 极限场景:WeakHashMap 配合外部 key 引用,但只适用于 key 有外部引用的场景

生产案例:某 IM 服务的消息推送模块,用 ConcurrentHashMap 缓存未推送的消息,key 是消息 ID。早期量小没事,上线半年后夜间 OOM。dump 后发现 Map 里有 300 万条消息,最早的 3 个月前。根因:推送失败的消息没有重试策略,也没有过期清理,失败后 key 一直留在 Map 里。修复:Caffeine.newBuilder().maximumSize(50000).expireAfterWrite(10, TimeUnit.MINUTES).build(),且推送失败写回 DB,不在内存里兜底。

模式 3:Listener / Observer 未取消注册

java
// 泄漏代码
@Component
public class MyListener implements ApplicationListener<SomeEvent> {
    @Autowired
    private EventBus eventBus;
    
    @PostConstruct
    public void init() {
        eventBus.register(this);  // 注册了
        // 没有对应的 @PreDestroy unregister
    }
}

现象:Spring Bean 被销毁后(热部署、重加载),EventBus 里还保留着引用,导致 Bean 无法被 GC。

MAT 确认:在 Dominator Tree 中搜索 MyListener,看引用链——通常会看到 EventBussubscribers 字段持有它。

根因:GC Roots 之一就是 EventBus 实例(通常也是单例/Spring Bean),它通过 subscribers 列表引用着所有已注册的 Listener。即使原 Bean 被销毁,EventBus 这条引用链也让它到不了 GC。

模式 4:ClassLoader 泄漏(热部署场景)

现象:Tomcat 热部署几次后,PermGen / Metaspace 持续增长。每次重启 Web 应用都会创建新的 ClassLoader,旧的类加载器因为静态变量或 ThreadLocal 的引用无法被回收。

MAT 确认:使用 jmap -clstats <pid> 查看类加载器统计。Dominator Tree 中搜索 WebappClassLoaderParallelWebappClassLoader,看引用链。

典型元凶

  • 第三方库的静态 ThreadLocal 持有 ClassLoader 引用
  • 日志框架(Log4j/Logback)的 Appender 持有 ClassLoader
  • 自定义注解处理器缓存的 Class 引用

预防-verbose:class 看类加载/卸载日志,确认 [Unloading class ...] 是否出现。

排查脚本:快速确认 ClassLoader 泄漏

bash
# 看当前 Metaspace 使用量
jstat -gcmetacapacity <pid> 1000 5

# 看 ClassLoader 统计
jmap -clstats <pid> | grep -E "class_loader|alive|number"

# 对比:热部署前后各跑一次,看 Metaspace 是否增长

模式 5:堆外内存泄漏(DirectByteBuffer)

现象top 看到 RES 持续增长,但 jmap -histo 显示堆内内存正常。最终 OS 层面 OOM 或 OutOfMemoryError: Direct buffer memory

MAT 确认:MAT 不能直接看堆外内存,需要通过 pmap -x <pid> 看地址空间,或用 NMT

bash
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary

典型场景:Netty 的 ByteBuf 频繁申请 DirectByteBuffer,但 release 不彻底。ByteBuf.refCnt() 大于 0 一直不释放,最终堆外内存耗尽。

排查流程

1. top 看 RES 远超 -Xmx
2. NMT 确认 Internal 或 DirectBuffer 增长
3. pmap -x <pid> 看哪些地址段在增长
4. 用 gperftools 或 jemalloc 的 profiler 跟踪分配堆栈

生产案例:某网关服务,-Xmx 4GB 但 RES 跑到 12GB,系统 OOM Killer 杀进程。NMT 显示 Internal 分配 6GB 且持续增长。pmap 发现大量 64KB 的匿名映射段,确认是 DirectByteBuffer 碎片。根因:Netty 的 PooledByteBufAllocator 没有正确 release,导致内存泄漏到堆外。修复:检查所有 ByteBuf 使用路径,确保 try-finally 保证 release。


四、生产案例:一次完整的 OOM 排查

背景:某支付网关服务,8GB 堆,4 个节点,凌晨 3 点突然 OOM。

排查过程

  1. 自动 dump:启动参数加了 -XX:+HeapDumpOnOutOfMemoryError,OOM 时自动生成 /data/logs/java_pid1234.hprof(约 3.2GB)

  2. MAT 打开:Leak Suspects 报告显示一个 HashMap 实例占用 2.8GB,持有 150 万个 PaymentRecord 对象

  3. 引用链HashMapPaymentGateway.cachePaymentRecord,所有记录都是前 24 小时内的交易数据

  4. 根因:活动期间(晚 8 点到凌晨 2 点)交易量暴增,缓存中的 PaymentRecord 只增不减。设计是"缓存最近 24 小时",但没有淘汰机制,凌晨 3 点高峰持续,内存撑爆

  5. 修复:改为 Caffeine.newBuilder().maximumSize(100000).expireAfterWrite(6, TimeUnit.HOURS).build(),加上兜底:-XX:SoftRefLRUPolicyMSPerMB=0

数据:修复后排除了该 OOM,缓存命中率从 92% 降到了 88%(因为增加了淘汰),但接口 P99 延迟从 120ms 降到 95ms(因为减少了 Full GC 频率)。


五、预防:上线前就做好的三件事

  1. 启动参数加自动 dump-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/,OOM 时自动生成 hprof,不用追着运维手动执行 jmap。注意:磁盘空间要够,堆 8GB 则 dump 文件至少预留 8GB 空间,否则 OOM 瞬间写文件失败,白忙一场。

  2. 定期做基线堆转储:流量低峰期(如凌晨)做一次 jmap -dump:live,和 OOM 时的 dump 对比,看哪些对象异常增长。

  3. Arthas 在线监控

    bash
    # 监控方法调用次数
    monitor -c 5 com.example.service.UserService getUser
    
    # 看最忙的线程
    thread -n 3
    
    # 实时看 HashMap 的 size(不 dump 全堆)
    vmtool --action getInstances --className java.util.HashMap --express 'size()'
  4. JDK 21+ Flight Recorder 持续监控(无侵入,生产可用):

    bash
    # 启动时就开启 JFR,持续 24 小时
    -XX:StartFlightRecording=name=leak_detect,disk=true,dumponexit=true,maxsize=1GB,settings=profile
    
    # 运行时手动触发
    jcmd <pid> JFR.start name=leak_check duration=30m filename=/tmp/leak_check.jfr
    
    # 看 JFR 中的 GC 详情
    jcmd <pid> JFR.dump name=leak_check filename=/tmp/leak_dump.jfr

    JFR 的 Old Object Sample 事件可以在不 dump 全堆的情况下,每周采样一次大对象引链,适合长期监控。


六、排查流程速查表

步骤工具产出耗时常用命令
获取堆转储jmap / jcmd / Arthasheap.hprof30s-2minjmap -dump:live,format=b,file=heap.hprof <pid>
快速定位嫌疑Leak Suspects Report最可能的泄漏点1min点按钮
逐层确认Histogram + Dominator Tree引用链 + 支配树3min右键 → Merge Shortest Paths
精准验证OQL + Inspector字段值 + 实例数2minSELECT * FROM INSTANCEOF xxx
根因修复业务代码补上 remove() / 加淘汰策略 / 取消注册取决于代码-

面试一句话总结:内存泄漏排查的本质是找到一条不该存在的 GC Root 引用链。先 Dominator Tree 找头,再 Histogram 看分布,最后 OQL 定案。工具链熟,5 分钟出结论。


七、面试追问自检清单

面试题核心考点关键回答
jmap -dump:live 和 -dump 的区别?是否触发 Full GC + 生产经验前者触发 Full GC 只存活对象,大堆慎用;ZGC 下会退化为单线程 GC
ThreadLocal 为什么用弱引用?引用的四种类型 + 泄漏场景防 ThreadLocal 本身泄漏,但 value 仍可能泄漏
Dominator Tree 怎么算的?图算法基础遍历所有 GC Root 到对象的路,找公共祖先(支配者)
OOM 自动 dump 会不会二次 OOM?实际情况会,所以文件路径要配到有足够空间的分区,且打开文件句柄也要够用
堆外内存泄漏怎么查?NMT + pmap 工具链NMT summary 看趋势,pmap 看地址段,gperftools 看分配堆栈
ZGC 下怎么排查内存泄漏?新 GC 的排查差异不用 -dump:live,用 jcmd GC.heap_dump -all,配合 JFR Old Object Sample
WeakHashMap 能完全替代 Caffeine 吗?弱引用适用边界不能,WeakHashMap 只有 key 被外部引用时才能回收,不适合高频读写场景

内存泄漏排查靠的是工具链 + 经验——工具帮你缩小范围,经验告诉你该往哪看。多拿线上 dump 练手,下次 OOM 你就不会慌。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。