线程池会复用工作线程,所以 ThreadLocal 值可能跨任务残留。即使 ThreadLocal 键能被回收,线程内部表中的值也可能在
清理前继续占用内存。更危险的是身份、租户或追踪信息串到下一个请求。
Table of contents
Open Table of contents
运行上下文残留实验
下载ThreadLocal 泄漏样例下载, 运行:
java -cp target/classes dev.elaine.concurrency.ThreadLocalLeakSample
输出为:
未清理时下一请求读到=request-A
清理后下一请求读到=null
单线程执行器保证两个任务使用同一工作线程,因此无需循环碰运气。第一个任务不清理时,第二个任务读到旧请求标识。
ThreadLocal 的所有权在当前线程
每个线程保存自己的 ThreadLocal 映射,键是弱引用,值不是弱引用。键被回收并不代表值立即消失;长寿命线程只有在后续 访问触发清理或线程结束时才可能释放条目。
正确的局部使用模式是:
try {
REQUEST_ID.set(requestId);
handleRequest();
} finally {
REQUEST_ID.remove();
}
不能用 set(null) 替代 remove(),前者仍保留条目结构。框架 Filter、Interceptor 或任务装饰器必须覆盖正常、异常和
取消路径。
异步边界不会自动传播
提交到另一个 Executor 后,任务可能运行在不同线程,普通 ThreadLocal 不会自动复制。InheritableThreadLocal 只在创建
子线程时复制初始值,不适合池化线程,也容易传播过量状态。
更清楚的做法是把不可变 Context 作为参数:
record RequestContext(String requestId, String tenantId) {}
CompletableFuture<Result> execute(RequestContext context, Command command) { ... }
若框架提供日志 MDC 或上下文传播组件,仍要测试嵌套任务、异常、取消和线程复用后的清理。
虚拟线程不等于无限 ThreadLocal
虚拟线程通常每任务创建,不会像平台线程池那样跨请求复用,但创建海量虚拟线程时,每个线程都保存昂贵 ThreadLocal 会 放大内存占用。JEP 444 建议不要用 ThreadLocal 缓存昂贵、可共享的对象来“节省线程创建成本”。
诊断方法
先检查错误上下文日志和执行器边界,再用 Heap Dump 查找长寿命线程到 ThreadLocalMap 值的引用链。诊断文件可能包含 请求数据,必须按敏感数据处理。
下一步
阅读CPU 缓存、伪共享与 Disruptor,从硬件缓存一致性解释无锁计数为何 仍可能很慢。