ThreadLocal 入门:它是怎么隔离数据的、为什么会导致内存泄漏
📌 先说结论
ThreadLocal就干一件事:
给每个线程存一份独立的变量副本,线程之间互不干扰。
典型用途:一次请求的上下文(当前登录用户、traceId),一个线程从头用到尾,不用到处传参。
但它有个必须知道的坑:在线程池里不清理,会导致内存泄漏 + 数据串号(脏数据)。实测会给你看。
一、先看它解决什么问题
1.1 场景:请求上下文到处传参
假设一个 Web 请求要记录"当前登录用户",你会怎么写?
// ❌ 方式一:参数一层层往下传,方法签名全被污染publicvoidhandle(StringuserId){service.doBiz(userId);// 传}publicvoiddoBiz(StringuserId){dao.query(userId);// 再传}// 中间每一层都要加这个参数,很啰嗦ThreadLocal的解法:
// ✅ 存一份"线程私有"的副本publicclassUserContext{privatestaticfinalThreadLocal<String>CURRENT_USER=newThreadLocal<>();publicstaticvoidset(StringuserId){CURRENT_USER.set(userId);}publicstaticStringget(){returnCURRENT_USER.get();}publicstaticvoidclear(){CURRENT_USER.remove();}}// 任意地方直接用,不用传参UserContext.set("张三");// ... 很多层调用之后 ...Stringuser=UserContext.get();// 还是"张三"这就是核心价值:同一个线程内,数据全局可访问,不用改所有方法的签名。
1.2 实测:线程之间确实隔离
ThreadLocal<String>tl=newThreadLocal<>();Threadt1=newThread(()->{tl.set("A的数据");System.out.println(tl.get());});Threadt2=newThread(()->{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.2ThreadLocalMap和普通HashMap的区别
| 点 | HashMap | ThreadLocalMap |
|---|---|---|
| 结构 | 链表 + 红黑树 | 纯开放地址法(数组 + 线性探测) |
key | 强引用 | ThreadLocal是弱引用 |
| 作用 | 通用存储 | 只是线程私有数据 |
key是弱引用——这是理解内存泄漏的关键,下一章讲。
三、内存泄漏坑(重点)
3.1 先看实测:线程池里的脏数据
这是最常出事的场景——线程池会复用线程,上一次任务的数据会留给下一次。
ThreadLocal<String>userTl=newThreadLocal<>();ExecutorServicepool=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 再看待测:清理之后
// 任务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 正确用法模板
privatestaticfinalThreadLocal<UserInfo>CURRENT_USER=newThreadLocal<>();// ✅ 用 try-finally 保证清理publicvoidhandleRequest(UserInfouser){try{CURRENT_USER.set(user);// ... 业务逻辑}finally{CURRENT_USER.remove();// 必须!}}两个规范:
ThreadLocal声明为static final(避免每次 new,也避免它本身成为泄漏源);set()之后一定在finally里remove()。
💡如果用 Spring:
RequestContextHolder已经帮你处理了,你可以直接用RequestContextHolder.getRequestAttributes(),不用自己维护。
四、InheritableThreadLocal(了解即可)
子线程想继承父线程的值,用InheritableThreadLocal:
InheritableThreadLocal<String>tl=newInheritableThreadLocal<>();tl.set("父线程的值");Threadchild=newThread(()->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 核心知识点和实战技巧!