本文以《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 也不是通用答案:它只在线程创建时复制值,而线程池里的线程通常早已创建;可变对象被复制后还可能带来新的共享问题。
更可控的选择按优先级排序:
- 把必要信息作为显式参数传入异步任务;
- 在提交任务时捕获不可变上下文,在任务开始时绑定,并在
finally中清理或恢复; - 使用框架提供的上下文传播机制,但仍要确认异常路径会清理;
- 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。