ThreadLocal
ThreadLocal 把值关联到当前线程,适合保存少量调用上下文。本文先介绍值保存在哪里,再解释线程池中的内存保留和上下文污染,最后说明跨线程传递与嵌套恢复。
1. 面试核心
先记住这几条,再读正文- 存储位置。 OpenJDK 21 把值保存在当前
Thread的ThreadLocalMap中,用ThreadLocal实例作为查找键。 - 引用与存活时间。
ThreadLocalMap的键是弱引用、值是强引用,长期存活的线程会保留尚未清理的值。卡 · ThreadLocalMap 的结构 - 请求结束清理。 独立请求在
finally中调用remove(),可以删除当前线程的条目,避免后续任务读到旧值。 - 创建时继承。
InheritableThreadLocal在创建子线程时传递初始值。卡 · 子线程继承与线程池复用 - 嵌套恢复。 嵌套调用覆盖上下文后,应在
finally中恢复外层值,让外层调用继续使用原来的上下文。卡 · 嵌套作用域的恢复
2. ThreadLocalMap 的结构
同一个 ThreadLocal 实例可以让不同线程读到各自的值。要理解这种隔离,需要先看值保存在哪里:
private static final ThreadLocal<RequestContext> CONTEXT =
new ThreadLocal<>();
OpenJDK 21 中,每个 Thread 持有自己的 ThreadLocalMap。这张映射表用 ThreadLocal 实例作为键,保存当前线程关联的值。因此,调用 CONTEXT.get() 时,查找的是当前线程的表。不同线程可以用同一个键保存不同的 RequestContext。
隔离的是每个线程保存引用的位置。如果多个线程放入同一个可变对象,它们仍然会访问同一份数据。此时,对象内部的并发读写还需要同步。
3. 弱引用 key 与 value 保留
键被垃圾回收后,关联的值仍可能留在内存中。原因是映射表的条目(entry)对键和值使用了不同的引用方式。
条目的 key 是弱引用。也就是说,外部失去对 ThreadLocal 的强引用后,这个键就可以被垃圾回收,变成 null。value 则是强引用,仍由线程的映射表持有。
后续部分映射表操作会顺带清理失效条目,线程结束也会释放对应存储。但线程池的工作线程(Worker)会反复执行任务,甚至与进程一样长寿。如果清理迟迟没有发生,请求对象、大对象及其关联的类加载器都会被意外保留。
静态 ThreadLocal 的键持续存活时,也会保留尚未清理的旧请求值。所以,清理应跟随请求或调用的结束,而不是等待垃圾回收。
4. finally 清理
独立请求结束后,应同时清理它在线程上留下的上下文。把设置与清理放在同一作用域,并使用 finally,就能覆盖正常返回和异常退出:
CONTEXT.set(context);
try {
handleRequest();
} finally {
CONTEXT.remove();
}
此处假设每次请求独占该作用域。remove() 会删除当前线程的条目,后续 get() 将重新初始化。set(null) 则保留条目,只把值设为 null,所以后续 get() 直接返回 null。
线程复用时,遗漏清理还会造成上下文污染,也就是新任务读到了旧任务的数据。比如,下一次请求未成功设置上下文,就会读到旧用户身份、租户或 trace 信息。trace 信息用于把同一次请求经过的多个服务调用关联起来。嵌套调用则需要恢复外层绑定,下文会给出示例。
5. 跨线程的上下文传递
提交线程和执行任务的 Worker 各有自己的映射表。因此,把任务交给已有线程池时,普通 ThreadLocal 的值仍留在提交线程中。
需要传播时,可以用任务包装器管理上下文的捕获、绑定与恢复:
- 提交任务时,捕获必要的不可变上下文。
- 执行任务前,保存执行线程的原值,再绑定捕获的上下文。
- 任务结束时,在
finally中恢复原状态,覆盖正常返回和异常路径。
日志框架和可观测性库也会提供上下文传播机制,可以按所用框架接入。传播内容应限于租户、trace 等必要字段。若保存整个请求对象,后台任务就会延长它及其关联数据的生命周期。
包装器还要适用于任务在提交线程中执行的情况,嵌套恢复一节会说明原因。
6. 虚拟线程与 ThreadLocal
Java 21 可以为每个任务创建一个虚拟线程,用完即结束。这样可以减少池化 Worker 跨任务复用造成的上下文污染,线程结束时也会释放局部存储。
但一个进程中可以有数百万个虚拟线程。如果每个线程都缓存昂贵对象,总内存仍会显著增长。因此,应按业务作用域清理局部绑定,跨线程任务显式传递必要上下文。嵌套调用则恢复外层值。
7. 内存保留与上下文诊断
诊断时要分别确认两件事:对象为什么仍在内存中,以及新任务是否读到了旧上下文。可以检查以下四项证据:
- 在堆转储中,沿 Worker、
ThreadLocalMap$Entry和可疑 value 查看引用链。这样可以确认是哪一个线程保留了对象。 - 在任务开始和结束处检查上下文是否残留,日志只记录是否存在残留,避免输出敏感值。
- 在测试中连续复用同一个 Worker,确认前一任务抛异常后,下一任务仍读不到旧上下文。
- 同时观察线程数量和单任务内存,区分合法缓存增长与请求对象超出作用域的保留。
8. 子线程继承与线程池复用
InheritableThreadLocal 解决的是创建子线程时的初始值传递。默认 childValue 直接返回父值引用,所以父子线程会引用同一个对象。若这个对象可变,双方的修改就会相互影响。
线程池会把多个任务交给已经创建的 Worker。继承只在线程创建时发生,后续提交任务时不会重新复制上下文。如果 Worker 首次创建时继承了某个用户的信息,后面的任务还会沿用旧值。
所以,线程池应按任务捕获不可变快照,并在任务开始和结束时绑定、恢复上下文。
9. 嵌套作用域的恢复
嵌套调用结束后,外层代码还要继续使用自己的上下文。因此,内层设置新值前要保存原值,退出时再恢复:
RequestContext previous = CONTEXT.get();
CONTEXT.set(inner);
try {
invokeNested();
} finally {
if (previous == null) {
CONTEXT.remove();
} else {
CONTEXT.set(previous);
}
}
本例使用默认初始化,并约定 null 表示尚未绑定。内层直接调用 remove() 会把外层上下文一起丢掉。保存原值后,内层才能在退出时恢复外层绑定;原先未绑定时才删除条目。
线程池采用 CallerRunsPolicy 拒绝策略时,任务会在池未关闭且已饱和时交给提交线程执行。这相当于在提交者内部嵌套执行任务,所以包装器也要恢复原值。
10. 常见问题
10.1 key 已经是弱引用,为什么还要 remove?
弱引用只允许 ThreadLocal 键被回收,value 仍由映射表强引用。等待后续表操作清理,无法确定值何时被释放。因此,独立请求结束时应调用 remove(),明确结束绑定。
10.2 ThreadLocal 能用来保存数据库连接吗?
事务框架可以在事务范围内临时绑定连接,但必须完整管理连接的获取、解绑和归还。若应用只是把连接长期放入 ThreadLocal,就容易造成连接泄漏和事务串扰。切换到异步任务或虚拟线程时,还要确认执行线程和事务边界,连接应由明确的资源作用域管理。
11 · 面试题卡片
11.1 ThreadLocal 为什么可能发生内存泄漏,怎样避免
出现公司:美团、某云原生企业
考察重点
- 说明键被回收后,线程为什么仍能通过
ThreadLocalMap保留值。 - 解释 Worker 长期存活怎样导致内存保留和上下文污染。
- 区分独立请求清理、嵌套恢复与跨线程传播。
相关内容:ThreadLocalMap 的结构至跨线程的上下文传递。
参考回答
长期存活的线程会通过 ThreadLocalMap 保留已超出业务作用域的 value,造成内存泄漏。
条目的 key 是弱引用,value 是强引用。因此,即使 key 被回收,值仍要等到条目清理或线程结束才会释放。线程池复用 Worker 时,未清理的旧值还会污染下一任务。
独立请求应在设置值的同一作用域内,用 finally 调用 remove()。嵌套调用则保存并恢复先前绑定。跨线程任务只捕获必要的不可变上下文,再在执行边界绑定和恢复。
11.2 InheritableThreadLocal 为什么不适合直接传播线程池任务上下文?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分线程创建与任务提交的时机,并解释默认继承为什么会共享对象引用。
相关内容:子线程继承与线程池复用。
参考回答
InheritableThreadLocal 只在线程创建时继承值,而线程池会让已有 Worker 执行新任务。
因此,后续提交任务时,Worker 仍持有最初继承的上下文。默认继承还会共享同一个值引用,可变对象的修改会相互影响。
按任务显式传递不可变上下文,才能让每次执行对应正确的请求。任务结束时还应恢复执行线程原来的绑定。
11.3 嵌套调用设置 ThreadLocal 后,直接 remove 有什么问题?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明内层清理怎样影响外层绑定,以及任务在提交线程执行时为什么也需要恢复。
相关内容:嵌套作用域的恢复。
参考回答
直接 remove() 会丢失外层仍需使用的上下文。
内层进入前应保存原值,在 finally 中恢复,只有原先未绑定时才删除条目。
线程池未关闭且已饱和时,CallerRunsPolicy 会让任务在提交线程执行。这时任务与提交者共用一个线程,所以也应恢复原值,让提交者继续使用自己的上下文。