ThreadLocal 内存泄漏与线程池坑
更新时间:2026-09-18。ThreadLocal 是把「线程私有变量」存进线程自身的机制,在用户上下文、TraceId、SimpleDateFormat 复用里大量使用,但在线程池场景有两类经典事故。线程栈与排查见 jstack 实战,线程池参数见线程池七大参数。
一、数据结构与泄漏根因
每个 Thread 对象内部持有一个 ThreadLocalMap,key 是 ThreadLocal 实例(WeakReference 弱引用),value 是你 set 的对象(强引用)。
Thread ──持──> ThreadLocalMap ──> Entry[]
└ key(WeakRef) ──> ThreadLocal 实例(可能已被 GC)
└ value(强引用) ──> 你的对象- ThreadLocal 实例没有其它强引用时,GC 会回收 key,Entry 变成 key=null、value 仍强引用。
- 在线程池里,工作线程长期存活甚至与应用同寿,这个 value 一直被「线程 → map → entry → value」强引用链占着,无法回收 → 内存泄漏。线程不结束,value 不释放。
- 虽然 map 在后续 get/set 时会顺带清理部分 stale entry,但不能依赖它,必须显式清理。
二、正确用法:try/finally remove
static final ThreadLocal<UserCtx> CTX = new ThreadLocal<>();
void handle(Request req) {
try {
CTX.set(auth(req)); // 入口设置
business(); // 下游任意层 CTX.get()
} finally {
CTX.remove(); // 出口必须清理,哪怕异常
}
}remove() 同时摘掉 entry,彻底解除 value 强引用。用 static final 持有 ThreadLocal 实例本身也可避免 key 被回收,但不能替代 remove(线程复用串号问题仍在)。
三、线程池第二大坑:数据串号
线程复用意味着:上一个任务 set 的上下文若没 remove,下一个复用到同一线程的任务 get() 到的是上一个任务的残留值。典型事故:
- 用户身份串号:A 请求把 UserId 放 ThreadLocal,处理完没清,B 请求复用线程读到 A 的身份 → 越权/数据错乱。
- TraceId/SpanId 串接:链路 ID 张冠李戴,监控全乱。
- 日期工具(
SimpleDateFormat非线程安全)线程私有是正面用法,但同样要保证是「每线程独立实例」而非共享。
这与分布式锁/上下文无关,纯粹是线程复用导致的内存可见性残留。
四、父子线程与线程池透传
| 方案 | 能否传子线程 | 能否传线程池 | 说明 |
|---|---|---|---|
ThreadLocal | 否 | 否 | 仅当前线程 |
InheritableThreadLocal | 是(new Thread 时拷一份) | 否 | 只在创建子线程那一刻拷贝,线程池线程是复用的,提交任务不会重新拷贝 |
TransmittableThreadLocal(阿里 TTL) | 是 | 是 | 在任务提交(Runnable 包装)时捕获、执行时回放、执行完还原,配合 TtlExecutors 装饰线程池 |
TransmittableThreadLocal<String> traceId = new TransmittableThreadLocal<>();
ExecutorService pool = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(8));
// 父线程 set 后提交任务,任务内能拿到正确 traceId,且不会污染下一个任务异步场景透传上下文的同类问题也存在于 async/事件循环 的上下文传播。
五、如何排查
- 内存方向:堆 dump 后看 ThreadLocalMap、线程实例数量与 retained size(jmap 堆转储)。
- 串号方向:在 get 处打印当前线程名 + 值,或 Arthas
watch/stack(Arthas 三件套)确认复用线程与残留来源。 - 新代码也可关注 JDK 较新的作用域值能力(ScopedValue),它绑定在一次「执行作用域」内、退出即失效,天然无泄漏与串号,可作为长期演进方向。
六、常见坑
- 只 set 不 remove:线程池下既泄漏又串号,最致命。
- 以为弱引用 key 就不泄漏:泄漏的是被强引用的 value 和整条链,不是 key。
- 用 InheritableThreadLocal 想透传线程池:只在新建线程时拷贝,复用线程拿不到新值。
- ThreadLocal 存大对象/集合:放大 retained heap,应只存必要上下文。
- 多处嵌套同名上下文未隔离:remove 会清掉外层值时要用可重入/保存恢复,或确保作用域清晰。
- Tomcat/框架线程上设值:请求线程也是池化的,同样必须在请求结束清理。
总结
ThreadLocal 把数据存在每个 Thread 自己的 ThreadLocalMap 里,其 Entry 的 key 是弱引用而 value 是强引用,在线程池工作线程长期存活时,一旦 ThreadLocal 实例被回收就会留下 key 为 null、value 仍被“线程→map→entry”强引用的泄漏项,因此标准写法是在入口 set、finally 里 remove;更隐蔽的事故是线程复用导致上一个任务残留的用户身份、TraceId 被下一个任务读到而串号越权,remove 同样是唯一可靠解;跨线程透传时 InheritableThreadLocal 只在 new Thread 那一刻拷贝、对池化复用线程无效,需要用阿里 TransmittableThreadLocal 配合 TtlExecutors 在任务提交时捕获、执行时回放;排查串号可打印线程名或用 Arthas,排查泄漏用堆 dump 看 ThreadLocalMap 的 retained size,长期可关注随作用域自动失效的 ScopedValue。