背景与问题
ThreadLocal 是 Java 里一个“很好用,也很容易出事”的工具。
很多项目里都会这么干:
- 用
ThreadLocal保存当前登录用户 - 保存 traceId,串联日志
- 保存数据库路由上下文
- 保存租户 ID、灰度标识、语言环境等
单线程、短生命周期线程里,它确实省事。但一旦进入线程池场景,如果使用姿势不对,就会踩两个非常典型的坑:
- 内存泄漏
- 上下文串号
我第一次遇到这个问题,是线上日志里突然出现了非常诡异的现象:A 用户发起的请求,后续日志里偶尔带着 B 用户的 traceId。更离谱的是,堆内存还在缓慢上涨,Full GC 次数逐渐增多。
最后一路查下来,根因并不复杂:
请求处理结束后,没有清理 ThreadLocal,而线程池线程又会被复用。
这篇文章我不打算只讲“记得 remove()”这种口号,而是从:
- 为什么会泄漏
- 为什么会串号
- 怎么复现
- 怎么定位
- 怎么止血
- 怎么彻底修
带你完整走一遍。
背景场景图
flowchart LR
A[请求1 进入线程池线程 T1] --> B[ThreadLocal.set 用户A/traceA]
B --> C[业务执行]
C --> D[未调用 remove]
D --> E[线程 T1 归还线程池]
F[请求2 再次分配到线程 T1] --> G[读取 ThreadLocal]
E --> G
G --> H[读到旧值 用户A/traceA]
H --> I[出现上下文串号]
现象复现
先说最常见的两个现场症状。
1. 上下文串号
比如你把当前用户放到了 ThreadLocal:
- 请求 A 在线程池线程
pool-1-thread-1上执行,写入用户alice - 结束后没清理
- 请求 B 恰好复用了同一条线程
- 代码里又直接
get()当前用户 - 结果读到了
alice
这就是经典串号。
常见表现:
- 日志 traceId 错乱
- 用户 ID 偶发串到别人请求里
- 多租户场景下,租户隔离失效
- 灰度标识、权限上下文错乱
2. 内存泄漏
有些人会说:“串号我理解,但为什么会内存泄漏?”
原因是:
- 线程池线程是长生命周期
ThreadLocalMap挂在线程对象上- 只要线程不死,value 就可能一直留着
- 如果 value 还很大,或者引用了一串对象,那内存就被长时间占住
于是堆内存会表现出:
- Old 区缓慢增长
- Full GC 后仍回收不明显
- dump 后能看到很多业务上下文对象被线程引用
核心原理
先把底层关系讲透,不然后面的排查会像黑盒。
ThreadLocal 和 Thread 的关系
很多人以为值存在 ThreadLocal 对象里,其实不是。
真实情况更接近:
ThreadLocal只是“访问 key”- 真正的数据存在
Thread对象的ThreadLocalMap里
可以理解为:
Thread
└── ThreadLocalMap
├── Entry(key=ThreadLocal实例, value=你的上下文对象)
├── Entry(...)
└── Entry(...)
为什么会泄漏
ThreadLocalMap.Entry 的 key 是弱引用,value 是强引用。
这意味着:
- 如果外部没有强引用指向某个
ThreadLocal实例了 - GC 可以把这个 key 回收掉
- 但 value 不会自动立刻消失
- 只要线程还活着,这个 value 可能继续挂在 map 里,直到后续触发清理逻辑
这就是很多文章提到的“key 没了,value 还在”的情况。
为什么在线程池里更容易出事
因为线程池线程不是“用完即销毁”,而是会一直复用。
如果是每次请求都新建线程,即使你没 remove(),线程结束时整条线程对象也会被回收,问题还不至于长期积累。
但在线程池里:
- 线程常驻
ThreadLocalMap常驻- 脏数据也常驻
于是泄漏和串号都具备了长期存在的条件。
ThreadLocal 在线程池中的生命周期
sequenceDiagram
participant Req1 as 请求1
participant Pool as 线程池
participant T1 as 工作线程T1
participant TL as ThreadLocalMap
Req1->>Pool: 提交任务
Pool->>T1: 分配线程
T1->>TL: set(traceId=A)
T1->>T1: 执行业务
T1-->>Pool: 任务结束(未remove)
participant Req2 as 请求2
Req2->>Pool: 提交任务
Pool->>T1: 复用同一线程
T1->>TL: get()
TL-->>T1: 返回旧值 A
T1-->>Req2: 发生串号
核心原理:两类问题不要混为一谈
这两个问题经常一起出现,但本质不同。
串号的根因
同一线程被复用,而旧上下文未清理。
即使你的 ThreadLocal 是静态常量、永远不会被 GC,照样会串号。因为旧值还在。
泄漏的根因
长生命周期线程持有不再需要的 value。
尤其是这几种情况更危险:
- value 很大,比如缓存片段、大对象集合
- value 持有数据库连接、文件句柄等资源
ThreadLocal频繁创建,而不是定义成稳定的静态成员- 使用线程池,线程长期不销毁
所以排查时要分开看:
- 串号:看有没有清理、有没有跨线程传播失控
- 泄漏:看 value 是否长时间被线程引用、是否存在 stale entry
实战代码(可运行)
下面用一段可运行示例,先复现串号,再演示正确修复。
示例一:错误写法,复现上下文串号
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadLocalLeakDemo {
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(1);
// 第一个任务:写入用户,但不清理
executor.submit(() -> {
USER_CONTEXT.set("alice");
System.out.println(Thread.currentThread().getName() + " set user = " + USER_CONTEXT.get());
// 模拟业务执行结束,但忘记 remove()
}).get();
// 第二个任务:没有设置用户,直接读取
executor.submit(() -> {
String user = USER_CONTEXT.get();
System.out.println(Thread.currentThread().getName() + " get user = " + user);
if (user != null) {
System.out.println("发生串号:读到了上一个任务遗留的上下文");
}
}).get();
executor.shutdown();
executor.awaitTermination(3, TimeUnit.SECONDS);
}
}
运行结果示意
pool-1-thread-1 set user = alice
pool-1-thread-1 get user = alice
发生串号:读到了上一个任务遗留的上下文
这段代码虽然简单,但已经把问题本质暴露得很清楚了:
线程池只有一个线程,所以第二个任务复用了第一个任务的线程。
示例二:正确写法,用 try-finally 清理
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadLocalSafeDemo {
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(1);
executor.submit(() -> {
try {
USER_CONTEXT.set("alice");
System.out.println(Thread.currentThread().getName() + " set user = " + USER_CONTEXT.get());
} finally {
USER_CONTEXT.remove();
}
}).get();
executor.submit(() -> {
String user = USER_CONTEXT.get();
System.out.println(Thread.currentThread().getName() + " get user = " + user);
if (user == null) {
System.out.println("正常:上下文已清理,没有串号");
}
}).get();
executor.shutdown();
executor.awaitTermination(3, TimeUnit.SECONDS);
}
}
示例三:模拟“大对象残留”导致的内存风险
下面这个例子不保证在所有机器上都直观 OOM,但足以说明风险模式。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalMemoryRiskDemo {
private static final ThreadLocal<List<byte[]>> LOCAL = new ThreadLocal<>();
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
// 每个任务都放一些大对象进去
List<byte[]> data = new ArrayList<>();
for (int j = 0; j < 10; j++) {
data.add(new byte[1024 * 1024]); // 1MB
}
LOCAL.set(data);
// 业务结束后若不 remove,线程会一直持有这些对象
// LOCAL.remove();
});
}
executor.shutdown();
}
}
如果你把 remove() 注释掉,线程池里的工作线程会长期持有最后一次任务写入的大对象。
虽然不是“每个任务都无限叠加”,但对于常驻线程来说,这种残留就足够危险了。
定位路径:我是怎么一步步查出来的
排查这类问题,我建议按“现象 -> 线程 -> 上下文 -> 引用链”的顺序走,别一上来就怀疑 JVM。
第一步:先确认是不是线程复用导致的脏上下文
最实用的办法是加日志,打出:
- 线程名
- traceId / userId / tenantId
- 请求唯一标识
例如:
System.out.printf("thread=%s, requestId=%s, userId=%s%n",
Thread.currentThread().getName(),
requestId,
UserContextHolder.get());
如果你发现:
- 不同请求 ID
- 但线程名相同
- 且上下文值意外相同
基本就已经很像 ThreadLocal 未清理了。
第二步:全局搜 ThreadLocal 的 set/remove 是否成对出现
这个动作非常土,但极其有效。
直接全局搜索:
new ThreadLocal.set(.remove(
重点看这些位置:
- Filter
- Interceptor
- AOP 切面
- 自定义线程池任务包装器
- 异步回调
- MQ 消费逻辑
- 定时任务
常见坏味道:
contextHolder.set(xxx);
// 中间各种 return / throw
// 没有 finally remove
或者:
if (needSet) {
contextHolder.set(xxx);
}
// 某些分支没清理
第三步:看堆转储,找线程到 value 的引用链
如果怀疑内存泄漏,就上 dump。
可以用:
jmap -dump:live,format=b,file=heap.hprof <pid>
然后用 MAT 或者其他分析工具看:
- 可疑大对象是谁
- 是谁在持有它
- 引用链是否经过
java.lang.Thread - 是否能看到
threadLocals/ThreadLocalMap
典型引用链大概会长这样:
Thread
-> threadLocals
-> ThreadLocalMap
-> Entry
-> value
-> YourContextObject
如果你看到 value 是:
- 用户上下文对象
- trace 上下文
- 大集合
- byte[]
- 数据源路由对象
那基本就坐实了。
排查思路图
flowchart TD
A[发现现象: 串号/内存上涨] --> B{是否涉及线程池/异步}
B -- 是 --> C[检查 ThreadLocal 使用点]
B -- 否 --> D[先排除其他引用链]
C --> E[确认 set/remove 是否成对]
E --> F{有无 finally remove}
F -- 否 --> G[高概率根因]
F -- 是 --> H[检查异步传播/嵌套调用]
G --> I[复现问题并加线程名/请求ID日志]
H --> I
I --> J{是否仍异常}
J -- 是 --> K[导出 heap dump / 线程栈]
K --> L[查看 Thread -> ThreadLocalMap -> value 引用链]
J -- 否 --> M[验证修复并回归]
常见坑与排查
下面这些坑,我几乎都见过。
1. 只 set,不 remove
这是头号元凶。
错误示例:
public void handle(Request req) {
USER_CONTEXT.set(req.getUserId());
doBusiness(req);
}
正确姿势:
public void handle(Request req) {
try {
USER_CONTEXT.set(req.getUserId());
doBusiness(req);
} finally {
USER_CONTEXT.remove();
}
}
2. remove 写了,但没放 finally
很多代码“看起来”有清理,实际上异常一来就失效。
错误示例:
USER_CONTEXT.set(userId);
doBusiness();
USER_CONTEXT.remove();
如果 doBusiness() 抛异常,remove() 根本执行不到。
3. 在线程池父线程 set,子任务里以为能自动拿到
普通 ThreadLocal 不能跨线程传递。
ThreadLocal<String> local = new ThreadLocal<>();
local.set("trace-123");
executor.submit(() -> {
System.out.println(local.get()); // 大概率是 null
});
有些人改成 InheritableThreadLocal,以为就解决了。
但在线程池里,这通常也不靠谱,因为线程不是新建的,而是复用的,继承发生在线程创建时,不是在任务提交时。
4. 用 InheritableThreadLocal 在线程池里传上下文
这是另一个经典坑。
InheritableThreadLocal 的语义是“子线程创建时继承父线程值”,但线程池线程往往早就创建好了。结果就是:
- 有时拿不到预期值
- 有时拿到旧值
- 行为不稳定,很难排查
如果确实需要跨线程传播上下文,应该显式包装任务,或者使用成熟方案,而不是指望 InheritableThreadLocal 在线程池里“自动生效”。
5. 把大对象塞进 ThreadLocal
ThreadLocal 适合放轻量、短生命周期、明确边界的数据。
不适合放:
- 大集合
- DTO 树
- 文件内容
- 缓存块
- 数据库连接等重资源
否则即使没有严格意义上的无限泄漏,也会造成线程常驻大对象,占用堆空间。
6. 静态 ThreadLocal 没问题,不代表业务值没问题
很多人会误以为:
“我的
ThreadLocal是static final,key 不会被回收,所以没有泄漏。”
这话只说对了一半。
- 是的,静态
ThreadLocal可以减少 stale key 问题 - 但value 残留依然存在
- 线程池复用导致的串号也依然存在
所以 static final 不是免死金牌。
7. 框架层帮你 set 了,但业务层没意识到边界
例如:
- 日志 MDC
- 数据源切换
- 多租户上下文
- 安全上下文
这些框架很多底层就是 ThreadLocal。如果你在异步线程、线程池、自定义执行器里切换使用姿势,问题就会冒出来。
所以排查时别只盯你自己写的 ThreadLocal,也要看框架是否在用。
修复实践
说修复,不只是补一个 remove()。我更建议分成三层来做。
第一层:止血方案
最短路径是:
- 找到所有请求入口
- 在边界处统一清理上下文
- 给关键线程池任务加包装器
Web 请求入口统一清理
如果是 Web 项目,可以在 Filter / Interceptor 最外层做:
public class UserContextHolder {
private static final ThreadLocal<String> USER = new ThreadLocal<>();
public static void set(String userId) {
USER.set(userId);
}
public static String get() {
return USER.get();
}
public static void clear() {
USER.remove();
}
}
public class DemoFilter {
public void doFilter(String userId, Runnable chain) {
try {
UserContextHolder.set(userId);
chain.run();
} finally {
UserContextHolder.clear();
}
}
}
核心点只有一个:
在“请求边界”统一 set,在“请求结束”统一 clear。
第二层:线程池任务统一包装
如果项目里有大量异步任务,靠每个开发手写 try-finally 很容易漏。
可以包装 Runnable / Callable。
public class ContextAwareRunnable implements Runnable {
private final Runnable delegate;
private final String capturedUser;
public ContextAwareRunnable(Runnable delegate) {
this.delegate = delegate;
this.capturedUser = UserContextHolder.get();
}
@Override
public void run() {
try {
if (capturedUser != null) {
UserContextHolder.set(capturedUser);
}
delegate.run();
} finally {
UserContextHolder.clear();
}
}
}
使用方式:
executor.submit(new ContextAwareRunnable(() -> {
System.out.println("async user = " + UserContextHolder.get());
}));
这样能解决两个问题:
- 需要传播的上下文显式传入
- 任务结束后统一清理
第三层:建立规范,避免“自由发挥”
团队里最好明确以下规范:
ThreadLocal只能封装在 Holder 类中,禁止散落业务代码- 任何
set()必须对应try-finally remove() - 不允许往
ThreadLocal放大对象和资源对象 - 线程池异步任务必须使用统一包装器
- 代码评审时,把
ThreadLocal当高风险点检查
安全/性能最佳实践
这部分很重要,因为 ThreadLocal 的坑,不只是“功能错”,还可能带来安全和性能问题。
1. 上下文里不要放敏感数据原文
比如:
- 明文 token
- 身份证号
- 银行卡号
- 完整权限列表
原因很简单:
- 串号时可能泄露给其他请求
- dump 分析时会落到文件里
- 日志打印时容易误输出
建议:
- 只放必要标识,如 userId、traceId
- 敏感信息做脱敏或避免进入上下文
2. 值尽量轻量化
好的 ThreadLocal 值应该是:
- 小对象
- 不持有庞大引用图
- 生命周期清晰
例如:
String traceIdLong userId- 小型上下文 DTO
而不是塞一个“万能上下文对象”,里面挂一堆请求参数、缓存和服务实例。
3. 尽量缩短上下文作用域
不要一进请求就 set,一直到所有流程结束才清。
能缩小作用域的地方就缩小。
例如数据库路由上下文,只在切库操作前后包裹:
try {
DbContextHolder.set("slave");
query();
} finally {
DbContextHolder.clear();
}
而不是整条请求都挂着它。
4. 对线程池做统一治理
如果你们项目线程池多、异步链路复杂,建议:
- 统一封装线程池创建
- 统一任务装饰器
- 统一异常日志和上下文清理
- 禁止业务自行
Executors.newFixedThreadPool(...)
因为 ThreadLocal 问题本质上不是单点 bug,而是线程复用治理问题。
5. 压测时观察“上下文残留”
除了看吞吐和 RT,还应该加一些针对性验证:
- 同一线程连续处理不同用户请求时是否串号
- 压测后堆内存是否持续抬升
- dump 中是否存在明显的 ThreadLocal value 残留
这类问题在低并发测试里常常不明显,但在线上高复用、高异常率场景下很容易暴露。
一张原理图看懂“为什么 remove 必须有”
classDiagram
class Thread {
+ThreadLocalMap threadLocals
}
class ThreadLocalMap {
+Entry[] table
}
class Entry {
+WeakReference~ThreadLocal~ key
+Object value
}
class BusinessContext {
+String traceId
+Long userId
+String tenantId
}
Thread --> ThreadLocalMap
ThreadLocalMap --> Entry
Entry --> BusinessContext
关键理解:
- 线程池线程活得久
ThreadLocalMap跟着线程活得久- 不
remove(),value 就可能长期挂在线程上
一个更稳妥的落地模板
如果你在项目里想快速落地,我建议直接用这种模板。
Context Holder
public final class RequestContextHolder {
private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();
private RequestContextHolder() {
}
public static void set(RequestContext context) {
CONTEXT.set(context);
}
public static RequestContext get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
Context 对象
public class RequestContext {
private final String traceId;
private final Long userId;
public RequestContext(String traceId, Long userId) {
this.traceId = traceId;
this.userId = userId;
}
public String getTraceId() {
return traceId;
}
public Long getUserId() {
return userId;
}
}
边界统一控制
public class RequestHandler {
public void handle(String traceId, Long userId, Runnable bizLogic) {
try {
RequestContextHolder.set(new RequestContext(traceId, userId));
bizLogic.run();
} finally {
RequestContextHolder.clear();
}
}
}
这个模板的好处是:
- 使用入口统一
- 清理动作固定
- 便于以后替换实现
- 便于代码审计
什么时候不该用 ThreadLocal
这点我想单独说一句,因为很多坑并不是“用错了”,而是“本来就不该用”。
如果你的数据:
- 需要跨线程自然传递
- 需要异步链路稳定传播
- 需要可观测、可测试
- 生命周期复杂
那优先考虑:
- 显式参数传递
- 上下文对象作为方法参数
- 框架级上下文传播机制
- 任务装饰器而不是隐式 ThreadLocal 读取
ThreadLocal 适合解决“当前线程局部状态”问题,
不适合被当成“全局隐式上下文总线”。
总结
把这篇文章压缩成几句实战结论,就是:
- ThreadLocal 在线程池里最大的两个坑:上下文串号和内存残留。
- 串号的根因是线程复用后旧值未清理。
- 泄漏的根因是长生命周期线程长期持有无用 value。
- 真正有效的修复不是“记得 remove”,而是边界统一治理。
- 任何
set()都要放进try-finally,并在 finally 里remove()。 - 线程池异步场景不要迷信
InheritableThreadLocal。 - 不要把大对象、资源对象、敏感信息放进 ThreadLocal。
如果你现在怀疑项目里已经踩坑,我建议按这个顺序行动:
- 先全局搜
ThreadLocal - 再检查
set/remove是否成对 - 给请求入口和线程池任务补统一清理
- 对关键链路加线程名 + 请求 ID + 上下文日志
- 必要时抓 heap dump 看
Thread -> ThreadLocalMap -> value引用链
最后一句很实在的话:
ThreadLocal 不是不能用,但它属于那种“写起来很爽,收尾一定要认真”的工具。在线程池里,谁忘了清理,谁就迟早要为它买单。