ThreadLocal 不是银弹:一次内存泄漏把老年代打满的复盘

发布时间:2026/8/3 6:16:50
ThreadLocal 不是银弹:一次内存泄漏把老年代打满的复盘 引子为什么人人都用 ThreadLocalWeb 开发里ThreadLocal几乎无处不在Spring 的RequestContextHolder、MyBatis 的PageHelper、日志链路追踪的 traceId底层都靠它把只属于当前请求的数据绑在线程上免去一层层传参。它本身不难一行set、一行get但一旦用在线程池环境坑就来了。我们线上一次老年代被打满、Full GC 每 5 秒一次的事故根因就是ThreadLocal没清理。问题线程池 ThreadLocal 泄漏温床关键事实Tomcat、各种 RPC 框架处理请求用的都是线程池线程是复用的不会被销毁。而ThreadLocal的值存在线程自己的ThreadLocalMap里Thread不死这个 Map 就不释放。如果你在请求处理里set了一个大对象处理完没remove那么这个对象会一直挂在线程上被下一个请求、下下个请求反复复用和累积直到老年代撑爆。我们当时的代码长这样private static final ThreadLocalUserContext ctx new ThreadLocal(); public void handle(Request req) { ctx.set(loadUserContext(req)); // 1. set 了大对象 try { bizService.process(); // 2. 业务逻辑内部各处 get(ctx) } finally { // 3. 这里忘了 remove —— 事故起点 } }逐行看问题第 2 行set把UserContext里面塞了我们系统的用户权限树平均 200KB挂到当前线程。第 6 行finally里我们只写了日志没写remove。在线程池下这个线程处理完返回池子200KB 的UserContext跟着线程一起存活。线程池 200 个线程每个都残留一份就是 40MB而且因为对象一直被强引用永远到不了回收Full GC 也清不掉——这正是我们老年代暴涨的直接原因。源码/原理为什么泄漏以及为什么 JDK 已经尽力了ThreadLocal的存储不是存在ThreadLocal对象里而是存在Thread.threadLocals这个ThreadLocalMap里key 是ThreadLocal本身弱引用value 是你 set 的对象强引用。// ThreadLocalMap 的 Entry 定义简化 static class Entry extends WeakReferenceThreadLocal? { Object value; // 1. value 是强引用 Entry(ThreadLocal? k, Object v) { super(k); // 2. key 是弱引用指向 ThreadLocal value v; } }逐行解释这个设计的微妙之处第 2 行 key 用弱引用意思是当你代码里的ThreadLocal变量比如上面的ctx静态字段因为类卸载或置 null 而失去强引用时下一次 GC 会把 key 清掉。这是 JDK 为了减少泄漏做的努力。但第 1 行的value是强引用。key 被 GC 清成 null 后value仍然挂在Entry上而Entry挂在ThreadLocalMap上ThreadLocalMap挂在Thread上。Thread不死这条强引用链就断不了——value 泄漏了。所以弱引用只解决了key 那一半的问题value 这边的泄漏 JDK 帮不了你只能靠你自己remove。set/get时 JDK 会顺手做一次过期 entry 清理expungeStaleEntry但这只是擦边球——它只在你再次访问同一个 ThreadLocal 且恰好遇到 stale slot时才清理不能替代显式remove。实战事故当晚我们怎么定位和止血定位过程其实很简单粗暴用jmap -histo:live pid抓堆排第一的是一个UserContext类实例数 200和线程池大小吻合。再jstack一看这些UserContext的引用链都指向Thread.threadLocals。根因锁定没remove。止血用三步public void handle(Request req) { ctx.set(loadUserContext(req)); try { bizService.process(); } catch (Exception e) { log.error(process failed, e); throw e; } finally { ctx.remove(); // 1. 唯一的正解用完即清 } }第 8 行ctx.remove()会在ThreadLocalMap里删除这条 entry断开 value 的强引用下次 GC 就能回收。这是我们当晚加的补丁。但这里还有个细节如果bizService.process()抛异常且外层没捕获会不会跳过finally不会——finally无论如何都会执行所以把remove放finally是对的。真正的风险是有人把remove放在try的正常分支里而忘了异常分支那样异常时还是漏。第二步我们顺手把静态ThreadLocal改成每次请求用完后必然 remove并对所有ThreadLocal使用点做了一次全局 grep发现还有两处PageHelper风格的手动分页没清理一并修了。第三步加监控老年代使用率超过 80% 就告警避免下次再等到 Full GC 风暴才发现。对比几种请求级上下文方案的取舍方案是否泄漏风险适用静态 ThreadLocal 手动 remove忘了 remove 就泄漏简单请求上下文InheritableThreadLocal线程池下值串号父子线程传参慎用TransmittableThreadLocal (阿里)需引入依赖解决线程池传递线程池 上下文传递方法参数显式传递无泄漏但代码啰嗦调用链短的场景我的判断纯请求上下文、且你能保证finally里remove用原生ThreadLocal足够。但只要你的任务会被线程池跨线程传递比如把上下文塞进异步任务丢给别的线程池跑原生ThreadLocal直接失效——值传不过去这时候得上阿里的TransmittableThreadLocal。总结与我的取舍ThreadLocal的价值在于隐式传参但代价是隐式持有。它在线程池时代最大的敌人就是遗忘的remove。我现在给自己定的铁律任何ThreadLocal.set必须配对try/finally { remove() }宁可多写两行也不留泄漏口。我不建议在业务代码里大量自建ThreadLocal存大对象——大对象一旦泄漏危害远超传参那点便利。能显式传参的尽量显式传非用不可的把存的东西压到最小并确保remove。复盘数据那次事故的具体数字把当晚的监控数字摊开更有说服力出问题的服务是 4 核 8G 容器Tomcat 默认线程池 200 线程每个请求的UserContext平均 200KB里面是用户权限树 菜单树。泄漏后老年代在约 20 分钟内从 1.2GB 涨到 7.5GBFull GC 从几乎不发生变成每 5 秒一次单次停顿 600ms~1.2s接口 P99 从 80ms 飙到 3s 以上。加完remove并重启后老年代稳定在 1.5GB 上下Full GC 归零P99 回到 90ms。事后我们算了一笔账200 线程 × 200KB 40MB 常驻看着不多但它进的是老年代且永不被回收叠加每次请求新建的临时对象老年代上涨速度远超回收速度。这也是为什么jmap -histo:live一眼就能看出——UserContext实例数正好等于线程池大小是个很明显的指纹。补充一个版本相关的点ThreadLocal从 Java 1.2 就有InheritableThreadLocal同期但当任务要跨线程池传递上下文时原生方案会失效我们后来在异步链路里换成了阿里的TransmittableThreadLocal当时用的 2.12.1 版本它通过在任务提交时快照上下文、执行前回填来解决线程池传递问题。如果你也在做 traceId 透传这个依赖值得记一笔。还有一类比忘了写 remove更隐蔽的坑提前 return 漏清理。我们 review 时发现有一处if (whiteList) return;提前返回恰好跳过了后面的remove。这类问题静态扫不出来最好的办法是把set/remove收口到模板里让调用方改不了// 用模板方法把 set/remove 锁死调用方无法漏写 remove public static T, R R with(ThreadLocalT holder, T ctx, FunctionT, R fn) { holder.set(ctx); try { return fn.apply(ctx); // 1. 业务逻辑任意 return/throw 都走 finally } finally { holder.remove(); // 2. 清理固化在模板里调用方改不了 } }这段封装的关键在第 2 行remove被固化在finally中业务方在里面怎么return、throw都会执行从结构上消灭了提前返回漏清理。我们后来把所有ThreadLocal入口都收口到这类模板泄漏类故障基本绝迹。这也顺带回答了前面那个疑问只要对象还被Thread上的ThreadLocalMap强引用着哪怕只有 10MBYoung GC 也回收不了它因为它根本不在年轻代的新建链路里而是挂在久经不衰的线程上。思考题InheritableThreadLocal在线程池下为什么会出现值串号A 请求的 traceId 出现在 B 请求的日志里如果你用原生ThreadLocal存了一个 10MB 的缓存对象且忘了remove它会被 Young GC 回收吗欢迎讨论。