从 OOM 到 Root Cause:一次生产环境 JVM 内存泄漏排查全纪实
在 Java 程序的生命周期中,内存泄漏(Memory Leak)像是一个隐形的“慢性病”。它最初可能只是让你的服务响应稍微变慢,但随着运行时间的推移,频繁的 FullGC 会导致 Stop-The-World (STW) 时间变长,最终引发 java.lang.OutOfMemoryError: Java heap space,导致整个应用崩溃。
上周,我们核心链路的一个微服务在生产环境遭遇了严重的内存泄漏。本文将还原整个排查过程,分享如何利用 JVM 堆分析工具精准定位代码级的问题。
一、 案发现场:监控告警与初步观察
下午 2:00,Prometheus 触发告警:某核心计算模块节点内存占用率超过 90%,且 GC 频率异常。
通过 Grafana 面板观察到以下特征:
- 老年代(Old Gen)持续攀升:即便触发了 FullGC,回收掉的内存也微乎其微。
- 锯齿状波形消失:正常的内存曲线应该是“上升-回落”的锯齿状,而现在的曲线几乎是一条平缓上升的斜线。
- CPU 飙升:由于 JVM 试图频繁进行 GC 来释放空间,导致 CPU 资源被垃圾回收线程耗尽。
初步结论:存在典型的内存泄漏,对象被某些长期存在的引用(GCRoot)持有,无法被回收。
二、 排查工具箱:工欲善其事,必先利其器
在生产环境,我们通常不能直接挂载图形化工具,因此采取了“先抓取快照,后离线分析”的策略:
- jstat -gcutil [pid] 1000:实时观察各区内存占用和 GC 次数。
- jmap -histo [pid]:快速查看内存中对象的数量和占用大小(初筛)。
- jmap -dump:format=b,file=heap.hprof [pid]:导出完整的堆快照文件。
- MAT (Eclipse Memory Analyzer):进行深度的离线堆转储文件分析。
三、 故障排查实录
1. 抓取堆快照
在将该节点摘除流量后,迅速执行 dump 命令:
jmap -dump:live,format=b,file=leak_case.hprof 12455
注意:加上 live 参数会先触发一次 FullGC,只保留存活对象,减小文件体积并聚焦核心问题。
2. 使用 MAT 进行“大手术”
将几 GB 的 hprof 文件下载到本地,导入 MAT。
第一步:查看 Leak Suspects(泄漏猜想报告)
MAT 自动生成的报告中显示:ThreadLocal 相关的对象占用了接近 70% 的堆内存。
第二步:分析 Dominator Tree(支配树)
切换到 Dominator Tree 视图,按 Retained Heap(保留堆) 大小进行倒序排序。我们发现一个名为 java.lang.Thread 的对象集合中,每个线程的 threadLocals 映射表里都存放了大量的 CustomRequestContext 对象。
第三步:查找 GCRoot 路径
右键点击该对象 -> Path To GC Roots -> exclude all phantom/weak/soft etc. references。
链路非常清晰:Thread -> ThreadLocalMap -> Entry -> value -> CustomRequestContext -> byte[] buffer。
3. 定位罪魁祸首
通过代码检索 CustomRequestContext。我们发现为了在链路中传递用户信息,开发人员在拦截器(Interceptor)中使用了 ThreadLocal:
public class UserContextHolder {
private static final ThreadLocal<CustomRequestContext> holder = new ThreadLocal<>();
public static void set(CustomRequestContext context) {
holder.set(context);
}
// 问题出在这里:没有提供 remove 方法,或者在业务结束后没有调用
}
病因分析:
该应用使用了线程池(Tomcat/Executor)。线程池中的线程是复用的。当一个请求处理完毕后,如果没有手动调用 ThreadLocal.remove(),该线程持有的 CustomRequestContext 对象将一直保留。随着处理的请求越来越多,每个线程的 ThreadLocalMap 都塞满了未清理的对象,最终撑爆内存。
四、 解决方案与验证
修复手段:
利用 AOP 或 Interceptor 的 afterCompletion 回调,强制执行清理逻辑:
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContextHolder.clear(); // 内部调用 ThreadLocal.remove()
}
验证结果:
上线后,内存曲线恢复了健康的锯齿状,FullGC 次数回归到每日个位数。
五、 总结与建议
排查 JVM 内存泄漏,我的三板斧逻辑是:
- 看趋势:通过监控判断是内存溢出(瞬时大对象)还是内存泄漏(长期积累)。
- 拿现场:
jmap导出堆快照是分析的核心。 - 找 GCRoot:内存泄漏的本质是“不该活的对象还活着”,通过 MAT 的支配树和引用链,顺藤摸瓜找到那个本该断开的引用。
避坑指南:
- 谨防静态集合:
static HashMap往往是内存泄漏的高发区。 - ThreadLocal 必须 remove:在使用线程池的环境下,这是必须遵守的铁律。
- 资源关闭:数据库连接、IO 流、Netty 的 ByteBuf 必须有对应的释放逻辑。