比价接口为了让各层日志带上请求标识,把 traceId 放进 ThreadLocal。第一次请求正常,第二次请求却打印了第一次的标识。代码没有把标识存进全局普通变量,为什么仍然发生串值?原因在于请求已经结束,处理请求的线程却还活着,并且被线程池交给了下一项任务。
这是“Java 并发工程”的第六篇。我们通过四个可重复实验,区分线程隔离、任务作用域、内存滞留和跨线程传播。实验不依赖垃圾回收恰好发生,也不通过一次堆快照推断所有泄漏情况。
隔离的单位是线程
ThreadLocal 为访问它的线程维护相应的值。相同 ThreadLocal 对象可以被多个线程使用,每个线程的映射分别保存;同一个线程内的不同方法,则可以通过同一 ThreadLocal 访问相同值。它适合在明确作用域内传递上下文,但没有自动理解“当前 HTTP 请求”的能力。
线程池复用线程,任务边界与线程生命周期因此不同。任务甲调用 set 后返回,映射可能仍留在工作线程中。任务乙恰好被同一线程执行,又没有先建立自己的上下文,就可能读到甲留下的值。这是作用域清理问题,不需要发生任何线程间同时访问。
ThreadLocal 也不自动复制放进去的对象。如果两个线程都 set 同一个可变对象引用,它们仍在操作同一对象。隔离的是每个线程保存的映射,不是把任意对象深拷贝成互不相关的副本。共享对象自身的线程安全仍需单独处理。
Thread、ThreadLocal 与映射的关系
在本篇采用的 OpenJDK 实现中,Thread 持有自己的 ThreadLocalMap,Entry 的 key 关联 ThreadLocal,value 保存业务对象。ThreadLocal 对象负责根据当前线程访问对应映射,并不是一个由所有线程共同竞争的普通全局 Map。
这张关系图能解释两类容易混淆的现象:ThreadLocal 对象本身可以被很多线程共同引用,但每个线程的映射独立;某个任务结束不意味着线程对象结束,所以映射中的 value 仍可能保持可达。
阅读实现时还应注意这里的 Map 是专用结构,不是把 HashMap 换个字段名。碰撞处理、失效条目清理和扩容路径有自己的逻辑。业务开发通常不需要记住每个私有方法,但必须知道清理不是一个自动按请求完成触发的事件。
弱引用保护的是 key
Entry 对 ThreadLocal 的弱引用,可以在没有其他强引用时允许 key 被回收。然而 value 仍然可能通过活着的线程、映射和 Entry 保持强可达,直到相应条目被清除或线程结束。把“key 是弱引用”直接解释成“不会内存泄漏”,遗漏了最关键的引用链。
另一种情况是 ThreadLocal 本身仍由静态字段等位置强引用,key 根本不会失效。线程池里的 value 可以长期留在一个完全有效的映射中,尽管它已经不再服务任何请求。这同样需要作用域清理,不能只盯着 key 为 null 的条目。
实现中的某些 get、set、remove 路径会清理部分失效条目,但不是每次调用都承诺完整遍历并马上释放全部陈旧 value。不要把机会性清理当成业务生命周期管理,更不能用“以后还有请求,迟早会清理”作为保留大对象的理由。OpenJDK 21 ThreadLocal 源码
稳定复现串值不需要制造 GC
实验使用只有一个工作线程的固定线程池,保证两个顺序提交的任务由同一个工作线程执行。任务甲设置 request-A,正常结束却不清理;主线程读取甲的完成结果后再提交任务乙,乙直接 get,观察到 request-A。
ThreadLocal<String> trace = new ThreadLocal<>();
// pool 是单线程池;完整实验所有 get 都有超时。
pool.submit(() -> trace.set("request-A")).get();
String residue = pool.submit(() -> {
try { return trace.get(); }
finally { trace.remove(); }
}).get();
这个复现没有并发写同一个普通字段,也没有依赖内存压力。它揭示的是任务甲留下的状态被任务乙继承了。测试最后会在同一个工作线程上清理并关闭线程池,避免演示本身把残留状态留给后续测试。
实际应用还可能残留租户、语言、事务辅助信息或日志上下文。越接近授权与数据隔离的字段,串值影响越大。进入任务时显式建立上下文,退出任务时按作用域恢复或删除,应该成为一套完整协议。
finally 要覆盖异常路径
trace.set(requestId);
try {
return queryMerchant();
} finally {
trace.remove();
}
把 remove 放在正常返回之前,会遗漏异常、超时包装和提前返回。实验让任务在设置上下文后抛出 IllegalStateException,finally 执行清理;主线程确认 Future 传播了失败,再提交下一任务,断言读到 null。
在请求线程上调用 remove,并不会清理另一个线程池工作线程的值。清理必须发生在实际持有对应映射的线程里。这一点在异步编排中尤其重要:提交者的 finally 和执行者的 finally 处理的是不同作用域,不能互相替代。
set(null) 与 remove 也不完全相同。前者为当前线程建立或保留一个值为 null 的映射;后者移除映射,使后续访问可以重新执行初始化逻辑。若业务使用 withInitial,应根据想要的生命周期决定清理方式,而不是仅比较一次 get 是否返回 null。
跨线程时不会自动传递
普通 ThreadLocal 的调用线程上下文,不会因为把任务提交给线程池就自动出现在工作线程中。实验在主线程设置 caller,让池内任务读取同一 ThreadLocal,结果为空。这个行为正是线程隔离的一部分,而不是某种偶发丢值。
InheritableThreadLocal 也不应被直接当作线程池任务传播机制。继承发生在线程创建关系中,工作线程可能早于本次请求存在;之后提交新任务,不会自动重新采样提交者最新的值。创建时间与任务时间不一致,是理解这个边界的关键。
更明确的方法是在提交时捕获本次任务所需的不可变上下文,在执行时安装,任务结束后恢复原值或移除。也可以直接把 requestId 作为参数传递,减少隐式依赖。选择哪种形式,应考虑调用层次和框架已有的上下文机制。
捕获、安装和恢复分别解决什么
捕获决定数据属于哪个请求,安装决定工作线程在这项任务中看到什么,恢复决定外层作用域是否被破坏。三者不能简单缩成“执行完一律 remove”,因为当前任务可能嵌套在另一个合法上下文中。
实验先在单线程池建立 outer,然后捕获提交者的 request-C。内部任务暂时安装 request-C,finally 恢复 outer,下一任务验证外层值仍然存在,最后再删除它。这是一个明确的嵌套作用域演示,不代表生产代码应该在线程池里长期保存任意外层请求。
String previous = trace.get();
try {
trace.set(captured);
return doWork();
} finally {
if (previous == null) trace.remove();
else trace.set(previous);
}
这个简化包装约定 null 表示没有业务上下文。若应用需要区分“没有映射”和“显式映射为 null”,或者初始化方法有副作用,就需要更明确的上下文容器与作用域标记。本实验把约定写出来,避免把简化代码误当成所有框架的通用包装器。
上下文传播仍需决定传播什么
traceId 这样的不可变标识通常容易复制;数据库连接、事务对象、可变请求缓存则不能未经分析直接搬到其他线程。资源可能绑定原线程,可能要求串行访问,也可能在提交任务之前就已进入关闭过程。
提交时捕获与执行时读取也有不同语义。若排队时间很长,执行时从一个可变全局容器重新读取,可能拿到另一个请求的身份。任务应携带自己需要的数据,并对截止时间、取消状态和资源生命周期分别建模。
同时不要把日志上下文当作鉴权来源。某个 traceId 能帮助关联记录,但不证明用户具有访问权限。业务身份应来自经过验证的请求与授权协议;上下文工具负责传递,不能凭空赋予可信性。
四项实验与内存结论的边界
python3 run.py --work /tmp/java-concurrency-run --group ContextLab
本机四项通过:顺序请求复现 request-A 残留;异常任务后下一请求上下文为空;普通 ThreadLocal 不自动传播至线程池;显式捕获 request-C 后正确恢复 outer。完整结果见实测摘要。
这些断言证明了值的可见作用域与清理行为,没有证明某个对象已经被 GC 回收。对象是否仍有其他引用、什么时候发生 GC、映射内部何时整理,都需要额外证据。System.gc 只是请求,不适合作为“对象必须马上消失”的稳定测试前提。
排查真实内存滞留时,应结合线程池生命周期、上下文对象大小、引用链和堆分析。即使每个线程只保留一份值,值也可能间接引用庞大的对象图;也不能一概用线程数乘以某个对象浅大小估算全部影响。
减少隐式依赖的维护成本
ThreadLocal 使深层方法无需显式参数就能获得上下文,同时也让方法签名不再完整表达依赖。单元测试、后台任务和异步回调如果忘记建立作用域,可能得到与 Web 请求中不同的行为。建议在最外层统一创建和关闭作用域,并为缺失上下文规定清楚的结果。
例如,日志关联字段缺失可以采用明确的匿名标记;租户身份缺失通常应使操作失败,而不是沿用上一个任务的值。不同字段的默认值不能混成一个“没有就不管”的统一策略。边界错误尽早暴露,比让错误身份继续穿过多层调用更容易定位。
任务装饰器要覆盖提交后的所有执行入口
当应用决定使用显式上下文包装时,需要确认任务实际通过哪些入口提交。只包装 Runnable,却遗漏 Callable、定时任务或异步阶段的回调,可能让同一服务出现部分有上下文、部分没有上下文的现象。每个执行入口都应遵守同一捕获与恢复约定,或者干脆在业务参数中显式携带信息。
还要考虑执行器拒绝任务的情况。捕获通常发生在提交线程,安装应发生在真正执行任务的线程。如果提交阶段就在当前线程安装而没有及时恢复,任务被拒绝后可能污染调用者本身。将“包装一个任务”与“进入任务作用域”分开,能让拒绝路径更容易推理。
对于回调链,某些回调可能同步执行,某些会切换执行器。只要包装按进入时保存旧值、退出时恢复的规则工作,同线程嵌套和跨线程执行才有机会保持一致。若包装依赖“肯定换线程”的假设,同步回调反而可能删掉调用者仍需要的上下文。
本篇的四项实验分别覆盖残留、异常、缺失传播和嵌套恢复,没有实现一个通用执行器代理。将它扩展为公共组件时,应额外测试拒绝、重复包装、定时执行和多层异常,先确定对 null 与缺失值的约定,再决定 API 形式。
面试归纳
回答 ThreadLocal 问题时,先说明隔离单位是线程,线程池任务结束不等于线程结束;再画出 Thread 到 Map、Entry 和 value 的引用链;最后解释 finally 清理、跨线程不自动传播以及嵌套作用域恢复。弱引用是实现细节之一,生命周期才是工程问题的核心。