堆Dump分析实战:如何定位内存泄漏

对应简历段落

简历中关于堆 Dump 和内存泄漏的表述,通常可以写成:

参与核心保险业务系统线上内存泄漏排查,通过堆 Dump、MAT、Arthas heapdump、GC 日志和业务链路分析定位静态缓存、ThreadLocal、导出任务上下文等对象无法释放的问题,完成缓存治理和对象生命周期优化。

面试官看到这段,很容易追问:你怎么抓 Dump?线上抓 Dump 会不会影响服务?MAT 里 Dominator Tree、Histogram、Retained Size、Shallow Size 分别怎么看?怎么从 GC Roots 引用链证明是泄漏?怎么区分内存泄漏和内存峰值?如果 Dump 很大打不开怎么办?

这篇文章的重点是把“会用 MAT”升级为“会用 Dump 证明问题”。资深候选人不能只说“某对象很多”,还要能解释这些对象为什么活着、由谁引用、业务上为什么不该活、怎么改造,以及改造后如何验证。

业务背景

保险业务系统里,内存泄漏很少是“传统 C 语言意义上的忘记释放内存”,更多是对象已经没有业务价值,但仍然被 Java 引用链持有,GC 无法回收。比如运营后台做导出任务时,把每次导出的查询条件、用户信息、结果摘要放进一个静态 Map,只新增不删除;批量核保任务为了复用上下文,把保单明细放进 ThreadLocal,线程池复用后没有 remove;权限系统把用户机构树缓存在本地 ConcurrentHashMap,离职用户、历史渠道、临时权限长期不清理。

这类问题的线上表现通常不是立刻 OOM,而是慢慢变坏。第一天 Full GC 后老年代还能降到 40%,第二天只能降到 55%,第三天降到 70%,到了高峰期再来一个大导出就直接 OOM。应用重启后恢复,运行一段时间又复现。这个特征非常适合用 GC 日志发现趋势,用堆 Dump 找证据。

真实项目里,泄漏排查往往发生在压力很大的场景:服务已经抖动,业务方催促恢复,Dump 文件很大,机器磁盘紧张,线上又不能随便停服务。因此排查前要有清晰流程,先止血,再留证,再分析,再改造。

核心原理

Java 的内存泄漏本质是“无用对象仍然可达”。GC 判断对象是否可回收,不关心业务是否还需要它,只关心从 GC Roots 出发能不能到达它。GC Roots 包括线程栈中的局部变量、静态字段、JNI 引用、正在运行的线程、类加载器等。

堆 Dump 是某一时刻堆内对象的快照。它能告诉我们:有哪些对象、各有多少、占用多少内存、对象之间如何引用、哪些对象因为某条引用链而无法回收。但 Dump 不是时间序列,它只是一张照片,所以最好结合 GC 日志、监控和多个时间点 Dump 一起判断。

分析 Dump 时有几个概念必须清楚。

Shallow Size 是对象自身占用的内存,不包含它引用的其他对象。一个 ArrayList 对象本身很小,但它引用的数组和数组里的业务对象可能很大。

Retained Size 是如果这个对象被回收,连带可以释放的总内存。排查泄漏时,Retained Size 往往比 Shallow Size 更重要,因为它代表这个对象作为“支配者”控制了多少内存。

Histogram 按类统计对象数量和浅堆大小,适合快速看哪些类数量异常。Dominator Tree 按支配关系展示对象保留内存,适合找真正压住大量对象的根。Path to GC Roots 用于追踪对象为什么无法被回收,是证明泄漏的关键。

泄漏判断不能只看对象多。保险系统在高峰期有大量 PolicyDTOCustomerVOString 很正常;关键要看它们是否在请求结束后仍被静态集合、线程、本地缓存、队列、单例 Bean 持有。如果引用链显示 static Map -> ExportTaskContext -> List<PolicyExportVO>,并且 Map 没有过期清理,这才是泄漏证据。

