跳到正文
Elaine Blog
返回

ThreadLocal 泄漏与请求上下文治理

并发编程

线程池会复用工作线程,所以 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,从硬件缓存一致性解释无锁计数为何 仍可能很慢。


分享这篇文章:

上一篇
ForkJoinPool、工作窃取与并行流
下一篇
CPU 缓存、伪共享与 Disruptor