本文以《ThreadLocal 内存泄漏问题》为选题起点,结合 Oracle 当前的 ThreadLocal 与 ScopedValue 文档重新梳理。原文的核心提醒仍然成立,但生产问题往往不只表现为 OOM,更常见的是线程复用导致的上下文污染。

ThreadLocal 不是一块独立于线程的全局缓存。更准确的理解是:每个线程内部都有一份以 ThreadLocal 对象为键的值表。调用 set() 时,值被放进当前线程;调用 get() 时,也只从当前线程读取。

这个模型非常适合保存一次同步调用链中的 request id、租户、鉴权上下文等信息。问题也恰好来自这里:只要线程还活着,线程里的值就可能继续活着。在线程池中,一个工作线程会连续处理许多互不相关的任务,忘记清理就会把上一次任务的状态带给下一次任务。

两类问题不要混为一谈

1. 内存滞留

JDK 的 ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,但 value 仍是强引用。某个 ThreadLocal 在其他地方不可达后,key 可以被 GC 清掉,value 却可能继续挂在线程的 map 中,直到后续操作触发槽位清理或线程结束。

因此,“key 是弱引用”只能降低一部分风险,不能代替 remove()。线程池里的核心线程可能与进程存活时间接近,value 如果持有大对象、类加载器或完整请求对象,滞留时间也会非常长。

ThreadLocal 声明成 static final 同样不是清理方案。它能避免反复创建 key,却会让这个 key 始终可达;如果没有 remove(),对应 value 仍会跟着线程长期存在。

2. 跨请求上下文污染

比 OOM 更隐蔽的故障是串数据。假设一个线程先处理租户 A 的请求,随后被线程池复用来处理租户 B:

private static final ThreadLocal<String> TENANT = new ThreadLocal<>();

public void handle(Request request) {
    TENANT.set(request.tenantId());
    service.execute();
    // 忘记清理
}

只要后续某条异常路径没有重新赋值,就可能读到 A 的租户信息。这类问题不会立刻打满内存,却可能造成日志串号、错误路由,严重时甚至形成越权。

所以 ThreadLocal 的安全目标有两个:

  • 不让对象在线程里存活得超过业务作用域;
  • 不让一个任务的值被下一个任务观察到。

正确的最小模式:在边界处清理

最稳妥的做法是在请求、消息消费或任务执行的最外层设置上下文,并在 finally 中清理:

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

public void doFilter(Request request, FilterChain chain) throws Exception {
    CONTEXT.set(RequestContext.from(request));
    try {
        chain.doFilter(request);
    } finally {
        CONTEXT.remove();
    }
}

必须是 finally,因为业务异常、超时和提前返回都不能跳过清理。也应使用 remove(),而不是 set(null);后者仍可能保留一个值为 null 的 entry。

如果业务允许嵌套绑定,应先保存旧值,结束后恢复旧作用域:

public static <T> T withContext(
        RequestContext next,
        Supplier<T> action
) {
    RequestContext previous = CONTEXT.get();
    CONTEXT.set(next);
    try {
        return action.get();
    } finally {
        if (previous == null) {
            CONTEXT.remove();
        } else {
            CONTEXT.set(previous);
        }
    }
}

不要把清理责任分散到每个 Service。上下文从哪个基础设施边界进入,就应由同一个边界负责退出。

异步任务不会自动继承正确的上下文

CompletableFuture、自建线程池和框架异步任务切换线程后,普通 ThreadLocal 不会自动传递。直接换成 InheritableThreadLocal 也不是通用答案:它只在线程创建时复制值,而线程池里的线程通常早已创建;可变对象被复制后还可能带来新的共享问题。

更可控的选择按优先级排序:

  1. 把必要信息作为显式参数传入异步任务;
  2. 在提交任务时捕获不可变上下文,在任务开始时绑定,并在 finally 中清理或恢复;
  3. 使用框架提供的上下文传播机制,但仍要确认异常路径会清理;
  4. JDK 25 及以上的一次性只读上下文,可以评估 ScopedValue

ScopedValue 把值的生命周期绑定到一段动态作用域,作用域正常返回或抛出异常后会自动解绑:

private static final ScopedValue<RequestContext> CONTEXT =
        ScopedValue.newInstance();

ScopedValue.where(CONTEXT, requestContext)
        .run(() -> service.execute());

它适合“上游绑定、下游只读”的传递方式,不是所有 ThreadLocal 状态的机械替代品,但能从 API 形态上减少忘记清理和下游任意改写的问题。

怎样把问题测出来

用单线程线程池复现串数据

单线程池会稳定复用同一个线程,适合写回归测试:

ExecutorService pool = Executors.newSingleThreadExecutor();

pool.submit(() -> CONTEXT.set(new RequestContext("tenant-a"))).get();

RequestContext leaked = pool.submit(CONTEXT::get).get();
assertNull(leaked); // 没有清理时这里会失败

pool.shutdownNow();

真正的测试应通过应用自己的 Filter、Interceptor 或任务装饰器进入,而不是直接操作 holder。目标是证明一次任务结束后,同一线程执行的新任务看不到旧上下文。

从堆转储看引用链

怀疑内存滞留时,可以在堆转储中从线程对象向下检查:

Thread
└── threadLocals
    └── ThreadLocalMap.Entry
        └── value

重点关注线程池核心线程、value 的 retained size,以及 value 是否意外持有 WebApplicationClassLoader、大集合或完整请求对象。只看 key 是否为 null 不够;一个仍可达的 static final ThreadLocal 也能长期保留 value。

上线前检查清单

  • ThreadLocal 是否只保存调用链真正需要的最小、最好不可变的数据;
  • 设置和清理是否位于同一个基础设施边界;
  • 所有异常路径是否都经过 finally
  • 是否使用 remove(),而不是依赖弱引用或 set(null)
  • 异步切线程时是否明确了传播策略;
  • 是否有“同一线程连续执行两次任务”的隔离测试;
  • 新项目若使用 JDK 25+,只读上下文是否更适合 ScopedValue

参考资料