背景与问题
很多 Java 项目里都用过 ThreadLocal:保存用户信息、请求追踪 ID、租户标识、数据库路由上下文……写起来很顺手,因为“当前线程可见,别的线程拿不到”。
问题也恰恰出在这里。
一旦你把 ThreadLocal 放进线程池环境里,坑就开始变得隐蔽:
- 内存泄漏:请求结束了,但
ThreadLocal里的值没清掉,线程又因为在线程池里长期存活,值也跟着长期存活。 - 上下文串值:上一个请求留在工作线程里的用户信息,被下一个请求“继承”了,最终出现 A 用户看到了 B 用户数据这种离谱现象。
- 排查困难:现场通常不是“直接报错”,而是内存缓慢上涨、日志 traceId 混乱、偶发越权、数据串租户。
我自己第一次遇到这个问题时,表面现象是“日志 traceId 偶尔串了”,一开始还以为是链路追踪组件有 bug,最后才发现是线程池里的 ThreadLocal 没有 remove()。
这篇文章不讲空泛定义,重点从原理、复现、定位、止血、最佳实践几个角度,把这个坑完整走一遍。
背景场景:为什么在线程池里更容易出事
先看一个常见使用方式:
- Web 请求进来
- 在拦截器/过滤器里把当前用户信息放入
ThreadLocal - 业务代码从
ThreadLocal里直接获取 - 请求结束后理论上应该清理
如果线程是“一次性线程”,任务跑完线程结束,问题还没那么明显。
但在线程池中,线程不会结束,会被复用。于是:
- 线程 A 处理请求 1,往
ThreadLocal放了值 - 请求 1 结束,没有清理
- 同一个线程 A 又处理请求 2
- 请求 2 在某些逻辑里读到请求 1 的遗留值
这就是典型的“串值”。
而内存泄漏则更隐蔽:线程池线程长期存在,线程对象里关联的 ThreadLocalMap 也长期存在,里面的 value 如果一直挂着,就很难被回收。
flowchart TD
A[请求1进入线程池线程T1] --> B[ThreadLocal.set 用户A]
B --> C[业务处理结束]
C --> D[未调用 remove]
D --> E[线程T1归还线程池]
E --> F[请求2复用线程T1]
F --> G[读取到旧上下文 用户A]
G --> H[发生串值或权限问题]
核心原理
1. ThreadLocal 并不是“全局隔离”,而是“线程维度存储”
ThreadLocal 的本质不是把值存在 ThreadLocal 对象里,而是把值放在当前线程对象的 ThreadLocalMap 中。
可以粗略理解为:
currentThread.threadLocalMap.put(threadLocal, value);
因此:
- 同一个
ThreadLocal,不同线程读到的是不同值 - 值的生命周期,很大程度取决于线程生命周期
- 线程池线程长时间不销毁,值就更容易残留
2. 为什么会内存泄漏
ThreadLocalMap 的 key 是 ThreadLocal 的弱引用,value 是强引用。
这会带来一个经典问题:
- 如果外部不再持有某个
ThreadLocal实例 - 那么 key 可能被 GC 回收,变成
null - 但是对应的 value 仍然被
ThreadLocalMap强引用着 - 只要线程还活着,这个 value 可能就一直留在内存里
这类 entry 通常被称为“stale entry(陈旧条目)”。
注意一个容易误解的点:
弱引用 key 并不等于不会泄漏。
它只是让 key 更容易被回收,但 value 仍可能滞留。
classDiagram
class Thread {
ThreadLocalMap threadLocals
}
class ThreadLocalMap {
Entry[] table
}
class Entry {
WeakReference~ThreadLocal~ key
Object value
}
Thread --> ThreadLocalMap
ThreadLocalMap --> Entry
3. 为什么线程池下更严重
普通短命线程中,即使没清理,线程结束后整块线程对象都能被回收。
但线程池线程通常是:
- 核心线程长期驻留
- 工作线程不断复用
- 一个线程会处理大量任务
所以没清理的 ThreadLocal 值可能:
- 在多个任务之间串传
- 长时间占用内存
- 让问题变成“慢性病”,很难第一时间被发现
4. InheritableThreadLocal 不是救星
不少人会想到 InheritableThreadLocal:子线程能继承父线程变量,看起来很方便。
但在线程池中它通常更危险:
- 线程池线程往往早就创建好了
- 后续提交任务时,并不会重新从父线程复制上下文
- 结果经常是“你以为继承了,其实没继承对”
- 再叠加线程复用,还会出现更加诡异的串值
一句话:在线程池里,不要把 InheritableThreadLocal 当作上下文传播方案。
现象复现
先用一个最小可运行示例,把“串值”和“未清理”复现出来。
复现 1:线程池中的上下文串值
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadLocalLeakDemo1 {
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
// 第一个任务:设置值,但故意不清理
pool.submit(() -> {
USER_CONTEXT.set("user-A");
System.out.println(Thread.currentThread().getName() + " set user-A");
System.out.println(Thread.currentThread().getName() + " get = " + USER_CONTEXT.get());
// 故意不 remove
}).get();
// 第二个任务:不设置值,直接读取
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " second task get = " + USER_CONTEXT.get());
}).get();
pool.shutdown();
pool.awaitTermination(3, TimeUnit.SECONDS);
}
}
运行结果示例
pool-1-thread-1 set user-A
pool-1-thread-1 get = user-A
pool-1-thread-1 second task get = user-A
第二个任务本来没有设置任何用户上下文,却读到了上一个任务留下来的 user-A。
这就是串值。
复现 2:正确做法,用 finally 保证清理
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadLocalLeakDemo2 {
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> {
try {
USER_CONTEXT.set("user-A");
System.out.println(Thread.currentThread().getName() + " set user-A");
System.out.println(Thread.currentThread().getName() + " get = " + USER_CONTEXT.get());
} finally {
USER_CONTEXT.remove();
}
}).get();
pool.submit(() -> {
System.out.println(Thread.currentThread().getName() + " second task get = " + USER_CONTEXT.get());
}).get();
pool.shutdown();
pool.awaitTermination(3, TimeUnit.SECONDS);
}
}
运行结果示例
pool-1-thread-1 set user-A
pool-1-thread-1 get = user-A
pool-1-thread-1 second task get = null
这才是我们预期的行为。
实战代码(可运行)
下面给一个更贴近业务场景的示例:
模拟 Web 服务里通过 ThreadLocal 维护 traceId,然后在线程池中执行业务任务。
错误示例:上下文残留
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TraceContextBadCase {
static class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void set(String traceId) {
TRACE_ID.set(traceId);
}
public static String get() {
return TRACE_ID.get();
}
public static void clear() {
TRACE_ID.remove();
}
}
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(1);
// 模拟请求1
executor.submit(() -> {
String traceId = "REQ-1-" + UUID.randomUUID();
TraceContext.set(traceId);
log("处理请求1");
// 这里漏掉 clear()
}).get();
// 模拟请求2,没有设置 traceId,直接执行业务
executor.submit(() -> {
log("处理请求2");
}).get();
executor.shutdown();
}
private static void log(String message) {
System.out.printf("[%s] [%s] %s%n",
Thread.currentThread().getName(),
TraceContext.get(),
message);
}
}
可能输出
[pool-1-thread-1] [REQ-1-0c1c8f62-xxxx] 处理请求1
[pool-1-thread-1] [REQ-1-0c1c8f62-xxxx] 处理请求2
请求 2 居然带着请求 1 的 traceId。
正确示例:封装上下文执行器,自动清理
如果业务里经常使用 ThreadLocal,我建议不要把 set/remove 散落在每个地方,而是统一封装。
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TraceContextGoodCase {
static class TraceContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void set(String traceId) {
TRACE_ID.set(traceId);
}
public static String get() {
return TRACE_ID.get();
}
public static void clear() {
TRACE_ID.remove();
}
}
static class ContextAwareExecutor {
private final ExecutorService executorService;
public ContextAwareExecutor(ExecutorService executorService) {
this.executorService = executorService;
}
public void submit(String traceId, Runnable task) throws Exception {
executorService.submit(() -> {
try {
TraceContext.set(traceId);
task.run();
} finally {
TraceContext.clear();
}
}).get();
}
public void shutdown() {
executorService.shutdown();
}
}
public static void main(String[] args) throws Exception {
ContextAwareExecutor executor = new ContextAwareExecutor(Executors.newFixedThreadPool(1));
executor.submit("REQ-1-" + UUID.randomUUID(), () -> log("处理请求1"));
executor.submit("REQ-2-" + UUID.randomUUID(), () -> log("处理请求2"));
executor.shutdown();
}
private static void log(String message) {
System.out.printf("[%s] [%s] %s%n",
Thread.currentThread().getName(),
TraceContext.get(),
message);
}
}
这个版本做了两件事:
- 每个任务执行前设置上下文
- 无论任务是否异常,都会在
finally中清理
这是线程池里使用 ThreadLocal 最基本的防线。
定位路径:出了问题该怎么查
很多人知道“要 remove”,但真实线上排查时,难点不是知道原理,而是怎么快速定位。
我一般会按下面这条路径查。
1. 先看现象是否符合 ThreadLocal 问题特征
重点关注这些信号:
- 日志中的
traceId、userId、tenantId偶发串号 - 线程池中的任务偶发读到“上一次请求”的上下文
- 内存持续上涨,但对象分布看起来并不集中在业务缓存里
- 问题只在线上高并发或压测时明显出现
- 重启后暂时恢复正常
如果同时出现“线程池 + 上下文变量 + 偶发串值”,十有八九要怀疑 ThreadLocal。
2. 全局搜索 ThreadLocal 定义点
直接在代码里搜:
new ThreadLocalThreadLocal.withInitialInheritableThreadLocal- 各类上下文类名:
UserContext、TraceContext、TenantContext
重点关注:
- 是否有
set()但没有remove() remove()是否真的在finally里- 是否跨线程读取
- 是否通过线程池异步执行
3. 检查过滤器、拦截器、AOP、线程池包装器
实际项目里最容易漏清理的地方,不是在 Controller,而是在这些“基础设施层”:
- Servlet Filter
- Spring HandlerInterceptor
- AOP 切面
- 自定义线程池提交包装
- 异步任务框架回调
尤其要看有没有这种代码:
context.set(xxx);
chain.doFilter(request, response);
// 没有 finally 清理
或者:
executor.submit(() -> {
context.set(xxx);
service.doSomething();
// 异常时不会走到 clear
context.clear();
});
后者在发生异常时特别容易漏。
sequenceDiagram
participant Req1 as 请求1
participant Pool as 线程池线程T1
participant TL as ThreadLocal
participant Req2 as 请求2
Req1->>Pool: 提交任务
Pool->>TL: set(userA)
Pool->>Pool: 执行业务
Pool-->>Req1: 返回(未remove)
Req2->>Pool: 提交任务
Pool->>TL: get()
TL-->>Pool: userA
Pool-->>Req2: 读到旧值,发生串值
4. 内存问题排查:看线程对象关联值
如果你怀疑已经有内存泄漏,可以用这些手段:
jmap -histo- heap dump + MAT / YourKit / JProfiler
- 查看可疑大对象是否被线程长时间引用
- 按线程名、线程池名聚类分析
排查思路不是机械地找“ThreadLocal”三个字,而是看:
- 某些 value 对象为什么没有释放
- 它们是否被
Thread -> ThreadLocalMap -> Entry -> value链路引用 - 线程池线程是否长期存活
在 MAT 里常见路径会类似:
java.lang.Thread
-> threadLocals
-> ThreadLocalMap
-> table
-> Entry
-> value
如果这个 value 恰好是:
- 大对象
- 用户会话
- 数据缓存
- DB 连接包装
- ByteBuffer
- 业务上下文对象树
那危害会更大。
常见坑与排查
坑 1:只 set,不 remove
这是最常见、也是最致命的。
错误写法
public void process(String userId) {
USER_CONTEXT.set(userId);
doBiz();
}
正确写法
public void process(String userId) {
try {
USER_CONTEXT.set(userId);
doBiz();
} finally {
USER_CONTEXT.remove();
}
}
坑 2:以为设成 null 就等于清理
很多人会写:
USER_CONTEXT.set(null);
这不等于 remove()。
区别在于:
set(null):条目可能还在,只是 value 为nullremove():真正移除当前线程里对应 entry
实践里,清理一定优先用 remove()。
坑 3:异常路径漏清理
比如:
USER_CONTEXT.set(userId);
service.call(); // 这里抛异常
USER_CONTEXT.remove();
一抛异常,后面的 remove() 根本执行不到。
所以一定要写进 finally。
坑 4:在线程池任务提交前后传播上下文不完整
比如主线程里有 traceId,你提交到线程池的任务也想带过去。
很多人会这么写:
executor.submit(() -> {
log.info("traceId={}", TRACE_ID.get());
});
结果发现线程池里是 null,或者更糟,读到旧值。
因为线程池线程不是当前线程,ThreadLocal 不会自动传播。
如果确实要跨线程传递上下文,要么:
- 显式把参数传进去
- 要么在任务包装时复制、设置、清理
而不是指望 ThreadLocal 自动帮你传。
坑 5:把大对象放进 ThreadLocal
有时为了图省事,会把整套上下文对象放进去:
ThreadLocal<Map<String, Object>> CTX = new ThreadLocal<>();
甚至里面还挂:
- 用户权限树
- 大量缓存数据
- 大 JSON
- 连接对象/流对象
一旦泄漏,后果会非常明显。
ThreadLocal 更适合放轻量、短生命周期、明确边界的小对象。
坑 6:使用 static ThreadLocal 却没有生命周期治理
static final ThreadLocal 本身不是问题,很多上下文工具类都这么写。
真正的问题是:
- 全局可访问
- 使用点分散
- 很容易只写 set,不写 remove
所以如果用 static ThreadLocal,更要有统一规范和封装。
坑 7:误用 InheritableThreadLocal 在线程池中传参
这个前面提过,再强调一次:
- 它更适合“新建子线程时继承”
- 不适合线程池复用线程的场景
在线程池里,它经常不是“继承失效”,就是“继承脏数据”。
止血方案
如果线上已经出现串值或内存上涨,优先做止血,而不是先做“大重构”。
方案 1:所有 ThreadLocal 使用点加 finally remove
这是第一优先级,收益最大。
可以先从高风险点下手:
- 用户上下文
- 租户上下文
- traceId / MDC
- 数据源路由上下文
方案 2:在线程池入口统一包装任务
如果项目里线程池提交点比较集中,可以统一做代理:
public class SafeRunnable implements Runnable {
private final Runnable delegate;
private final String traceId;
public SafeRunnable(Runnable delegate, String traceId) {
this.delegate = delegate;
this.traceId = traceId;
}
@Override
public void run() {
try {
TraceContext.set(traceId);
delegate.run();
} finally {
TraceContext.clear();
}
}
}
这样比要求每个业务开发都记得清理更可靠。
方案 3:临时缩短线程存活不是根治
有些场景里,重建线程池或缩短线程存活时间能缓解泄漏,但这只是缓解,不是根治。
根因还是:上下文没有被正确清理。
方案 4:必要时改为显式参数传递
如果上下文只在几层调用链里使用,其实直接传参更清晰:
service.process(userId, traceId);
不要为了“代码看起来优雅”而过度依赖 ThreadLocal。
很多时候,显式传参比隐式上下文更可维护。
安全/性能最佳实践
这一节我尽量给“能落地”的建议。
1. ThreadLocal 的使用边界要非常明确
适合放的内容:
- 请求级 traceId
- 轻量用户标识
- 简单租户标识
- 临时格式化器实例等线程私有轻量对象
不适合放的内容:
- 大对象
- 可关闭资源(连接、流、会话)
- 跨异步链路的业务状态
- 需要长期缓存的数据
2. 永远配对:set/get/remove
推荐固定模板:
try {
CONTEXT.set(value);
// business logic
} finally {
CONTEXT.remove();
}
如果你在 code review 里看到 set() 没有在同一作用域内配到 finally remove(),就应该警觉。
3. 优先封装,不要散点使用
比如封装成:
ContextHolder.runWithContext(...)ContextAwareExecutor- Filter/Interceptor 基类
- AOP 统一切面
这样能把“清理责任”从业务代码中剥离出来。
4. 跨线程时优先显式传参
线程切换以后,ThreadLocal 的语义天然变弱。
如果链路中有:
ExecutorServiceCompletableFuture- MQ 消费线程
- 定时任务线程
- Reactor / 异步框架
都要优先思考:是不是该传参,而不是 ThreadLocal?
5. 如果用了日志 MDC,也要同样清理
很多日志框架 MDC 底层也是 ThreadLocal 思路。
所以这类代码同样要注意:
try {
MDC.put("traceId", traceId);
// ...
} finally {
MDC.clear();
}
别只清自己的 ThreadLocal,忘了清日志上下文。
6. 做压测时加“串值检测日志”
我很建议在测试环境加一些断言式日志,例如:
- 当前线程名
- 当前用户 ID
- 当前 traceId
- 请求入口参数
如果一个请求里打印出不属于自己的上下文,很快就能暴露问题。
flowchart LR
A[提交任务前获取上下文] --> B[包装 Runnable/Callable]
B --> C[工作线程执行前 set]
C --> D[执行业务逻辑]
D --> E[finally 中 remove/clear]
E --> F[线程归还线程池]
一份实用检查清单
如果你准备对现有项目做一次 ThreadLocal 风险排查,可以直接按这份清单过:
代码检查
- 是否存在
ThreadLocal/InheritableThreadLocal - 每个
set()后是否都有finally remove() - 是否在线程池任务中使用了
ThreadLocal - 是否存在异步执行但未做上下文传递
- 是否把大对象或资源对象放入
ThreadLocal - 是否同时使用了 MDC 但未清理
运行时检查
- 线程池线程是否长期驻留
- traceId / userId / tenantId 是否偶发串值
- heap dump 中是否存在
ThreadLocalMap持有大对象 - 问题是否在高并发下更明显
- 重启后是否短暂恢复正常
总结
ThreadLocal 本身不是洪水猛兽,真正的坑在于:它和线程生命周期绑得太紧,而线程池又让线程活得太久。
所以一旦在线程池中使用 ThreadLocal,你要牢牢记住三件事:
- 它不会自动清理
- 它不会自动正确跨线程传播
- 线程复用会把问题放大成串值和泄漏
最实用的落地建议是:
- 所有
ThreadLocal访问都采用try/finally/remove - 在线程池入口统一包装上下文设置与清理
- 跨线程场景优先显式传参
- 不要把大对象、连接、流放进
ThreadLocal - 对日志 MDC、租户上下文、用户上下文做统一治理
如果你现在就要做一次排查,我建议先从这几个类开始搜:
UserContextTraceContextTenantContextDataSourceContextHolder- 所有自定义线程池包装器
很多线上“偶发串数据”的根因,最后都会落到一句很朴素的话上:
ThreadLocal 用完没 remove。
这句话听着简单,但真在线上踩到一次,你就会记很久。