ThreadLocal 入门:它是怎么隔离数据的、为什么会导致内存泄漏
📌 先说结论
ThreadLocal 就干一件事:
给每个线程存一份独立的变量副本,线程之间互不干扰。
典型用途:一次请求的上下文(当前登录用户、traceId),一个线程从头用到尾,不用到处传参。
但它有个必须知道的坑:在线程池里不清理,会导致内存泄漏 + 数据串号(脏数据)。实测会给你看。
一、先看它解决什么问题
1.1 场景:请求上下文到处传参
假设一个 Web 请求要记录"当前登录用户",你会怎么写?
java
// ❌ 方式一:参数一层层往下传,方法签名全被污染
public void handle(String userId) {
service.doBiz(userId); // 传
}
public void doBiz(String userId) {
dao.query(userId); // 再传
}
// 中间每一层都要加这个参数,很啰嗦
ThreadLocal 的解法:
java
// ✅ 存一份"线程私有"的副本
public class UserContext {
private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
public static void set(String userId) { CURRENT_USER.set(userId); }
public static String get() { return CURRENT_USER.get(); }
public static void clear() { CURRENT_USER.remove(); }
}
// 任意地方直接用,不用传参
UserContext.set("张三");
// ... 很多层调用之后 ...
String user = UserContext.get(); // 还是"张三"
这就是核心价值 :同一个线程内,数据全局可访问,不用改所有方法的签名。
1.2 实测:线程之间确实隔离
java
ThreadLocal<String> tl = new ThreadLocal<>();
Thread t1 = new Thread(() -> { tl.set("A的数据"); System.out.println(tl.get()); });
Thread t2 = new Thread(() -> { tl.set("B的数据"); System.out.println(tl.get()); });
实测输出:
线程1 读到: A的数据
线程2 读到: B的数据
两个线程各自设了值,互不影响。 这就是"线程隔离"。
二、原理:数据到底存在哪
2.1 一句话原理
数据不是存在
ThreadLocal里,而是存在Thread自己身上。
这是最关键的一点------很多人误以为是"ThreadLocal 里存了一个 Map"。错了。
真实结构:
每个 Thread 对象内部有一个 ThreadLocalMap
│
└── key = ThreadLocal 对象(弱引用)
value = 你要存的数据
Thread-1
└── threadLocals (ThreadLocalMap)
├── entry1: key=TL_A(弱引用) → value="张三"
└── entry2: key=TL_B(弱引用) → value="男"
Thread-2
└── threadLocals (ThreadLocalMap)
├── entry1: key=TL_A(弱引用) → value="李四"
└── entry2: key=TL_B(弱引用) → value="女"
所以 ThreadLocal.get() 实际是:拿到当前线程 → 用当前线程的 ThreadLocalMap → 以这个 ThreadLocal 对象为 key 去查值。
💡 这也解释了为什么它是"线程隔离"的 ------每个线程查的是自己的 那个
ThreadLocalMap,天然不会串。
2.2 ThreadLocalMap 和普通 HashMap 的区别
| 点 | HashMap |
ThreadLocalMap |
|---|---|---|
| 结构 | 链表 + 红黑树 | 纯开放地址法(数组 + 线性探测) |
key |
强引用 | ThreadLocal 是弱引用 |
| 作用 | 通用存储 | 只是线程私有数据 |
key 是弱引用------这是理解内存泄漏的关键,下一章讲。
三、内存泄漏坑(重点)
3.1 先看实测:线程池里的脏数据
这是最常出事的场景------线程池会复用线程,上一次任务的数据会留给下一次。
java
ThreadLocal<String> userTl = new ThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1); // 只用一个线程复用
// 任务1:设置数据,但没清理
pool.submit(() -> {
userTl.set("用户张三");
System.out.println("任务1: " + userTl.get());
}).get();
// 任务2:没设置,却读到了任务1的残留!
pool.submit(() -> {
System.out.println("任务2 未设置,读到: " + userTl.get()); // ← "用户张三"
}).get();
实测输出:
任务1 设置: 用户张三, 当前 = 用户张三
任务2 未设置,读到 = 用户张三 ← 上一个任务的残留!(脏数据)
这就是数据串号 :任务 2 根本没设数据,却读到了上一个用户的用户名。
在生产里的后果 :用户 B 的请求,读到了用户 A 的登录信息------这是安全事故级别的 bug。
3.2 再看待测:清理之后
java
// 任务3:用完立刻清理
pool.submit(() -> {
try {
userTl.set("用户李四");
System.out.println("任务3: " + userTl.get());
} finally {
userTl.remove(); // ← 关键!
}
}).get();
// 任务4:读到 null,正确
pool.submit(() -> {
System.out.println("任务4 读到: " + userTl.get()); // ← null
}).get();
实测输出:
任务3 设置: 用户李四, 当前 = 用户李四
任务4 读到 = null ← 已清理,正确为 null
修复就一行:remove() 放 finally。
3.3 为什么会内存泄漏
根本原因:ThreadLocalMap 的 key 是弱引用,但 value 是强引用。
ThreadLocalMap 的 entry:
key = ThreadLocal 对象(弱引用) ← 可以被 GC 回收
value = 你存的数据 (强引用) ← 只要 entry 还在,就不会回收
泄漏链条:
① 你在方法里 new 了一个 ThreadLocal(局部变量,方法结束后没人引用它)
② ThreadLocalMap 里这个 entry 的 key 是弱引用 → 下一次 GC 时 key 被回收变成 null
③ 但 value 是强引用,还挂在 entry 上 → value 无法回收
④ 更糟的是:这个 entry 还挂在 Thread 的 ThreadLocalMap 上
→ 只要线程不死(线程池里的核心线程就不死),这个 value 就一直占着内存
所以:长期运行的线程池 + 大量临时 ThreadLocal = 内存持续增长。
💡 那为什么 JDK 不干脆用强引用?
因为如果 key 是强引用,ThreadLocal 对象永远无法被回收------那才是真泄漏。弱引用是权衡后的设计 ,但要求使用者自己
remove()。
3.4 正确用法模板
java
private static final ThreadLocal<UserInfo> CURRENT_USER = new ThreadLocal<>();
// ✅ 用 try-finally 保证清理
public void handleRequest(UserInfo user) {
try {
CURRENT_USER.set(user);
// ... 业务逻辑
} finally {
CURRENT_USER.remove(); // 必须!
}
}
两个规范:
ThreadLocal声明为static final(避免每次 new,也避免它本身成为泄漏源);set()之后一定在finally里remove()。
💡 如果用 Spring :
RequestContextHolder已经帮你处理了,你可以直接用RequestContextHolder.getRequestAttributes(),不用自己维护。
四、InheritableThreadLocal(了解即可)
子线程想继承父线程的值,用 InheritableThreadLocal:
java
InheritableThreadLocal<String> tl = new InheritableThreadLocal<>();
tl.set("父线程的值");
Thread child = new Thread(() -> System.out.println(tl.get())); // 能读到"父线程的值"
child.start();
但注意 :它只在"创建子线程"的那一刻复制一次 。线程池里的线程是复用的,所以不生效。
线程池里要传递上下文,得用别的方案:
- 阿里
TransmittableThreadLocal(TTL)------专为线程池设计; - 或者在提交任务时,手动把上下文打包传入。
五、ThreadLocal vs synchronized
经常有人混淆这两个,其实方向完全相反:
ThreadLocal |
synchronized |
|
|---|---|---|
| 思路 | 隔离:每个线程有自己的副本 | 同步:共享同一份,排队访问 |
| 解决问题 | 避免共享 | 控制共享访问 |
| 性能 | 高(无竞争) | 有锁开销 |
| 适用 | 每个线程需要独立状态 | 多个线程要共享状态 |
一句话:
不想共享 →
ThreadLocal;必须共享 → 加锁。
六、面试常问的 6 个问题
1. ThreadLocal 是什么?有什么用?
答:给每个线程提供独立的变量副本 ,线程之间互不干扰。典型用途是保存请求上下文(登录用户、traceId),避免在方法参数里一层层传。
2. ThreadLocal 的原理是什么?数据存在哪?
答:数据存在 Thread 对象里,不是存在 ThreadLocal 里 。每个 Thread 内部有一个 ThreadLocalMap,key 是 ThreadLocal 对象(弱引用),value 是数据。get() 时拿当前线程的 Map,用 ThreadLocal 做 key 去查。
3. ThreadLocal 会导致内存泄漏吗?为什么?
答:会。因为 ThreadLocalMap 的 key 是弱引用,value 是强引用 。ThreadLocal 对象被回收后 key 变成 null,但 value 仍是强引用,且 entry 还挂在 Thread 的 Map 上 ------只要线程不死(线程池的核心线程就不死),value 就一直占内存。解决办法:用完 remove()。
4. 线程池里用 ThreadLocal 会有什么问题?
答:数据串号 。线程池复用线程,上一个任务 set 的值会残留给下一个任务。实测:任务 1 设置"用户张三"没清理,任务 2 读到的是"张三"------生产环境会导致用户 A 的数据泄露给用户 B 。必须 finally 里 remove()。
5. ThreadLocal 和 synchronized 有什么区别?
答:方向完全相反 。ThreadLocal 是隔离 (每个线程有自己的副本,不需要同步);synchronized 是同步(多线程共享同一份,排队访问)。"不想共享用 ThreadLocal,必须共享用 synchronized"。
6. InheritableThreadLocal 有什么用?线程池里能用吗?
答:用于让子线程继承父线程的值 。但它只在创建子线程时复制一次 ,线程池里的线程是复用的,所以不生效 。线程池传上下文要用阿里 TransmittableThreadLocal(TTL)。
七、总结
四个要点:
- 是什么------给每个线程独立的变量副本,典型用于请求上下文。
- 存在哪 ------数据在
Thread的ThreadLocalMap里 ,key 是ThreadLocal(弱引用)。 - 怎么泄漏------key 弱引用被回收、value 强引用留着,线程池线程不死 → value 一直占内存。
- 怎么防 ------
static final声明 +finally里remove(),两条规范必须守。
一句口诀:
ThreadLocal 用得好是"上下文神器",用不好是"内存泄漏 + 数据串号"------差的就是一句
remove()。
如果本文对你有帮助,欢迎点赞 + 收藏 + 关注!如果疑问或补充,欢迎在评论区交流~
关注我,后续分享更多 Java 核心知识点和实战技巧!