记一次线上 OOM 排查:原来是 ThreadLocal 在搞鬼
记一次线上 OOM 排查:原来是 ThreadLocal 在搞鬼
在日常的后端开发中,OOM(Out Of Memory)绝对是让所有 Java 程序员心头一紧的词汇。就在上周,我们的核心业务系统在晚高峰期间突然爆发了大量的 OOM 告警,服务器相继宕机,业务受到了不小的影响。经过几个小时的紧急排查,最终发现罪魁祸首竟然是我们平时非常熟悉的 ThreadLocal。
这篇文章将详细复盘这次线上 OOM 的排查过程、原因分析以及最终的解决方案,希望能为大家提供一些借鉴。
一、 事故现场:突如其来的报警风暴
上周四晚上 8 点左右,正是流量高峰期。监控大盘上,某核心服务的接口响应时间突然从平时的 50ms 飙升到了 2000ms 以上,紧接着,运维群里的报警机器人开始疯狂刷屏,提示该服务的多个节点出现 java.lang.OutOfMemoryError: Java heap space。
我们立刻登录监控平台查看各项指标,发现几个明显的异常现象:
- CPU 飙升:部分节点的 CPU 使用率直接打满到了 100%。
- 内存曲线呈阶梯状上升:JVM 堆内存使用量在过去几个小时内持续上涨,没有下降趋势。
- 频繁 Full GC:GC 日志显示,JVM 正在疯狂进行 Full GC,但每次回收后释放的内存寥寥无几,最终由于无法分配新的对象而抛出 OOM 异常。
为了尽快恢复业务,运维同学果断对出问题的节点进行了重启,并从负载均衡中摘除。幸运的是,在重启之前,我们通过启动参数 -XX:+HeapDumpOnOutOfMemoryError 成功保留了当时的 Heap Dump 内存快照文件(hprof),这为后续的排查留下了最关键的证据。
二、 抽丝剥茧:分析 Heap Dump 快照
拿到几十个 GB 的 Heap Dump 文件后,我们将它下载到本地,并使用 MAT (Memory Analyzer Tool) 工具打开进行深入分析。
MAT 工具加载完毕后,我们直接点开了 Leak Suspects(泄漏疑点)报告。报告中赫然显示,有一个类的实例占据了超过 70% 的堆内存空间,这个类就是 java.lang.Thread!
顺着 Dominator Tree(支配树)继续往下看,我们发现导致内存占用的对象引用链非常集中,路径大概是这样的:java.lang.Thread -> java.lang.ThreadLocal$ThreadLocalMap -> java.lang.ThreadLocal$ThreadLocalMap$Entry[] -> UserContextInfo
看到这里,我心里已经大概有数了。原来是 ThreadLocalMap 里面塞满了我们自定义的 UserContextInfo 对象。这个 UserContextInfo 包含了用户的详细权限、基本信息、大报文等,是一个相当“重”的对象。
但是,为什么这些对象会一直存留在 ThreadLocal 中不被回收呢?
三、 根因分析:被忽视的 remove()
顺着这个线索,我们立刻去检查代码库中关于 UserContextInfo 的使用场景。
我们系统中使用了一个拦截器(Interceptor)来拦截所有的用户请求,从 Token 中解析出用户信息,并放入 ThreadLocal 中,方便在后续的业务 Service 层直接获取当前操作人信息,避免层层传递参数。
代码大致如下:
public class UserInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("Authorization");
UserContextInfo userInfo = parseToken(token);
// 将用户信息放入 ThreadLocal
UserContextHolder.set(userInfo);
return true;
}
}乍一看,这段代码似乎没什么问题,也是大家非常常用的上下文透传方案。但致命的漏洞在于:缺少了清理操作。
大家都知道,Web 服务器(比如 Tomcat、Undertow)处理请求时,使用的是线程池。也就是说,处理完当前请求的线程并不会被销毁,而是会被回收到线程池中,等待处理下一个请求。
ThreadLocal 的底层实现是每个 Thread 都有一个 ThreadLocalMap,这个 Map 的 Key 是 ThreadLocal 实例(弱引用),Value 是我们存入的对象(强引用)。
当我们调用 UserContextHolder.set(userInfo) 时,UserContextInfo 对象就被强引用到了当前的工作线程上。
由于开发者忘记在请求结束时调用 remove(),当这个线程处理完请求被放回线程池后,它身上的 ThreadLocalMap 依然保留着上一个用户的 UserContextInfo。
- 这不仅会导致极大的内存泄漏:随着时间推移,线程池里的每一个线程都挂载了大量不会被回收的对象。
- 还会引发严重的业务串数据漏洞:如果下一个请求没有覆盖当前 ThreadLocal 的值,就会读取到上一个用户的信息!
在晚高峰期间,大量的独立用户发起请求,线程池被充分打满,不断有新的 UserContextInfo 被塞进线程内部,而旧的却得不到释放。堆内存被迅速吃光,最终引发了惨烈的 OOM。
四、 紧急修复:规范 ThreadLocal 的生命周期
找到原因后,修复方案其实非常简单:只需要在拦截器的 afterCompletion 方法中,或者在 Filter 的 finally 代码块中,显式调用 ThreadLocal 的 remove() 方法即可。
修改后的代码如下:
public class UserInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("Authorization");
UserContextInfo userInfo = parseToken(token);
UserContextHolder.set(userInfo);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 请求处理完毕后,务必清理 ThreadLocal,防止内存泄漏
UserContextHolder.remove();
}
}将修复后的代码紧急打包上线,滚动重启了所有节点。随后我们紧盯监控大盘,发现内存使用率恢复了平稳的锯齿状波动(正常的 GC 表现),再也没有出现持续攀升的情况,这场 OOM 风波终于平息。
五、 总结与反思
这次线上事故给了我们一个深刻的教训。ThreadLocal 虽然是一个非常强大且便捷的并发工具,但如果使用不当,极易成为系统的“定时炸弹”。
回顾整个排查过程,总结出以下几点最佳实践,与大家共勉:
- 必须显式清理:使用
ThreadLocal时,有 set 必有 remove。尤其是在使用线程池的场景下,务必在finally块或生命周期结束的回调中清理上下文,不要依赖 JVM 的自动回收。 - 谨慎存放超大对象:尽量不要在
ThreadLocal中存放过于庞大的对象或集合,哪怕有清理机制,在并发极高时也会对新生代造成巨大的内存压力。 - 完善的监控体系:平时要多关注 JVM 的内存使用率曲线和 GC 频率。如果发现老年代内存持续缓慢增长且 Full GC 无法回收,大概率就是发生了内存泄漏,需要提前预警,不要等到 OOM 宕机了才去救火。
- 保留事故现场:生产环境务必加上
-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath参数。在 OOM 发生时,这份 Dump 文件就是排查问题的“黑匣子”。
代码千万条,规范第一条。希望这次“血淋淋”的教训,能让大家在以后编写 ThreadLocal 代码时,条件反射般地写下 remove()。