ThreadLocal 的弱引用、残留值与线程池上下文串扰
请求 A 已经结束,请求 B 却读到了 A 的用户身份:问题往往不在并发写入,而在工作线程被复用。ThreadLocal 隔离的是线程中的绑定,不是请求生命周期。下面把对象存活和业务串扰分开,再用 JDK 21 实际运行同一组代码。
引用链:弱的是键,值仍被强引用
依据 OpenJDK 21 ThreadLocal 源码,映射保存在当前 Thread.threadLocals,ThreadLocal 对象作为键。内部 Entry 继承 WeakReference<ThreadLocal<?>>,但其 value 是普通对象字段。
局部 ThreadLocal 不再被强引用后,GC 可以清除弱键;这不会自动把 Entry 的 value 置空。只要工作线程及其映射仍可达,值就可能继续占用内存。若值又间接强引用键,键甚至不满足弱可达条件,不能期待弱引用替你解除环路。
常见的 static final ThreadLocal 则始终有外部强引用,键通常不会消失。此时忘记清理也能长期保留值,并且下一请求还能用同一个键读到旧值。因而“空键残留”与“有效键串扰”是两条不同路径,不能把所有问题都归因于弱引用失效。
清理何时发生:不能把下一次 get 当成保障
JDK 21 的映射使用线性探测。查找未直接命中时,可能沿探测区间清理空键;插入可能执行局部扫描或重整;remove 定位当前键后清除并整理相邻区间。直接命中的 get 可以立即返回,不会遍历整张表。
因此,即使线程继续工作,也不能推断每次访问都会扫掉所有残留项;线程空闲时,更没有定时清理承诺。源码没有使用引用队列来即时移除这些 Entry。GC 清弱键与映射释放值是两个时刻,不能用一次 System.gc() 证明所有上下文已释放。
| 操作 | 当前线程的绑定 | 后续 get 的含义 |
|---|---|---|
set(null) | 保留键及空值 | 返回 null,不重新初始化 |
remove() | 删除当前键对应项 | 若未再次 set,调用初始化逻辑 |
| 丢弃局部键引用 | 映射可能仍持有值 | 无法再通过原键主动删除 |
remove() 也不会关闭值里的文件或连接;资源释放仍须遵守资源自己的生命周期。JDK 21 API明确说明,删除后再次 get 会重新初始化,默认初值为 null。
单线程池时序:请求结束不等于线程结束
示例使用一个工作线程,主线程等待 A 的 Future 完成后再提交 B,排除调度偶然性。A 设置 alice 后主动抛异常,B 不设置身份,模拟匿名请求或入口漏设。下面是可运行源码中的核心结构:
USER.set("alice");
try {
throw new IllegalStateException("simulated request failure");
} finally {
if (fixed) USER.remove();
}时序是:worker-1 执行 A → A 异常退出 → 线程回到池中 → worker-1 执行 B → B 调用 USER.get()。Future 报告任务异常,并不意味着池会销毁工作线程或清空 ThreadLocal。错误模式下,B 读到 alice,隔离断言失败;修复模式下,A 的 finally 已经删除绑定,B 得到默认 null。
完整源码见 ThreadLocalDemo.java。在仓库根目录执行,编译产物也留在专属证据目录:
javac -encoding UTF-8 --release 21 -d docs/upgrade-100/checks/threadlocal/classes examples/java-advanced/threadlocal/ThreadLocalDemo.java
java -cp docs/upgrade-100/checks/threadlocal/classes ThreadLocalDemo leak
java -cp docs/upgrade-100/checks/threadlocal/classes ThreadLocalDemo fixed
java -cp docs/upgrade-100/checks/threadlocal/classes ThreadLocalDemo semanticsleak 是预期失败样例:打印 request B: alice,以 AssertionError 和非零退出码结束;fixed 打印 request B: null 并通过断言。semantics 依次打印 after set(null): null 和 after remove: initial,说明两种清理方式并不等价。
本机实际使用 Oracle JDK 21.0.12 编译运行,原始输出、退出码与命令保存在 运行证据。这次没有执行堆转储或 GC 回收实验,引用关系结论来自官方源码;示例不依赖数据库、Redis 或任何服务器。
把清理放在拥有请求边界的位置
身份设置和删除应包围整个请求处理过程,通常由过滤器、拦截器或任务包装器负责;finally 要覆盖异常、提前返回等出口。必须在使用上下文的工作线程上 remove,主线程执行 remove 只能删除主线程自己的绑定。
排查时先找上下文的写入入口和所有退出路径,再确认读取者究竟运行在哪个线程。如果日志显示相同工作线程先后处理不同请求,应检查异常分支是否跳过清理。单看堆里是否存在空键,不能排除有效键保存了旧身份;反过来,没有业务串扰也不代表大对象已经及时释放。
跨异步边界时,普通 ThreadLocal 不会自动携带提交者上下文。应显式捕获必要且尽量不可变的数据,在目标任务内建立绑定并在退出时清理;不要把可变请求对象到处共享。若存在同线程嵌套作用域,内层简单 remove 会抹掉外层绑定,需要保存并恢复外层状态,或设计明确的作用域栈。
