先说结论

ThreadLocal 为同一变量提供每线程独立副本,数据存在线程对象内部的 ThreadLocalMap。Map 的键是 ThreadLocal 弱引用,值仍是强引用;键被回收后,如果线程长期存活且没有触发清理,值可能继续滞留。

在线程池中线程反复复用,不清理还会把上一个请求的用户、租户或链路信息泄露给下一个任务。

关键机制

get/set/remove 会顺便清理一部分失效槽位,但这不是及时回收保证。ThreadLocal 的隔离边界是线程,不是请求;异步切换线程后上下文也不会自动跟随。

ThreadLocalMap 的结构

每个 Thread 持有一个 ThreadLocalMap,而不是 ThreadLocal 持有每个线程的数据。Map 的 Entry 继承弱引用,弱引用指向 ThreadLocal key,value 则是强引用;哈希冲突使用开放寻址探测。

当业务代码不再引用某个 ThreadLocal,GC 可回收 key,但线程池工作线程仍活着,Entry 中的 value 可能继续占用内存。后续 get/set/remove 可能顺带清理该 stale entry,然而没有后续访问时它可能长期存在。

请求上下文的标准写法

final class RequestContextHolder {
    private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();

    static void with(RequestContext context, Runnable action) {
        CONTEXT.set(context);
        try {
            action.run();
        } finally {
            CONTEXT.remove();
        }
    }
}

把 set/remove 封装在单个边界组件中,比让控制器、拦截器和业务代码分别管理更可靠。异常、提前 return、任务超时和框架重试都不会绕过 finally。

异步场景为什么会丢上下文

String traceId = TRACE_ID.get();
executor.execute(() -> log.info("trace={}", TRACE_ID.get()));

任务在线程池的另一个线程执行,后者有自己的 ThreadLocalMap,所以默认取不到 traceId。解决方法是显式把不可变上下文传入任务、通过 TaskDecorator 包装提交动作,或使用符合框架规范的上下文传播库;传播后同样要清理,避免污染工作线程。

不要用 InheritableThreadLocal 解决线程池问题。它只在创建子线程时复制父线程值,而池中工作线程通常早已创建,且复制可变上下文会产生更隐蔽的共享问题。

ThreadLocal 与虚拟线程

虚拟线程也支持 ThreadLocal,但“一请求一虚拟线程”可能让每个请求都创建 ThreadLocalMap。轻量上下文、显式参数和 ScopedValue 等更结构化的机制在新代码中值得评估,尤其是只读、作用域明确的上下文。

线上排查清单

内存长期上涨且 Full GC 后无法回落时,除了业务缓存,要检查线程池数量、线程生命周期、ThreadLocal value 是否保存大对象或类加载器。堆转储中从 Thread -> threadLocals -> Entry.value 的引用链是重要证据;修复后还应压测验证工作线程复用下不再串上下文。

可以怎么用

context.set(value);
try {
    handle();
} finally {
    context.remove();
}

尽量缩小生命周期,不存放体积很大的对象。跨线程传递应显式封装上下文,或使用框架提供的传播机制并确保清理。

容易踩坑的地方

ThreadLocal 不是解决共享变量竞争,而是避免共享。InheritableThreadLocal 在线程池中也不可靠,因为线程通常早于请求创建,继承发生在创建线程时。

常见问题

追问:为什么 key 要设计成弱引用?

这样调用方不再持有 ThreadLocal 时,键有机会被回收;但值的清理仍依赖 Map 后续操作,因此显式 remove 仍不可省略。

追问:static final ThreadLocal 会被回收吗?

通常不会,因为类一直强引用它。只要 value 生命周期和线程生命周期都受控,这种模式反而避免了“key 被回收、value 残留”的一类问题,但每次请求仍必须 remove。

追问:能把数据库连接放到 ThreadLocal 吗?

框架可以在清晰事务边界内绑定连接,但业务代码自行长期保存连接风险很大:线程池复用会串请求,连接超时或事务异常也难以正确释放。优先使用数据源和事务框架。

追问:ThreadLocal 能解决线程安全吗?

只能让每个线程看到独立副本,不能协调跨线程共享数据,也不能让同一个对象内部操作原子化。它是隔离工具,不是同步工具。