背景与问题
ThreadLocal 是很多 Java 项目里“看起来很优雅”的工具:把用户信息、TraceId、租户 ID、数据库路由键塞进去,方法签名就能清爽不少。
但我自己在线上排查过几次事故后,对它的态度就变成了:能用,但一定要带着敬畏心用。
最典型的两个坑:
-
线程池复用导致上下文串值
- 上一个请求写进
ThreadLocal的值没清掉 - 下一个请求刚好复用到同一条线程
- 结果 A 用户看到了 B 用户的上下文,或者日志 TraceId 串了
- 上一个请求写进
-
线程长期存活导致内存泄漏
- 在线程池中,线程不是“用完就死”
ThreadLocal里的 value 可能一直挂在线程对象上- 如果 value 很大、或者关联大量对象,就会慢慢把堆吃满
很多人知道“用完要 remove()”,但真正出问题时,往往不是因为不知道这句话,而是:
- 不知道为什么会泄漏
- 不知道为什么在线程池里更容易出事
- 不知道怎么复现、怎么定位、怎么止血
这篇文章我就按“排障实战”的思路,带你走一遍。
背景现象复现
先看几个常见线上症状,你大概率会遇到其中一个:
- 日志里偶发出现错误的
TraceId - 同一个接口偶发读到别人的租户 ID / 用户 ID
- 老年代对象持续增长,Full GC 频繁
- 堆转储里能看到大量业务对象被
Thread引用链持有 - 重启服务后恢复正常,运行一段时间后又慢慢恶化
这些现象背后,很多时候都指向同一个组合:
ThreadLocal+ 线程池 + 未清理上下文
核心原理
先把原理讲清楚,不然后面的排查会像猜谜。
ThreadLocal 到底把值存哪了?
很多人下意识以为是“ThreadLocal 对象里存了线程对应的值”。其实不是。
更准确地说:
- 每个
Thread对象内部都有一个ThreadLocalMap ThreadLocal#set()时,会把当前线程作为宿主,把值存进这个ThreadLocalMapkey是ThreadLocal对象本身(准确说是弱引用)value是你放进去的对象
可以画成这样:
classDiagram
class Thread {
ThreadLocalMap threadLocals
}
class ThreadLocalMap {
Entry[] table
}
class Entry {
WeakReference~ThreadLocal~ key
Object value
}
class ThreadLocal
class UserContext
Thread --> ThreadLocalMap
ThreadLocalMap --> Entry
Entry --> ThreadLocal : weak key
Entry --> UserContext : strong value
关键点有两个:
- key 是弱引用
- value 是强引用
这两个点合起来,就是很多内存泄漏问题的根源。
为什么会内存泄漏?
假设你写了这样的代码:
new ThreadLocal<BigObject>().set(bigObject);
如果这个 ThreadLocal 实例本身没有外部强引用了,GC 后可能出现这种情况:
Entry.key == null- 但
Entry.value还在
因为 value 仍然被当前线程的 ThreadLocalMap 强引用着。只要线程不结束,这个 value 就可能一直活着。
而在线程池里,线程通常是长期存活的,所以问题特别明显。
为什么线程池里会串值?
因为线程池会复用线程。
请求 A 在 pool-1-thread-1 上执行时:
ThreadLocal.set(userA)- 忘了
remove()
请求 B 恰好也跑到 pool-1-thread-1:
- 如果代码里读取了同一个
ThreadLocal - 就可能直接读到
userA
这不是概率很低的“玄学 bug”,而是线程池的正常行为叠加上下文没清理的必然结果。
两类问题的执行路径
flowchart TD
A[请求进入线程池] --> B[线程执行任务]
B --> C[ThreadLocal set 上下文]
C --> D{任务结束是否 remove}
D -- 否 --> E[线程被归还线程池]
E --> F[下个请求复用同一线程]
F --> G[读到旧上下文 串值]
E --> H[线程长期存活]
H --> I[ThreadLocalMap 持有 value]
I --> J[对象无法及时释放]
J --> K[内存泄漏/GC压力上升]
D -- 是 --> L[上下文清理完成]
实战代码(可运行)
下面我们用一段简单代码,分别复现:
- 上下文串值
- 正确清理方式
示例 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_HOLDER = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> {
USER_HOLDER.set("user-A");
System.out.println(Thread.currentThread().getName() + " set user-A");
// 故意不清理
}).get();
pool.submit(() -> {
String value = USER_HOLDER.get();
System.out.println(Thread.currentThread().getName() + " read value = " + value);
}).get();
pool.shutdown();
pool.awaitTermination(3, TimeUnit.SECONDS);
}
}
预期输出
pool-1-thread-1 set user-A
pool-1-thread-1 read value = user-A
第二个任务明明没有 set,却读到了上一个任务的值,这就是典型串值。
这里故意把线程池大小设为 1,是为了稳定复现。在线上只是“线程复用”变成了不稳定复现而已,本质一样。
示例 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_HOLDER = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> {
try {
USER_HOLDER.set("user-A");
System.out.println(Thread.currentThread().getName() + " set user-A");
} finally {
USER_HOLDER.remove();
}
}).get();
pool.submit(() -> {
String value = USER_HOLDER.get();
System.out.println(Thread.currentThread().getName() + " read value = " + value);
}).get();
pool.shutdown();
pool.awaitTermination(3, TimeUnit.SECONDS);
}
}
预期输出
pool-1-thread-1 set user-A
pool-1-thread-1 read value = null
这才是线程池环境下 ThreadLocal 的基本用法。
示例 3:模拟“大对象不清理”的风险
这个例子不一定马上 OOM,但能说明问题:线程池线程长期不死,大对象就会长期挂着。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalLeakDemo3 {
private static final ThreadLocal<List<byte[]>> LOCAL = new ThreadLocal<>();
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(5);
for (int i = 0; i < 1000; i++) {
pool.submit(() -> {
List<byte[]> data = new ArrayList<>();
for (int j = 0; j < 5; j++) {
data.add(new byte[1024 * 1024]); // 1MB
}
LOCAL.set(data);
// 模拟业务逻辑
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 故意不 remove
});
}
// 演示用,不主动 shutdown,观察进程内存变化
}
}
如果你把它改成下面这样,风险会明显降低:
try {
LOCAL.set(data);
// do business
} finally {
LOCAL.remove();
}
定位路径:出问题时怎么查
真正棘手的不是“知道原理”,而是线上已经报警了,怎么一步步定位。
排查思路总览
sequenceDiagram
participant App as 应用线程池
participant Log as 日志/监控
participant JVM as JVM/GC
participant MAT as Heap Dump分析
Log->>App: 发现TraceId串值/用户上下文异常
Log->>JVM: 发现堆使用持续增长
JVM->>MAT: 导出Heap Dump
MAT->>App: 查看Thread对象引用链
MAT->>App: 定位ThreadLocalMap.Entry.value
App->>App: 回溯未remove的业务代码
1. 先从“现象”判断是串值还是泄漏
如果是串值,常见信号有:
- 某些请求日志里的 TraceId 不属于当前请求
- 用户 A 的请求带出了用户 B 的上下文
- 多租户场景下 SQL 跑到了错误的数据源
- 问题通常是“偶发”,重试可能消失
如果是泄漏,常见信号有:
- 堆使用率缓慢爬升
- 老年代回收效果差
- Full GC 次数增多
- 重启后恢复,运行一段时间再次出现
这两类问题经常是一起出现的,只是表现先后不同。
2. 日志里加线程名和上下文值
如果你怀疑上下文串值,第一步不要急着 dump 堆,先把日志打透。
比如打印:
System.out.printf("thread=%s, traceId=%s, userId=%s%n",
Thread.currentThread().getName(),
TRACE_ID.get(),
USER_ID.get());
观察有没有这种情况:
- 同一线程在不同请求之间,出现不应该继承的旧值
- 某次请求入口没有设置上下文,但业务层却读到了值
如果看到了,基本就能判断是清理缺失。
3. 查线程池包装层有没有兜底清理
我排查这类问题时,最常看的地方不是业务代码本身,而是这些公共层:
- 线程池任务提交封装
Runnable/Callable装饰器- Web Filter / Interceptor
- RPC Filter
- 异步框架回调封装
- 日志 MDC 透传组件
因为真正漏掉 remove() 的,往往不是“一个 set 一个 remove”这么简单,而是:
- 中间抛异常了
- 有多层
ThreadLocal - 某个分支 return 了
- 异步任务提交后复制了上下文,但没清空
4. 用 jmap / MAT 看堆里的引用链
如果怀疑内存泄漏,可以抓 Heap Dump 分析。
常见步骤:
jmap -dump:live,format=b,file=heap.hprof <pid>
用 MAT 打开后,重点关注:
java.lang.ThreadthreadLocalsThreadLocal$ThreadLocalMapThreadLocal$ThreadLocalMap$Entry- 你的业务对象是否被
value持有
典型引用链通常长这样:
Thread
-> threadLocals
-> ThreadLocalMap
-> Entry
-> value
-> YourBigObject
如果你发现:
Entry.key已经是null- 但
value很大
那就是典型的 stale entry 问题。
常见坑与排查
下面这些坑,都是我见过或者自己踩过的。
坑 1:只 set() 不 remove()
这是最直接也最常见的坑。
错误写法
public void process() {
USER_CONTEXT.set("u123");
doBusiness();
}
正确写法
public void process() {
try {
USER_CONTEXT.set("u123");
doBusiness();
} finally {
USER_CONTEXT.remove();
}
}
边界条件:set(null) 不等于 remove()。
set(null) 只是把值设为 null,Entry 还在;remove() 才会真正清理映射关系。
坑 2:在静态 ThreadLocal 上放大对象
比如:
- 大型 DTO
- SQL 结果集
- 文件内容
- 缓存对象
- 含外部连接的上下文对象
ThreadLocal 更适合放:
- 小型上下文标识
- TraceId
- 用户 ID
- 租户 ID
- 轻量级路由键
不适合放“重对象容器”。
坑 3:父线程设置了值,以为子线程也能安全继承
很多人会用 InheritableThreadLocal,想让子线程自动继承上下文。
这在线程池里尤其危险。
原因是:
- 线程池中的工作线程通常提前创建
- 不是“每次任务提交都新建子线程”
- 继承时机和你想象的不一样
- 还可能造成更隐蔽的串值
所以:
- 在线程池中,不要把
InheritableThreadLocal当作万能透传方案 - 需要上下文透传时,用明确的任务包装器,且执行后清理
坑 4:MDC、数据源路由、用户上下文分别各用一套 ThreadLocal,清理不一致
这是企业项目里非常常见的混乱场景:
- 日志 MDC 一套
- 数据源路由一套
- 用户信息一套
- 权限上下文一套
结果就是:
- 有的地方清理了
- 有的地方没清理
- 有的在 Filter 清
- 有的在 AOP 清
- 有的根本没人清
建议统一收口,做一个上下文生命周期管理器,至少保证:
- 设置入口统一
- 清理出口统一
- 异常路径统一
坑 5:异步任务复制上下文后忘记清理
比如你自己写了一个任务包装器:
public class ContextTask implements Runnable {
private final Runnable delegate;
private final String userId;
public ContextTask(Runnable delegate, String userId) {
this.delegate = delegate;
this.userId = userId;
}
@Override
public void run() {
USER_CONTEXT.set(userId);
delegate.run();
}
}
看起来做了上下文透传,但还是有问题:没清理。
应该这么写:
public class ContextTask implements Runnable {
private final Runnable delegate;
private final String userId;
public ContextTask(Runnable delegate, String userId) {
this.delegate = delegate;
this.userId = userId;
}
@Override
public void run() {
try {
USER_CONTEXT.set(userId);
delegate.run();
} finally {
USER_CONTEXT.remove();
}
}
}
止血方案
如果线上已经出问题,先别追求“最优雅”,先止血。
方案 1:统一在任务执行外层做 finally 清理
如果你们项目里线程池是统一封装的,可以直接包一层:
import java.util.concurrent.Executor;
public class CleaningExecutor implements Executor {
private final Executor delegate;
public CleaningExecutor(Executor delegate) {
this.delegate = delegate;
}
@Override
public void execute(Runnable command) {
delegate.execute(() -> {
try {
command.run();
} finally {
ContextHolder.clearAll();
}
});
}
}
配套的 ContextHolder:
public class ContextHolder {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void setUserId(String userId) {
USER_ID.set(userId);
}
public static String getUserId() {
return USER_ID.get();
}
public static void setTraceId(String traceId) {
TRACE_ID.set(traceId);
}
public static String getTraceId() {
return TRACE_ID.get();
}
public static void clearAll() {
USER_ID.remove();
TRACE_ID.remove();
}
}
这个方案的好处是:不用指望每个业务开发都记得手动 remove。
方案 2:Web / RPC 入口统一清理
在请求入口和出口统一做生命周期控制。
例如 Web Filter:
import javax.servlet.*;
import java.io.IOException;
public class ContextCleanupFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
} finally {
ContextHolder.clearAll();
}
}
}
注意这只能覆盖请求处理线程,如果内部又提交到线程池,异步任务仍然要单独处理。
方案 3:短期内无法彻底修复时,减少 ThreadLocal 存储内容
如果问题已经影响内存,短期止血可以先做:
- 只存 ID,不存大对象
- 上下文对象去掉大字段
- 避免缓存集合、字节数组、查询结果
- 拆成轻量级标识 + 显式查询
这不能替代 remove(),但能降低事故半径。
安全/性能最佳实践
这一节给“能落地”的建议,不讲空话。
1. 任何 ThreadLocal.set() 都要和 remove() 配对
最稳妥的模板就是:
try {
THREAD_LOCAL.set(value);
// business logic
} finally {
THREAD_LOCAL.remove();
}
不要侥幸,不要靠调用方记忆。
2. 优先把 ThreadLocal 当作“请求级轻量上下文”,不要当缓存
适合放:
- TraceId
- RequestId
- UserId
- TenantId
- Locale
不适合放:
- 大对象
- 可共享缓存
- 连接、流、会话资源
- 生命周期不清晰的业务对象
3. 在线程池场景下,优先做统一封装
比起让每个人自己写:
threadLocal.set();
try {
...
} finally {
threadLocal.remove();
}
更推荐:
- 封装线程池执行器
- 封装任务装饰器
- 封装上下文管理器
这样能把“规范”变成“机制”。
4. 对上下文透传要谨慎,特别是跨线程
如果业务允许,最安全的方式其实是:
- 显式传参
比如把 userId、traceId 作为方法参数传下去。
虽然代码啰嗦一点,但可读性和可控性通常更高。
ThreadLocal 更适合解决“横切关注点”,不适合替代正常的数据流。
5. 给关键 ThreadLocal 做命名和归档
实际项目中,最怕的是没人知道系统里有多少个 ThreadLocal。
建议做这些动作:
- 统一定义在
ContextHolder一类中 - 写清楚用途、设置入口、清理入口
- 禁止业务代码随手
new ThreadLocal<>() - 定期扫描
ThreadLocal使用点
这对排障帮助非常大。
6. 关注第三方框架也可能带来的 ThreadLocal 残留
别只盯着自己代码。常见框架也会使用 ThreadLocal,例如:
- 日志 MDC
- ORM / 数据源路由
- Web 请求上下文
- RPC 框架上下文
如果框架版本有 bug,或者接入姿势不对,也可能残留上下文。
所以排查时不要只搜自己项目代码,也要看依赖链。
一个推荐的落地模板
如果你们团队经常用上下文,我建议直接做成下面这种“统一入口、统一清理”的模式。
public final class RequestContext {
private RequestContext() {}
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
public static void setTraceId(String traceId) {
TRACE_ID.set(traceId);
}
public static String getTraceId() {
return TRACE_ID.get();
}
public static void setUserId(Long userId) {
USER_ID.set(userId);
}
public static Long getUserId() {
return USER_ID.get();
}
public static void clear() {
TRACE_ID.remove();
USER_ID.remove();
}
}
配合统一执行模板:
public class RequestContextRunner {
public static void runWithContext(String traceId, Long userId, Runnable task) {
try {
RequestContext.setTraceId(traceId);
RequestContext.setUserId(userId);
task.run();
} finally {
RequestContext.clear();
}
}
}
使用时:
public class DemoApp {
public static void main(String[] args) {
RequestContextRunner.runWithContext("trace-1001", 42L, () -> {
System.out.println(RequestContext.getTraceId());
System.out.println(RequestContext.getUserId());
});
}
}
这种方式虽然不花哨,但很稳定。
总结
ThreadLocal 本身不是洪水猛兽,真正危险的是:
- 在线程池中复用线程
- 业务把上下文写进去却不清理
- value 又比较大或者生命周期失控
你可以把这篇文章记成三句话:
- ThreadLocal 的值是挂在线程上的,不是挂在请求上的
- 线程池会复用线程,所以不清理就会串值
- key 是弱引用,value 是强引用,所以线程长期存活时可能泄漏
最后给你一组可执行建议:
- 所有
ThreadLocal.set()后面必须有finally remove() - 在线程池、异步任务、回调场景下尤其严格
- 不要在
ThreadLocal里放大对象 - 统一封装上下文管理和清理机制
- 出现串值先查日志和线程名,出现泄漏再抓 dump 看
ThreadLocalMap
如果你的系统已经在线上跑了很久,我真心建议你现在就全局搜一遍:
ThreadLocal
InheritableThreadLocal
MDC
set(
remove(
很多坑,都是“代码一直在那里,只是事故还没发生”。