ThreadLocal 用了 WeakReference,为什么还会内存泄漏

先看 ThreadLocal 的存储结构

ThreadLocal 本身不存数据,数据存在每个 Thread 对象里的一个 ThreadLocalMap 字段上。ThreadLocalMapThreadLocal 的内部类,结构类似 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 是更可靠的方案。

相关推荐
Cosolar1 小时前
大模型应用开发面试 • 每日三题|Day 002|记忆(Memory)、工具使用(Tool Use)和微调(Fine-tuning)
后端·python·llm
神奇小汤圆1 小时前
深入源码:Hermes Agent 如何实现 "Self-Improving"
后端
神奇小汤圆1 小时前
百度二面:Spring 中的 Bean 是线程安全的吗?
后端
铭毅天下1 小时前
当搜索引擎遇上 Rust——深度解读下一代实时搜索引擎 INFINI Pizza
开发语言·后端·搜索引擎·rust
用户298698530141 小时前
Java 后端处理 Word 修订:批量接受与拒绝的自动化方案
java·后端
马艳泽1 小时前
win11环境查找jar包中字符串
后端
宁&沉沦2 小时前
后端各框架热启动 极简启动命令(直接复制用)
后端
枕星而眠2 小时前
Linux 共享内存与信号量全解析:原理、实践与避坑指南
linux·c语言·开发语言·后端·ubuntu