☰
详解ThreadLocal
2026/10/8 19:29:23 网站建设 项目流程

一、是什么

简单一句话:

ThreadLocal 给每个线程单独创建一份变量副本;

A 线程修改副本,不影响 B 线程。

ThreadLocal是线程本地变量,它可以在同一个线程内共享数据,线程之间互相隔离。

核心:

数据不是存在 ThreadLocal 对象里,而是存在当前 Thread 对象的threadLocals成员变量中。

ThreadLocal 只是一个存取这个 Map 的工具入口。

ThreadLocal 对象本身只是一个 “钥匙”,不存数据;

真正存数据的仓库,是每个 Thread(线程)对象自己身上自带的一个 Map(threadLocals)。

ThreadLocal<String> tl = new ThreadLocal<>();

这个tl只是一把钥匙,不是仓库。

每个线程,各自拥有一个独立仓库threadLocals。

调用tl.set("abc"):拿钥匙tl,往当前线程自己的仓库存入一对key=tl,value="abc"。


拆开看结构

## 举个简单例子串起来 ``` ThreadLocal<String> tl1 = new ThreadLocal<>(); ThreadLocal<String> tl2 = new ThreadLocal<>(); // 线程A tl1.set("A的数据1"); tl2.set("A的数据2"); // 线程A的threadLocals里面: // entry1: key=tl1, value="A的数据1" // entry2: key=tl2, value="A的数据2" String valA1 = tl1.get(); // 拿到 "A的数据1" String valA2 = tl2.get(); // 拿到 "A的数据2" // 线程B tl1.set("B的数据1"); // 线程B自己独立的threadLocals: // entry1: key=tl1, value="B的数据1" ``` String valB1 = tl1.get(); // 拿到 "B的数据1" String valB2 = tl2.get(); // null,线程B没有往tl2存过值 同一个 `tl1` 对象,在线程 A、B 各自的 Map 里,是两条完全独立的 entry,互不干扰。

重点:

  1. 数据value放在t.threadLocals,属于线程对象。
  2. ThreadLocal对象只是作为 map 的 key。
  3. 同一个 ThreadLocal 钥匙,在不同线程,访问的是不同仓库。

✅ 正确理解:每个线程自带独立 Map,ThreadLocal 只是 key。

顺着这个原理,解释几个面试点

1. 为什么线程销毁,ThreadLocalMap 就销毁?

因为threadLocals是 Thread 的成员变量。线程对象被 GC,这个 Map 跟着一起回收。

⚠️

但是!!!

只有线程对象被 GC 的时候,ThreadLocalMap才会跟着销毁。

线程池里的线程不会销毁,线程对象一直存活、反复复用,所以它里面的ThreadLocalMap对象就一直保存在线程对象身上,不会被回收。。

2. get () 的时候发生了什么

tl.get();
  1. 获取当前线程Thread.currentThread()
  2. 获取当前线程身上的threadLocalsmap
  3. 以this(tl对象)为 key 去 map 里查 value
  4. 返回 value

全程没有任何数据存在 tl 对象本身。


极简比喻

  • Thread= 每个人
  • threadLocals= 每个人随身带的小本子
  • ThreadLocal= 固定标签(比如:【用户信息】这个标签)

操作:

tl.set("张三")→ 在当前这个人的小本子上,找到【用户信息】标签,写下张三。tl.get()→ 在当前这个人的小本子上,读取【用户信息】标签内容。

不同的人(线程),各自有自己本子,同一个标签,写在不同本子上,互不影响。

标签(ThreadLocal)全局共用;本子(threadLocals)每人一份。

二、底层结构

Thread类内部:

ThreadLocalMap threadLocals = null;
  • ThreadLocalMap:是 Thread 的内部属性,不是 HashMap,是 ThreadLocal 自定义的哈希表。
  • key:弱引用,指向ThreadLocal对象
  • value:我们存入的业务数据(强引用)
Thread └── ThreadLocalMap ├── Entry[] table ├── Entry(WeakReference<ThreadLocal<?>> key, Object value)

弱引用的作用

Entry的 key 是WeakReference<ThreadLocal<?>>(弱引用): 当外部 ThreadLocal 引用被置为 null,GC 可以回收 ThreadLocal 对象。

如果 key 是强引用:即使外部 ThreadLocal 引用消失,因为 Entry 还持有强引用,ThreadLocal 对象无法 GC,造成内存泄漏。

⚠️但是!value 仍然是强引用!这就是内存泄漏根源!

  • key 被 GC 清除后,Entry 变成[null, value]
  • Thread 如果一直存活(如线程池),value 强引用不会释放,value 内存泄漏。

