ThreadLocal 内存泄漏:你的应用正在悄悄 OOM
那天凌晨 3 点,我们的订单服务挂了。不是流量打挂的,是自己把自己撑死的。
一、事故现场
凌晨 3 点,我被电话叫醒:
"订单服务 OOM 了,JVM 刚重启过,但是跑了两个小时又挂了。"
登上机器看了下 GC 日志:
rust
Full GC (Allocation Failure) [...] [Eden: 0.0B->0.0B(0.0B) Survivors: 0.0B->0.0B Heap: 1.8G->1.8G(2.0G)]
堆几乎占满了,Full GC 回收不动。2G 的堆,1.8G 赖着不走。
用 jmap -histo:live 看了一下,排第一的是:
kotlin
num #instances #bytes class name
1 12847 154016400 [B
2 12843 92361600 com.xxx.service.UserContext
jmap -histo:live 的输出要这样看:[B 是 byte 数组,包含 String、序列化对象等底层存储;UserContext 是业务对象本身。两者不是简单相加------UserContext 持有的字符串、权限列表等字段,其内部的 byte 数组已经计入 [B] 的 154MB 中。粗略估算,这 12843 个 UserContext 对象本身约占 88MB,其间接引用的 byte\[\] 也包含在 154MB 的 [B] 中,合计占用远超 154MB,而且还在涨。
我们整个系统只有 200 个并发用户,怎么会有 12843 个 UserContext?
排查之后发现:问题出在 ThreadLocal 上。说得更准确点,出在线程池 + ThreadLocal 的组合上。
二、背景:我们怎么用 ThreadLocal 的
我们的订单服务有一个 UserContext,保存当前登录用户的信息,在 Controller 层设置,在 Service 层读取:
java
public class UserContext {
private static final ThreadLocal<UserContext> CONTEXT =
new ThreadLocal<>();
private Long userId;
private String username;
private List<String> permissions; // 权限列表,比较大
public static void set(UserContext ctx) {
CONTEXT.set(ctx);
}
public static UserContext get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
在拦截器里设置和清理:
java
@Component
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
// 从 token 解析用户信息,设置到 ThreadLocal
UserContext ctx = parseToken(request.getHeader("Authorization"));
UserContext.set(ctx);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response, Object handler, Exception ex) {
UserContext.clear(); // 请求结束,清理 ThreadLocal
}
}
这套用法看起来没问题。Tomcat 的线程处理完请求后,afterCompletion 会把 ThreadLocal 清掉。
问题出在异步任务上。
三、真正的麻烦:线程池里的"脏数据"
问题 1:线程池复用 → ThreadLocal 脏数据
我们的订单服务有异步处理逻辑:
java
@Service
public class OrderService {
@Autowired
private ThreadPoolTaskExecutor executor;
public void createOrder(OrderDTO orderDTO) {
// 主线程里 UserContext 有值
UserContext ctx = UserContext.get(); // ✅ 正常
// 把上下文传给异步任务(标准 ThreadLocal 不会自动传递到子线程)
UserContext finalCtx = ctx;
executor.submit(() -> {
UserContext.set(finalCtx); // 手动设置到子线程
doAsyncWork();
// ⚠️ 如果这里忘了调 UserContext.clear()
// 这个线程执行完任务后,ThreadLocalMap 里还留着 finalCtx
});
}
}
问题在于:标准 ThreadLocal 不会把值自动传给子线程,所以很多人会像上面这样手动 set 进去。但 set 了之后忘记 remove(),线程回到池里,ThreadLocalMap 的数据就留下来了。
下一个任务复用这个线程时:
java
// 线程-1 先后执行了三个任务
// 任务A:用户张三的请求 → UserContext.set(张三的上下文) → 没有 remove()
// 任务B:用户李四的请求 → UserContext.get() → 拿到的是张三的数据!
// 任务C:用户王五的请求 → UserContext.get() → 还是张三的数据!
还有一种更隐蔽的情况:项目里如果用了 InheritableThreadLocal(或者用了 TTL 但没正确包装线程池),父线程的值会"继承"给子线程。线程池复用后,这个值跟着传递,造成跨用户的上下文混乱。
用户李四的操作,用的是张三的权限。 这已经不是 Bug 了,是安全漏洞。
问题 2:线程池 + 不 remove → 内存泄漏
这就是 OOM 的根因。
Tomcat 的线程数量有限(默认 200),而且每次请求结束拦截器会调 remove(),所以主线程不会泄漏。
但我们用了 ThreadPoolTaskExecutor,核心线程数 50,最大线程数 200。异步任务里没调 UserContext.clear()。
每次异步任务执行,UserContext 被设进线程的 ThreadLocalMap。任务跑完了,线程回到池里,ThreadLocalMap 里的数据没人管。
线程不销毁 → ThreadLocalMap 不销毁 → Entry 的 value 不释放 → 内存泄漏。
而且这个泄漏是渐进式的:同一个 ThreadLocal key 的旧值会被新值覆盖,但如果不同的拦截器/工具类用了不同的 ThreadLocal key,就各自泄漏各自的,互不影响。
四、为什么 ThreadLocal 会泄漏?底层原理
4.1 ThreadLocal 的存储结构
每个 Thread 对象内部有一个 ThreadLocalMap,是个自定义的哈希表。Key 是 ThreadLocal 对象的弱引用,Value 是你存进去的值(强引用):
php
Thread 对象
└── ThreadLocalMap
└── Entry[]
└── Entry extends WeakReference<ThreadLocal<?>>
├── key: ThreadLocal 对象(弱引用)──→ 可能被 GC 回收
└── value: 你存的对象(强引用)──→ 不会被 GC 回收
4.2 泄漏的两条路径
路径 1:ThreadLocal 实例本身被 GC 回收(key 变成 null)
以下代码演示了路径 1 的触发条件------注意,这种写法在真实项目里几乎不存在 ,因为所有人都是 private static final:
java
public void process() {
ThreadLocal<UserContext> tl = new ThreadLocal<>(); // 局部变量
tl.set(new UserContext(...));
// 方法执行完,tl 出栈,强引用消失
// 但 ThreadLocalMap 里 Entry 的 key 是弱引用,GC 会回收 key
// 此时 Entry 的 key 变成 null,但 value 还在!
// 这就是 "key 被 GC、value 孤儿" 的泄漏
}
⚠️ 这种场景在实际项目中比较少见,因为绝大多数项目的 ThreadLocal 都是
private static final的,不会出栈消失。路径 2 才是生产环境真正出事的地方。
路径 2(我们的情况):线程池中线程不销毁
线程池的线程是长期存活的。线程不销毁 → ThreadLocalMap 不销毁 → Entry 不回收 → value 不释放。就算 key 被 GC 了也没用,因为还有一条强引用链:
css
线程对象 → ThreadLocalMap → Entry[] → Entry.value(强引用)→ UserContext
4.3 JDK 的"自愈"机制
JDK 在 ThreadLocalMap 的 get()、set()、remove() 里,会顺带清理 key 为 null 的 Entry(把 value 也置为 null)。对应的方法叫 expungeStaleEntry。
但这个"自愈"有两个前提:
- 你得调用
get()/set()/remove()------如果你再也不碰这个 ThreadLocal,就不会触发清理 - 在线程池场景下,线程可能很久都不执行新的 ThreadLocal 操作------自愈不会及时发生
简单一句话:JDK 的弱引用 + 自愈是"兜底",不是"保证"。正确做法永远是主动
remove()。
五、解决方案
5.1 方案一:try-finally 确保清理(最基本)
java
executor.submit(() -> {
try {
UserContext.set(ctx); // 设置
doAsyncWork();
} finally {
UserContext.clear(); // 必须清理!
}
});
这是底线。 不管逻辑多复杂、有没有异常,finally 里必须调 remove()。
5.2 方案二:用自定义的 ThreadPoolTaskExecutor 统一清理
如果一个项目里到处都在写 try-finally,迟早有人会忘。不如在线程池层面统一处理:
java
@Component
public class ThreadLocalAwareTaskExecutor extends ThreadPoolTaskExecutor {
@Override
public void execute(Runnable command) {
// 捕获提交线程的 ThreadLocal 快照
Map<ThreadLocal<?>, Object> snapshot = captureThreadLocals();
super.execute(() -> {
try {
// 在工作线程中恢复
restoreThreadLocals(snapshot);
command.run();
} finally {
// 执行完清理所有 ThreadLocal
cleanAllThreadLocals();
}
});
}
private Map<ThreadLocal<?>, Object> captureThreadLocals() {
// 使用 TransmittableThreadLocal 的快照能力
// 或自己维护一个 ThreadLocal 注册表
Map<ThreadLocal<?>, Object> snapshot = new HashMap<>();
// 遍历注册的 ThreadLocal,get 当前值
return snapshot;
}
private void restoreThreadLocals(Map<ThreadLocal<?>, Object> snapshot) {
snapshot.forEach((tl, value) -> {
if (value != null) {
tl.set(value);
} else {
tl.remove(); // snapshot 中为 null,也要清理工作线程可能残留的旧值
}
});
}
private void cleanAllThreadLocals() {
// 遍历注册的 ThreadLocal,全部 remove
UserContext.clear();
// 其他 ThreadLocal 也在这里统一清理
TraceContext.clear();
// ...
}
}
5.3 方案三(推荐):用 TransmittableThreadLocal
阿里开源的 TransmittableThreadLocal(简称 TTL),就是干这个的。
java
// 把 ThreadLocal 换成 TransmittableThreadLocal
public class UserContext {
private static final TransmittableThreadLocal<UserContext> CONTEXT =
new TransmittableThreadLocal<>();
// set / get / remove 方法不变
public static void set(UserContext ctx) { CONTEXT.set(ctx); }
public static UserContext get() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}
然后包装线程池:
java
// 方式 1:用 TTL 的 Java Agent,无侵入
// 启动参数加 -javaagent:transmittable-thread-local-2.x.jar
// 方式 2:手动包装
ExecutorService executor = TtlExecutors.getTtlExecutorService(
rawExecutorService
);
// Spring Boot 中
@Bean
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setThreadNamePrefix("async-");
executor.setTaskDecorator(TtlRunnable::get); // ← 关键:TTL 包装
executor.initialize();
return executor;
}
TTL 做了三件事:
- 自动传递:任务提交时,将父线程的 ThreadLocal 值复制给工作线程
- 执行后恢复快照:任务跑完后,TTL 会把工作线程的 ThreadLocal 状态恢复到执行前的快照。如果工作线程此前是干净的,效果等同于清除;如果此前就有残留值(历史泄漏),TTL 会恢复那些残留值,不会帮你清掉
- 不干扰线程原有值:工作线程在接手任务之前如果已经有 ThreadLocal 值,TTL 会在任务执行前保存原值,任务结束后恢复
说白了:TTL 能保证本次任务设置的值不漏给下一个任务,但不会自动修线程池里已有的旧泄漏 。用了 TTL 仍然建议在
finally中调remove(),双保险。
六、排查 ThreadLocal 泄漏的方法
如果你怀疑项目里有 ThreadLocal 泄漏,照着这个步骤来:
6.1 确认泄漏
bash
# 1. 导出堆 dump
jmap -dump:live,format=b,file=heap.hprof <pid>
# 注意:-dump:live 会先触发一次 Full GC,可能暂时缓解泄漏现象
# 如果需要看到最真实的泄漏状态,可以用 -dump:format=b(不带 live)
# 2. 用 MAT 或 VisualVM 分析
# 搜索你怀疑的类(如 UserContext),看引用链
# 如果引用链是 Thread → ThreadLocalMap → Entry → 你的对象
# 那就是ThreadLocal泄漏
6.2 定位哪个 ThreadLocal 没清理
java
// 在运行时打印所有线程的 ThreadLocalMap
for (Map.Entry<Thread, StackTraceElement[]> entry :
Thread.getAllStackTraces().entrySet()) {
Thread thread = entry.getKey();
try {
// 通过反射获取 ThreadLocalMap
Field threadLocalsField = Thread.class.getDeclaredField("threadLocals");
threadLocalsField.setAccessible(true);
Object threadLocalMap = threadLocalsField.get(thread);
if (threadLocalMap != null) {
Field tableField = threadLocalMap.getClass().getDeclaredField("table");
tableField.setAccessible(true);
Object table = tableField.get(threadLocalMap);
int count = 0;
int length = java.lang.reflect.Array.getLength(table);
for (int i = 0; i < length; i++) {
if (java.lang.reflect.Array.get(table, i) != null) count++;
}
if (count > 0) {
log.warn("线程[{}] ThreadLocalMap 中有 {} 个 Entry",
thread.getName(), count);
}
}
} catch (Exception e) {
// 忽略反射异常
}
}
⚠️ 这段代码只能临时用用,别放生产环境常驻跑。几个注意点:
- 反射访问私有字段在 JDK 9+ 的模块系统下需要添加
--add-opens java.base/java.lang=ALL-UNNAMED启动参数;ThreadLocalMap的内部结构在不同 JDK 版本间可能变化,这段代码可能在某些版本上直接抛异常;- 这段代码只打印了 Entry 数量,没有打印具体的 ThreadLocal Key(即哪个 ThreadLocal 变量在泄漏),对定位问题帮助有限。
更靠谱的方式 :用
jmap -dump导出堆 dump,用 MAT 分析Thread → ThreadLocalMap → Entry的引用链,可以直接看到是哪个 ThreadLocal 的 value 在泄漏。
6.3 线上监控
真正有用的监控不是看线程池活跃数,而是看 set/remove 的调用比例。set 比 remove 多,就说明有任务没清理。
java
public class UserContext {
private static final ThreadLocal<UserContext> CONTEXT =
new ThreadLocal<>();
// 用 Micrometer 计数器
private static final Counter SET_COUNTER =
Metrics.counter("threadlocal.usercontext.set");
private static final Counter REMOVE_COUNTER =
Metrics.counter("threadlocal.usercontext.remove");
public static void set(UserContext ctx) {
CONTEXT.set(ctx);
SET_COUNTER.increment();
}
public static void clear() {
CONTEXT.remove();
REMOVE_COUNTER.increment();
}
}
在 Grafana 配告警:set 计数 - remove 计数 > 阈值,一旦 set 远多于 remove,说明有任务没调 clear()。
七、一张 CheckList:ThreadLocal 使用规范
| # | 检查项 | 常见错误 | 正确做法 |
|---|---|---|---|
| 1 | 是否调了 remove() |
忘记清理,依赖 GC | try-finally 中必须调 remove() |
| 2 | 线程池场景 | 线程复用导致"脏数据" | 用 TTL 传递上下文 + finally 清理 |
| 3 | 异步任务 | @Async 方法里 ThreadLocal 为空 |
配合 TTL 或手动传递 |
| 4 | ThreadLocal 存大对象 | 权限列表、JSON 字符串等 | 只存必要信息,大对象用后立即 remove() |
| 5 | 多个 ThreadLocal | 各自清理容易遗漏 | 统一注册表 + 线程池层面批量清理 |
| 6 | 线上监控 | OOM 了才知道 | 监控 set/remove 调用比例,差距过大就告警 |
八、总结
回到开头的事故:12843 个 UserContext 实例把堆撑爆了,根因就一条------线程池里的异步任务 set 了 ThreadLocal 但没 remove。
修复后我们做了三件事:
- 所有异步任务加
try-finally { UserContext.clear(); } - 把
ThreadLocal换成TransmittableThreadLocal,用TtlRunnable包装线程池 - 把 ThreadLocal 使用规范加进代码审查 CheckList
ThreadLocal 的 3 条规则,记住就行:
- 只要
set()了,就必须在finally里remove()- 线程池场景下,ThreadLocal 的值不会自动清理,得手动处理
- 需要跨线程传递上下文,用 TTL,别手动传参
你在项目里用过 ThreadLocal 吗?有没有遇到过"脏数据"或者 OOM 的问题?评论区聊聊 👇
这是「Java 生产环境踩坑实录」系列的第 3 篇。上篇聊了 @Transactional 的 7 个坑,第 1 篇聊了 Redis 分布式锁的正确姿势。
如果这篇文章帮到了你,点个「在看」,让更多人少踩坑 💪