ThreadLocal 是 Java 后端里很常见的基础能力:traceId、用户上下文、事务资源、MDC......很多框架都在用它。但 ThreadLocal 也很容易"翻车",最典型就是两类问题:
- 看起来像内存泄漏(其实是"线程级常驻")
- 线程池复用导致数据串号
这篇文章不整复杂工程,只用最小示例让你看懂本质。
1. ThreadLocal 是什么?
一句话:
ThreadLocal = 每个线程一份的隐式变量 。
同一个线程里 set 的值,后面随处 get;不同线程互相隔离。
关键点:数据不存在线程外,而是存在 Thread 自己的一个 Map 里。
你可以想象成"每个线程都有个口袋":
set(x):把 x 放到当前线程口袋get():从当前线程口袋拿出来
2. 为什么不用普通变量?
- 局部变量 + 显式传参最安全(推荐),但工程里上下文常常跨很多层(日志、审计、鉴权、DAO),方法签名会爆炸。
- 成员变量 / static 变量并发下会串号(尤其 Controller 通常是单例,多个请求并发访问)。
ThreadLocal 的意义是:在不污染大量方法签名的情况下,提供线程级隔离的上下文。
3. 线程池复用为什么会导致"串号"?
核心原因只有一个:
线程池会复用同一个 Thread 执行多个任务。
如果你 set 了 ThreadLocal 却不 remove,下一个任务复用同一线程时就可能读到上一次残留值。
下面这个纯 Java 示例,能让你肉眼看到"串号"。
✅ 最小可运行示例:复用 + 不清理 = 串号
java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadLocalBleedDemo {
private static final ThreadLocal<String> TL = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
// 单线程池:保证每次都复用同一个线程,现象最清晰
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> doWork("Alice")).get();
pool.submit(() -> doWork("Bob")).get();
pool.submit(() -> doWork("Cindy")).get();
pool.shutdown();
}
private static void doWork(String user) {
String before = TL.get(); // 读到上一次残留(如果没清理)
TL.set(user);
String after = TL.get();
System.out.printf("[%s] before=%s, after=%s%n",
Thread.currentThread().getName(), before, after);
// ❌ 故意不 remove:下一次任务复用同一线程就会看到 before=上次的 after
// TL.remove(); // ✅ 打开它就不会串号
}
}
典型输出:
ini
[pool-1-thread-1] before=null, after=Alice
[pool-1-thread-1] before=Alice, after=Bob
[pool-1-thread-1] before=Bob, after=Cindy
看到没:这不是并发竞争,而是生命周期管理错误。
正确姿势(必背)
set 之后必须 finally remove
csharp
try {
TL.set(user);
// do work
} finally {
TL.remove();
}
4. ThreadLocal 为什么会"内存泄漏"?(更准确:线程级常驻)
先说结论:
ThreadLocal "泄漏"的不是 ThreadLocal 本身,而是线程长期持有的 value 释放不掉。
在线程池里,线程活得很久,所以 value 会"常驻",表现得像内存泄漏。
4.1 发生了什么(抓住关键结构)
在 JDK 里,ThreadLocal 数据的结构大致是:
-
每个
Thread有一个ThreadLocalMap -
Map 的 Entry 里:
- key 是 ThreadLocal 的弱引用(WeakReference)
- value 是强引用(Object value)
也就是:
ini
Thread
└── ThreadLocalMap
└── Entry(key=ThreadLocal弱引用, value=强引用的大对象)
4.2 为什么 key 弱引用了还会"泄漏"?
因为泄漏点在 value:
- 某些情况下 ThreadLocal 这个 key 没人引用了,会被 GC 掉(弱引用变 null)
- 但 Entry.value 仍然被 ThreadLocalMap 强引用着
- 线程(尤其线程池线程)不结束 → Map 不释放 → value 也释放不了
于是:你以为"请求结束对象就该回收",实际它挂在线程上一直在。
4.3 一个更直观的"常驻"例子
假设你把大对象塞进 ThreadLocal(比如 10MB 的 byte[] 或大 Map):
- 线程池有 200 个线程
- 每个线程都 set 过一次 10MB
- 即使业务早就结束,这些 value 仍可能长期挂在线程上
结果就是:
- 堆内存长期偏高
- GC 压力上升
- 极端情况下 OOM
4.4 如何避免(工程里就三条)
- 必须 finally remove(最重要)
- ThreadLocal 尽量
static final,不要到处new ThreadLocal() - ThreadLocal 里尽量别放大对象/重资源;确实要放就更要严格清理
5. 一句话总结
ThreadLocal 的问题不是并发,而是生命周期。
它绑定的是线程,不是请求。在线程池环境里,set 必须配 remove,否则串号和"线程级常驻"只是时间问题。