线程池复用 ThreadLocal 把订单串号了:一次内存泄漏与数据串味的双重事故

📅 2026/8/10 13:28:12
线程池复用 ThreadLocal 把订单串号了:一次内存泄漏与数据串味的双重事故
title: 线程池复用 ThreadLocal 把订单串号了一次内存泄漏与数据串味的双重事故tags: Java,ThreadLocal,内存泄漏,线程池,源码category: Java两个事故一个根因ThreadLocal 的坑我踩过两次而且两次症状完全不一样根因却是同一个用了 ThreadLocal 没清理又跑在会复用线程的线程池里。第一次是内存问题。一个接收文件上传的服务每次请求把解析出来的byte[]单个文件 200MB 左右塞进ThreadLocal供后续几个方法读取。服务用 Tomcat 线程池默认 200 线程长期运行某公司上传活动把线程池跑满后老年代在一个小时内涨到 90%Full GC 从每几小时一次变成每分钟两三次接口 RT 跟着抖动。dump 出来一看200 个 Tomcat 线程的threadLocals各自挂着 200MB 的byte[]光这些就占了 40GB。第二次比内存更吓人——数据串味。一个订单服务的TraceContext用ThreadLocal存当前用户 ID请求处理完没清理。Tomcat 线程被复用时下一个请求的ThreadLocal.get()取到的还是上一个用户的 ID。后果是A 用户的请求在日志、甚至某次缓存 key 里混入了 B 用户的用户 ID。形式是偶发,一天几十笔,排查了两天才定位到线程复用。这两次事故让我彻底明白ThreadLocal 不是用完自动没的局部变量它绑的是线程而线程池的线程是长生不老的。最小的串号复现public class OrderContext { private static final ThreadLocalLong USER_ID new ThreadLocal(); public static void setUser(Long uid) { USER_ID.set(uid); } public static Long getUser() { return USER_ID.get(); } // 注意这里没有 remove() } // 在线程池里模拟两个请求复用同一线程 ExecutorService pool Executors.newFixedThreadPool(1); Runnable reqA () - { OrderContext.setUser(1001L); log.info(request A sees user {}, OrderContext.getUser()); // 1001 }; Runnable reqB () - { // 没调用 setUser直接 get log.info(request B sees user {}, OrderContext.getUser()); // 期望 null实际 1001 }; pool.submit(reqA); pool.submit(reqB);reqB期望看到null新请求没设置用户实际看到1001。因为reqA和reqB跑在同一个线程上reqA设置的ThreadLocal值没有被清掉被reqB继承。这就是数据串味的最小模型。生产环境里它不会这么规整地复现而是随线程调度随机出现所以特别难查。ThreadLocal 的内部结构为什么泄漏甩不掉很多人以为ThreadLocal是个 Mapkey 是线程。看一眼 JDK 8 源码就知道反了。真正的结构是// Thread 类里 ThreadLocal.ThreadLocalMap threadLocals null; // ThreadLocalMap 是 ThreadLocal 的静态内部类 static class ThreadLocalMap { static class Entry extends WeakReferenceThreadLocal? { Object value; // 强引用 Entry(ThreadLocal? k, Object v) { super(k); // key 是弱引用WeakReference value v; // value 是强引用 } } private Entry[] table; }这里有两层关键设计Map 存在Thread上不在ThreadLocal上。一个线程可以挂多个 ThreadLocal 变量都装进它自己的threadLocals这个 Map 里。所以线程死了Map 才跟着死。线程池的线程不死Map 就一直在。我们的内存事故就是这么来的——200MB 的byte[]作为value被线程的 Map 强引用线程不死它就永远不回收。key 是弱引用value 是强引用。这是个容易误解的点。看Entry的构造super(k)把 key那个ThreadLocal对象本身包成WeakReference而value是普通强引用。// ThreadLocal.set 写入时 public void set(T value) { Thread t Thread.currentThread(); ThreadLocalMap map getMap(t); if (map ! null) map.set(this, value); else createMap(t, value); } // ThreadLocalMap.set private void set(ThreadLocal? key, Object value) { Entry[] tab table; int len tab.length; int i key.threadLocalHashCode (len-1); for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { if (e.refersTo(key)) { // JDK 9 写法等价于 e.get() key e.value value; return; } if (e null) { tab[i] new Entry(key, value); return; } // 线性探查处理哈希冲突 } }我们逐行看一下为什么弱引用 key救不了内存泄漏业务代码里ThreadLocalbyte[] local new ThreadLocal();创建了一个 ThreadLocal 对象。调用local.set(bigArray)把bigArray作为value存入当前线程的 MapEntry 的 key 弱引用指向localvalue 强引用指向bigArray。方法结束局部变量local在栈帧里消失了ThreadLocal对象只被 Entry 的弱引用持有。下一次 GC这个ThreadLocal对象因为没有强引用了被回收。于是 Entry 的 key 变成null但value那 200MBbyte[]还是被 Entry 强引用着——key 没了value 还在。因为线程不死这个 Map 永生那个nullkey 对应的value永远不会被访问到也永远不会被回收。这就是内存泄漏的精确机理。注意JDK 在set/get/remove时确实会顺手清理 key 为null的 EntryexpungeStaleEntry但前提是你要调用这三个方法之一。如果某个 ThreadLocal 你设了之后永远不再碰它我们的文件上传服务正是这样设了之后靠后续方法get偶尔触发清理但byte[]太大、触发频率不够那它的 value 就会顽固地赖在 Map 里。正确的清理姿势try-finally removepublic class SafeOrderContext { private static final ThreadLocalLong USER_ID new ThreadLocal(); public static Long withUser(Long uid, SupplierLong action) { USER_ID.set(uid); try { return action.get(); // 业务在回调里跑 } finally { USER_ID.remove(); // ★ 无论如何都清理 } } } // 使用把整段请求处理包进去 Long result SafeOrderContext.withUser(uid, () - { return orderService.create(req); });remove()的源码并不复杂public void remove() { ThreadLocalMap m getMap(Thread.currentThread()); if (m ! null) m.remove(this); // 直接把整条 Entry 从 table 里拿掉 } // ThreadLocalMap.remove private void remove(ThreadLocal? key) { Entry[] tab table; int len tab.length; int i key.threadLocalHashCode (len-1); for (Entry e tab[i]; e ! null; e tab[i nextIndex(i, len)]) { if (e.refersTo(key)) { e.clear(); // 清掉 key 的弱引用 expungeStaleEntry(i); // 顺手把这条 Entry 也抹掉value 断开强引用 return; } } }expungeStaleEntry会把 Entry 的value置null并挪动后续冲突的槽位。这一下value 的强引用被断开200MBbyte[]立刻在下一轮 GC 里就能被回收。把set/remove配对写在try/finally里是 ThreadLocal 的入门铁律但也是最容易在 code review 里被放过的点。如果是线程池 必须透传上下文用 InheritableThreadLocal 是另一个坑很多团队为了解决父子线程传值用InheritableThreadLocal。但如果你在 Web 线程池里用它同样会泄漏而且更隐蔽——因为子线程只在创建时从父线程拷贝一次值线程池里的子线程是预先创建好复用的父线程后面设的新值它根本收不到private static final InheritableThreadLocalLong UID new InheritableThreadLocal(); ExecutorService pool Executors.newFixedThreadPool(2); UID.set(1L); pool.submit(() - log.info(pool thread sees {}, UID.get())); // 1拷贝时值 UID.set(2L); // 改了父线程的值 pool.submit(() - log.info(pool thread sees {}, UID.get())); // 还是 1不是 2真实项目里我们最终切到了阿里开源的TransmittableThreadLocalTTL2.12.1 版本它的TtlRunnable会在任务提交时把父线程的值快照下来、执行前重新 set、执行后再恢复解决了线程池透传和清理两个痛点。但即便用 TTL用完也要remove它只是帮你正确透传不帮你自动清理。横向对比几种请求级上下文方案方案线程池复用安全跨线程/异步透传清理责任适用ThreadLocal 手动 remove不安全漏 remove 即泄漏/串味否开发者同步、单线程内InheritableThreadLocal不安全仅创建时拷贝一次开发者一次性 fork 子线程TransmittableThreadLocal安全是任务级快照开发者仍需 remove线程池 异步方法参数显式传递最安全是无调用链短MDCSLF4Jlog 场景需配合 TTL 才安全否默认框架/手动日志链路表格里清理责任这列值得多看一眼除方法参数显式传递外所有 ThreadLocal 系方案都需要开发者自己负责清理。框架不会替你 remove。这是 ThreadLocal 被滥用的最根本原因——它太像局部变量了用起来却没有局部变量的生命周期。复盘数字内存事故老年代 1 小时从 30% 涨到 90%Full GC 频率从 ~每 5 小时一次升到每分钟 1.8 次接口 P99 从 80ms 抖到 1.2s。dump 分析显示 ThreadLocal 持有 200MB×200 线程 ≈ 40GB 的byte[]。串号事故日均订单 120 万串号约 40-70 笔/天概率级约 0.005%连续两天才被人工对账发现涉及越权访问风险触发一次安全复盘。修复成本两处各约 10 行try/finally/remove但串号问题定位耗时 2 天对账复核又花 3 天。我的取舍判断ThreadLocal 我只用在短生命周期、确定会配对清理的地方典型是工具方法里临时透传一个非线程安全的对象比如SimpleDateFormat——它是线程不安全的老代码常见用 ThreadLocal 包一个避免重复创建。这种场景生命周期极短、调用即清理风险可控。跨请求、跨方法、需要长期存在于请求上下文里的数据用户身份、traceId、租户 ID我倾向于显式当方法参数传或者用一个明确命名为XxxContext、强制try/finally remove的封装类。我们后来把TraceContext的remove收敛进一个框架拦截器的finally块里SpringHandlerInterceptor.afterCompletion业务代码碰不到ThreadLocal也就不可能漏写 remove。把容易忘记的事交给框架做比依赖每个人的自觉可靠得多。我不建议在业务代码里自己new ThreadLocal()然后散落在十几个方法里 set/get。这类用法一旦哪个分支提前 return 没走到 remove就是隐患。要么用参数传要么用封装 拦截器兜底。留个思考题假设你在 Tomcat 线程里用ThreadLocalbyte[]存大对象但从不remove而你把这段代码挪进了CompletableFuture.supplyAsync(task, executor)——executor是一个自定义固定线程池。这时大对象绑在谁的threadLocals上如果executor是ForkJoinPool.commonPool()泄漏还在吗欢迎在评论区分享你被 ThreadLocal 坑过的现场。