👉 所以必须手动调用remove()。

三、核心方法

  1. set(T value)
    • 获取当前线程Thread
    • 获取线程内的ThreadLocalMap
    • map.put (this, value),key 是当前 ThreadLocal 实例
  2. get()
    • 获取当前线程的 ThreadLocalMap
    • this 作为 key 查询 Entry,返回 value;没有则初始化,返回 null
  3. remove()
    • 获取当前线程 map,删除当前 ThreadLocal 对应的 Entry,清除 key 和 value,解决内存泄漏。

四、使用示例

public class ThreadLocalDemo { // 创建ThreadLocal实例 private static final ThreadLocal<String> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { new Thread(() -> { threadLocal.set("线程A的数据"); System.out.println(threadLocal.get()); // 线程A的数据 threadLocal.remove(); // 必须remove }).start(); new Thread(() -> { threadLocal.set("线程B的数据"); System.out.println(threadLocal.get()); // 线程B的数据 threadLocal.remove(); }).start(); } }

两个线程使用同一个threadLocal对象,但是各自存的数据互不干扰。

五、典型使用场景

  1. 传递链路上下文:比如用户登录信息、traceId,同一个线程的多个方法不用反复传参(拦截器设置,业务代码直接 get)。
  2. SimpleDateFormat / DateFormat:非线程安全,用 ThreadLocal 每个线程持有一个实例,避免并发异常。
  3. 事务管理:Spring 事务,Connection绑定到当前线程,保证同一个线程拿到同一个数据库连接。

六、两大坑点

1. 内存泄漏

原因:

  • ThreadLocalMap.Entry 的 key 弱引用,value 强引用。
  • 如果线程长期存活(线程池!),key 被 GC 后,value 还被 Entry 强引用,无法释放。

✅ 解决方案:用完必须调用 remove ()(即删除当前线程 ThreadLocalMap 里,以当前 ThreadLocal 对象为 key 的那个 Entry。)

Entry = 一条记录,包含两部分:key(弱引用指向 ThreadLocal 实例) + value(强引用指向你存的数据)

2. 线程池下的数据污染(值传递问题)

线程池会复用线程! 线程 A 执行完任务,没有 remove。下一个任务复用这个线程,直接 get 拿到旧的脏数据。

例子: 线程池线程 1 执行任务 1,set userId=100,没有 remove。 任务 2 过来复用线程 1,直接 get () 得到 userId=100,不是预期 null,造成数据污染。

✅ 解决方案:任务执行完毕,finally块调用remove()。

七、ThreadLocalMap 哈希冲突怎么解决?

HashMap 用链表 + 红黑树; ThreadLocalMap 使用线性探测: hash 冲突时,向后寻找下一个空槽位存放 Entry。

好处:结构简单;缺点:冲突多的时候性能下降。

扩容阈值:table 长度的 2/3。同时会清理 key 为 null 的 Entry,帮助释放内存。

八、ThreadLocal vs synchronized

ThreadLocalsynchronized
原理每个线程独立副本,不共享变量同一共享变量,加锁排队访问
侧重点空间换时间,线程隔离时间换空间,保证共享数据安全
场景线程私有数据,上下文传递多线程修改共享资源

一句话区分:

synchronized:大家抢同一个东西,排队用。

ThreadLocal:每人单独一份东西,互不干扰。

九、扩展

Q1:ThreadLocal 能不能被子线程继承?

不能。

子线程是新 Thread,threadLocals 是全新空 map。

如果需要:InheritableThreadLocal,可以把父线程的 ThreadLocal 值传递给子线程。

局限:线程池场景失效,因为线程复用,不会重新复制。

Q2:ThreadLocal 为什么 key 设计成弱引用?

如果 key 是强引用:ThreadLocal 引用置 null 后,ThreadLocalMap.Entry 还持有强引用,ThreadLocal 对象永远无法 GC,key 也内存泄漏。

弱引用保证 ThreadLocal 对象本身可以被 GC,只留下 value 泄漏风险,由 remove 解决。

Q3:ThreadLocal 推荐定义成 static final 吗?

推荐。