项目落地

线上使用堆 Dump 时,第一原则是保护服务。可以优先选择已经摘流的实例、备用实例、低峰窗口或即将重启的故障实例。如果服务仍在承接核心交易,直接 jmap 可能造成停顿,甚至因为 Dump 文件太大打满磁盘。项目中建议提前配置:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump

这样 OOM 时自动保留现场。主动抓 Dump 可以使用:

jcmd <pid> GC.heap_dump /data/dump/app.hprof

或 Arthas:

heapdump /data/dump/app.hprof

抓 Dump 前要确认磁盘空间、进程 PID、实例是否可摘流、Dump 文件是否涉及敏感数据。保险系统的 Dump 里可能包含客户姓名、证件号、手机号、保单号、银行卡尾号、理赔信息等,必须按生产数据规范管理,不能随意外传。

项目实践中,我会把 Dump 分析分成三个层次。第一层看总览:堆大小、对象总数、最大 Retained Size 对象。第二层看业务类:哪些包名下的 DTO、VO、Entity、Context 数量异常。第三层看引用链:这些对象被谁持有,为什么没有释放。

例如某次老年代持续上涨,MAT 的 Dominator Tree 显示一个 ConcurrentHashMap retained size 很大。继续展开发现 key 是用户 ID,value 是 PermissionContext,其中包含机构树、菜单列表和渠道权限。Path to GC Roots 显示它被 PermissionLocalCache 的静态字段持有。业务上这个缓存原本用于减少权限查询,但没有最大容量和过期策略,用户切换机构后旧权限也没有清理。最后改为 Caffeine,设置最大容量、TTL、刷新策略和监控指标,泄漏趋势消失。

排查流程

第一步,判断是否值得抓 Dump。如果只是短时间流量峰值,GC 后老年代能回落,不一定马上抓。若 Full GC 后老年代低水位持续抬升,或者 OOM 前没有明显释放,就应该抓 Dump。最好在老年代较高但服务还没崩溃时抓一次,OOM 后自动 Dump 再抓一次,两个快照对比更有价值。

第二步,保留上下文。记录实例、时间、堆使用率、GC 次数、业务操作、发布版本、导出任务、定时任务。没有上下文的 Dump 很难解释,因为对象多不等于泄漏。

第三步,用 Histogram 找异常类。先看 byte[]char[]String、集合类、业务 DTO、Excel 类、JSON 类数量是否异常。比如大量 XSSFCellSXSSFRow 指向导出问题;大量 PolicyExportVO 指向导出结果持有;大量 HashMap$Node 指向 Map 膨胀。

第四步,用 Dominator Tree 找支配者。不要只盯着数量最多的类,而要看谁 retained size 最大。泄漏经常不是业务对象自己作为根,而是某个缓存、队列、线程、本地上下文持有了大量业务对象。

第五步,追踪 Path to GC Roots。找到一个典型对象,排除弱引用、软引用等干扰,查看强引用链。面试中可以这样描述:“我不是看到对象多就下结论,而是从 retained size 最大的集合展开,选取其中的业务对象查看到 GC Roots 的强引用链,确认它被某个静态缓存持有,并且缓存没有过期清理。”

第六步,回到代码验证。Dump 只是证据,最终还要看代码。找到类和字段后,检查 put/remove 是否成对、是否有 TTL、是否有最大容量、是否线程安全、异常路径是否释放、ThreadLocal 是否在 finally 中 remove。

第七步,修复并验证。修复后通过压测、灰度和线上监控看 Full GC 后老年代低水位是否稳定,缓存大小是否可控,接口 RT 是否恢复,是否还有新的大对象支配者。

常见坑

第一个坑是把 String 多当成根因。很多 Dump 里 Stringbyte[]char[] 都排在前面,因为业务对象内部大量使用字符串。真正要找的是谁持有这些字符串。只说“字符串太多”没有意义。

