先看 ThreadLocal 的存储结构
ThreadLocal 本身不存数据,数据存在每个 Thread 对象里的一个 ThreadLocalMap 字段上。ThreadLocalMap 是 ThreadLocal 的内部类,结构类似 HashMap,key 是 ThreadLocal 实例的弱引用,value 是你放进去的对象。
scss
Thread
└── ThreadLocalMap
├── Entry(WeakRef<ThreadLocal1>, value1)
├── Entry(WeakRef<ThreadLocal2>, value2)
└── ...
这里有个关键设计:key 是弱引用,value 是强引用。
泄漏发生的条件
泄漏需要同时满足两件事:
条件一:ThreadLocal 实例本身不再被强引用。
csharp
// 这个 ThreadLocal 定义为局部变量,方法返回后就没有强引用了
public void doSomething() {
ThreadLocal<BigObject> tl = new ThreadLocal<>();
tl.set(new BigObject());
// ... 方法结束,tl 这个局部变量消失,ThreadLocal 实例只剩 ThreadLocalMap 里的弱引用
}
static 字段持有的 ThreadLocal 不会出现这种情况,因为 static 字段的强引用一直在。
条件二:这个线程没有结束,还在被复用。
这就是为什么泄漏主要发生在线程池里。线程池里的线程不会结束,Thread 对象一直存活,它里面的 ThreadLocalMap 也一直存活。
把两个条件合在一起看:ThreadLocal 实例被 GC 了,对应的 Entry 的 key 变成 null,但 Entry 本身还挂在 ThreadLocalMap 里,value 里的对象被 Entry 强引用着,GC 回收不了。线程活多久,这个对象就占多久内存。
如果线程池有 200 个线程,每次处理请求都往 ThreadLocal 里放了一个 200KB 的对象,处理完没有 remove,ThreadLocal 实例也被 GC 了,那 ThreadLocalMap 里就会慢慢积累一批 key 为 null 的 Entry,每个 Entry 的 value 都挂着 200KB 的数据,不会被回收。
为什么 WeakReference 没有解决这个问题
弱引用只解决了 key 的问题:ThreadLocal 实例本身可以被 GC 回收,不会因为 ThreadLocalMap 里的引用而活着。
但 value 还是强引用。key 被 GC 后,value 就成了孤儿------没有任何途径从外部访问到它,也没有任何东西会主动清理它,只有等这个线程的 ThreadLocalMap 被整体回收(即线程结束)。
ThreadLocal 自己做了一些防御:get()、set()、remove() 的时候,会顺带清理 key 为 null 的 Entry(expungeStaleEntry)。但这是被动清理,且不是每次都触发,依赖概率,不能依赖它来防泄漏。
正确的用法
用完显式调用 remove(),放在 finally 块里确保执行:
csharp
private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();
public void handleRequest(Request request) {
try {
CONTEXT.set(new RequestContext(request));
doWork();
} finally {
CONTEXT.remove(); // 必须在 finally 里,保证异常时也执行
}
}
Web 框架里常见的 MDC(Mapped Diagnostic Context)也要记得清理:
vbscript
// 在 Filter 或 Interceptor 里
try {
MDC.put("requestId", UUID.randomUUID().toString());
MDC.put("userId", String.valueOf(userId));
chain.doFilter(request, response);
} finally {
MDC.clear(); // 底层也是 ThreadLocal
}
MDC 很容易被忽略。很多团队用了 MDC 打 traceId,但没有在请求处理完之后 clear,线程池里的线程带着上一个请求的 MDC 数据处理下一个请求,日志里的 traceId 对不上但很难察觉。
常见的踩坑场景
用 Spring 的 @Async 方法时,调用方线程里的 ThreadLocal 数据不会自动传递到新线程。需要显式传递:
typescript
@Async
public void asyncTask() {
// 这里拿不到调用方线程里的 ThreadLocal 数据
// 如果需要,要在提交任务时手动把数据传过来
}
如果业务上确实需要跨线程传递上下文(比如 traceId 要传到异步线程),用 InheritableThreadLocal------子线程创建时会从父线程复制一份数据。但在线程池场景里,线程不是每次都新建,InheritableThreadLocal 的复制逻辑不会触发,仍然需要手动处理。
Alibaba 开源的 transmittable-thread-local(TTL)解决了这个问题,在 Runnable/Callable 被提交到线程池时捕获当前线程的上下文,在任务执行时恢复,执行完清理。如果系统里有大量跨线程传递上下文的需求,TTL 是更可靠的方案。