  • static:ThreadLocal 实例属于类,不需要每个对象新建;
  • final:防止 ThreadLocal 引用被重新赋值。

private static final ThreadLocal<User> USER_TL = new ThreadLocal<>();

Q4:什么时候用 InheritableThreadLocal?

主线程开启新线程,希望子线程自动拿到父线程的 ThreadLocal 数据。线程池场景慎用!线程复用,继承只会在线程创建时拷贝一次。


面试真题

题目

说一下 ThreadLocal 原理,内存泄漏问题,线程池场景会出现什么问题,如何避免?

参考答案

ThreadLocal 实现线程本地变量,数据存储在当前 Thread 对象内部的 ThreadLocalMap 中,key 是 ThreadLocal 实例,Entry 的 key 使用弱引用。

内存泄漏:Entry 的 value 是强引用,如果线程长期存活,key 被 GC 后 value 无法释放。

线程池场景风险:线程复用,如果任务执行完没有 remove,下一个任务拿到旧数据,造成数据污染。 解决:用完在 finally 中调用 remove ()。

面试题:ThreadLocal 代码改错

题目

下面这段代码,使用线程池 + ThreadLocal,请问存在什么问题?会出现什么现象?如何修复?

public class ThreadLocalBugDemo { private static final ThreadLocal<Integer> userIdTl = new ThreadLocal<>(); private static final ExecutorService pool = Executors.newFixedThreadPool(2); public static void main(String[] args) { for (int i = 1; i <= 5; i++) { int userId = i; pool.submit(() -> { userIdTl.set(userId); System.out.println("当前线程:" + Thread.currentThread().getName() + ", userId:" + userIdTl.get()); // 忘记 remove }); } } }

参考答案

问题:线程池复用线程,ThreadLocal 脏数据污染

线程池只有 2 个线程,会反复复用。

任务 1 在线程 1 设置 userId=1,任务执行完没有 remove。

任务 3 分配到线程 1,执行userIdTl.set(3)会覆盖,看起来没问题;

但是!如果某个任务分支逻辑没有执行 set,直接get(),就会读到上一个任务遗留的旧 userId。

举个极端场景:

pool.submit(() -> { // 某些业务判断,没有执行 set System.out.println(userIdTl.get()); // 拿到上一个任务残留的旧userId!! });

现象

部分任务读到不属于自己的 userId,业务逻辑错乱,很难复现的偶现 bug。

修复方案

任务执行完毕,finally 块调用 remove (),保证无论正常 / 异常退出,都清理 ThreadLocal:

pool.submit(() -> { try { userIdTl.set(userId); System.out.println("当前线程:" + Thread.currentThread().getName() + ", userId:" + userIdTl.get()); } finally { userIdTl.remove(); // 清理,防止脏数据 + 内存泄漏 } });

为什么会有内存泄漏?为什么 key 设计为弱引用还会泄漏?

回答:

ThreadLocalMap.Entry 的 key 是弱引用,value 是强引用。

当 ThreadLocal 引用置为 null,key 会被 GC,Entry 变成[null, value]。

线程池中的线程长期存活,Thread 对象不会被回收,Entry 还在 Map 里,value 强引用无法释放,造成 value 内存泄漏。

remove()会把整个 Entry 删除,key 和 value 都释放,同时解决脏数据和内存泄漏。

为什么 ThreadLocal 不能用在分布式场景传递用户信息?

ThreadLocal 是线程内本地变量,只在当前 JVM 当前线程有效。

一旦发生 RPC 调用,请求会到另一个服务,新开线程,ThreadLocal 数据丢失。

分布式链路用:header 传递(token/traceId)。


InheritableThreadLocal 面试题

题目

InheritableThreadLocal 的作用是什么?有什么局限性?线程池场景下还能用吗?

参考答案

作用:父线程创建子线程的时候,会把父线程InheritableThreadLocal的值拷贝一份给新建出来的子线程。

private static final InheritableThreadLocal<String> tl = new InheritableThreadLocal<>(); public static void main(String[] args) { tl.set("父线程数据"); new Thread(()->{ System.out.println(tl.get()); // 输出:父线程数据 }).start(); }

局限性:

  1. 只有子线程新建的时候才拷贝一次;后续父线程修改,子线程不会同步更新。
  2. 线程池场景失效!线程池线程是提前创建好的,不是每次任务新建线程。父线程提交任务时不会触发拷贝,子线程拿不到父线程的值。

拓展:

如果在线程池想要透传上下文,阿里开源的TransmittableThreadLocal(TTL),专门解决线程池场景上下文传递。

问:ThreadLocalMap 扩容和清理过期 Entry 的机制?

答:

ThreadLocalMap 初始容量 16,负载阈值是容量的 2/3。

每次 set 的时候,会扫描 table,清理 key 为 null 的 Entry(expungeStaleEntry);

清理过程会连带把 value 置空。

当元素数量达到阈值,执行 rehash,再次全量清理过期 entry,如果清理后仍然超过阈值,才执行扩容,容量翻倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询