第二个坑是混淆 Shallow Size 和 Retained Size。一个静态 Map 自身浅堆不大,但 retained size 可能巨大。排查泄漏时要优先关注 retained size。

第三个坑是忽略线程栈引用。有些对象不是静态缓存泄漏,而是某个线程正在执行大任务,局部变量持有了大 List。此时 Dump 里能看到线程栈到对象的引用链。任务结束后对象可能释放,这不一定是泄漏,而是峰值内存占用。

第四个坑是在线上随便抓 Dump。Dump 可能导致 STW、磁盘暴涨、敏感数据落盘。资深回答里要体现风险意识:摘流、低峰、磁盘检查、敏感数据管理、自动 OOM Dump。

第五个坑是只修代码不加观测。缓存泄漏修了以后,应该把缓存容量、命中率、淘汰数、对象数量、老年代低水位纳入监控,否则下次还会靠故障发现问题。

面试追问

面试官可能会问:MAT 里 Dominator Tree 和 Histogram 有什么区别?Retained Size 为什么重要?怎么找 GC Roots?ThreadLocal 泄漏在 Dump 里怎么看?如果 Dump 文件太大打不开怎么办?线上抓 Dump 会不会让服务卡住?OOM 自动 Dump 和手动 Dump 分别适合什么场景?怎么证明修复有效?

还可能问得更细:软引用缓存会不会泄漏?WeakHashMap 一定安全吗?Caffeine 为什么比手写 Map 更适合本地缓存?队列堆积算不算内存泄漏?如果对象被线程栈引用,是泄漏还是任务没执行完?

回答时要围绕“可达但无用”这个核心判断,不要把所有大对象都叫泄漏。

推荐回答

可以这样回答:

我定位内存泄漏一般先看 GC 日志和监控,如果 Full GC 后老年代低水位持续抬升,说明有对象长期存活。然后在故障实例或摘流实例上抓堆 Dump,用 MAT 先看 Histogram 判断异常对象类型,再看 Dominator Tree 找 retained size 最大的支配对象,最后通过 Path to GC Roots 看强引用链。只有确认对象已经没有业务价值、但仍被静态缓存、ThreadLocal、队列或单例持有,我才会判断为泄漏。

有一次我们发现老年代每天都比前一天高,Dump 里一个权限本地缓存 retained size 很大,里面保存了大量用户机构树和菜单权限。引用链显示它被静态 ConcurrentHashMap 持有,代码里只有 put 没有过期和容量限制。后续改成 Caffeine,设置 TTL、最大容量和淘汰监控,同时在权限变更时主动失效。灰度后 Full GC 后老年代低水位稳定下来,缓存大小也维持在预期范围内。

这个回答同时覆盖了工具、证据、代码根因和效果验证,比单纯说“我用 MAT 找到了泄漏对象”更像真实项目经历。

延伸学习路线

第一阶段,学习 Java GC Roots、可达性分析、对象引用类型、ThreadLocalMap 结构、类加载器引用关系。理解为什么 Java 也会泄漏。

第二阶段,熟悉 MAT。重点练习 Histogram、Dominator Tree、Path to GC Roots、Leak Suspects、OQL。不要只点报告,要能手动展开引用链。

第三阶段,准备几类典型案例:静态 Map 泄漏、ThreadLocal 泄漏、无界队列堆积、监听器未注销、本地缓存无 TTL、大 Excel 导出峰值。每类都写出 Dump 特征和修复方案。

第四阶段,学习生产操作规范。包括 OOM 自动 Dump、主动 Dump 风险、磁盘空间检查、敏感数据处理、摘流和灰度、Dump 文件归档与删除。

第五阶段,把 Dump 分析和监控治理结合起来。真正成熟的内存治理,不是每次靠 Dump 救火,而是通过缓存指标、队列指标、导出任务指标、GC 低水位趋势提前发现问题。