ThreadLocal 如何实现“多个线程访问同一个变量”?——从 ThreadLocalMap 到线程隔离
ThreadLocal 如何实现“多个线程访问同一个变量”?——从 ThreadLocalMap 到线程隔离
面试指北 · 淘天
题意
这道题表面在问“多个线程访问同一个变量”,但这里有一个很容易被忽略的表述陷阱:ThreadLocal 并不是让多个线程读写同一份数据,而是让同一个 ThreadLocal 实例在不同线程中对应不同的 value。
面试官通常想确认几件事:
- ThreadLocal 的数据到底存在哪里,是存在 ThreadLocal 对象里,还是存在 Thread 对象里;
- 为什么多个线程调用同一个 ThreadLocal 的
set()/get()不会互相覆盖; - 是否了解
ThreadLocalMap的弱引用 key、线性探测、内存泄漏和remove()的必要性。
可以把 ThreadLocal 理解成一个“访问入口”或“线程本地变量的句柄”,真正存数据的是每个线程自己持有的 ThreadLocalMap。
结论
- 每个
Thread对象内部都有一个threadLocals字段,类型是ThreadLocal.ThreadLocalMap。 - 调用
threadLocal.set(value)时,实际是把threadLocal作为 key、value作为值,存进当前线程的threadLocals中。 - 调用
threadLocal.get()时,也是从当前线程的threadLocals中取值。 - 同一个 ThreadLocal 实例可以被多个线程共用,但它只是 key;不同线程对应不同的
ThreadLocalMap,因此 value 是各自独立的。 - ThreadLocal 解决的是“线程私有数据”的问题,不是“多线程共享同一份数据”的问题。真要共享数据,应该用锁、
volatile、原子类或并发容器。 - 使用完 ThreadLocal 后,尤其是在线程池场景下,推荐在
finally中调用remove(),避免内存泄漏和上下文串数据。
要点展开
1. 数据到底存在哪里
ThreadLocal 本身不是存储容器。真正的存储位置在线程对象内部:
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
// ...
}
也就是说,每个线程都有一张自己的 ThreadLocalMap。ThreadLocal 只是这张 Map 里的 key。
ThreadLocalMap 的 Entry 结构大致如下:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
这里有两个关键点:
- key 是 ThreadLocal 对象的弱引用;
- value 是强引用,指向真正存放的数据。
因此,同一个 ThreadLocal 实例在不同线程中表现为:
线程 A 的 Thread 对象
└─ threadLocals -> ThreadLocalMap
└─ Entry(key = 弱引用 -> 同一个 ThreadLocal 实例, value = A 的数据)
线程 B 的 Thread 对象
└─ threadLocals -> ThreadLocalMap
└─ Entry(key = 弱引用 -> 同一个 ThreadLocal 实例, value = B 的数据)
线程 A 和线程 B 虽然用的是同一个 ThreadLocal 对象作为 key,但它们的 value 分别保存在各自的 ThreadLocalMap 中,所以互不干扰。
2. set 和 get 的基本流程
set() 的核心逻辑可以简化为:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // 本质是 t.threadLocals
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
get() 的核心逻辑可以简化为:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
return (T) e.value;
}
}
return setInitialValue();
}
关键点在于:getMap(t) 取的是 t.threadLocals,也就是当前线程自己的 Map。因此:
- 线程 A 调
set(),数据进入线程 A 的ThreadLocalMap; - 线程 B 调
set(),数据进入线程 B 的ThreadLocalMap; - 两者即使使用同一个 ThreadLocal 实例,也不会写进同一张表。
get() 在第一次取不到值时,会触发 setInitialValue()。默认初始值是 null;如果使用 ThreadLocal.withInitial(...) 或重写 initialValue(),则可以指定初始值。
3. 为什么能实现线程隔离
线程隔离的本质不是 ThreadLocal 复制了数据,而是数据的归属不同:
- 同一个 ThreadLocal 实例相当于一把“钥匙”;
- 每个线程都有自己的“储物柜”,也就是
threadLocals; - 线程 A 用这把钥匙打开 A 的储物柜,线程 B 用同一把钥匙打开 B 的储物柜;
- 钥匙相同,但柜子不同,所以取出的东西不同。
因此,ThreadLocal 并不是让“多个线程访问同一个变量”,而是让“多个线程通过同一个 ThreadLocal 访问各自线程内的变量副本”。
4. ThreadLocalMap 的哈希冲突处理
ThreadLocalMap 不是普通的 HashMap。它没有链表,也没有红黑树,而是采用开放寻址法中的线性探测来解决哈希冲突。
ThreadLocal 内部维护了一个 threadLocalHashCode,计算槽位时通常类似:
int i = key.threadLocalHashCode & (table.length - 1);
如果目标槽位已经被其他 key 占用,就继续往后找下一个空槽;查找时也按同样规则向后探测,直到找到匹配的 key 或遇到空槽。
这种设计适合 ThreadLocalMap 通常不会存储太多 Entry 的场景,结构比 HashMap 更轻量。但阅读源码时要注意:它的 set()、getEntry()、remove() 都带有探测和清理过期 Entry 的逻辑。
5. 弱引用与内存泄漏
ThreadLocalMap.Entry 的 key 是弱引用,value 是强引用。这意味着:
- 如果 ThreadLocal 对象只被弱引用指向,没有其他强引用,它可能被 GC 回收;
- key 被回收后,Entry 中的 key 变成
null,但 value 仍然被 Entry 强引用着; - 如果线程本身长期存活,例如线程池中的核心线程,那么
Thread -> ThreadLocalMap -> Entry -> value这条引用链会让 value 无法被回收,形成内存泄漏风险。
ThreadLocalMap 在 set()、get()、remove() 等操作中会顺带清理一些 key 为 null 的过期 Entry,但这种清理不是“实时保证”的,不能替代主动 remove()。
所以实践中的推荐写法是:
try {
threadLocal.set(value);
// 业务逻辑
} finally {
threadLocal.remove();
}
尤其是在线程池场景下,线程会被复用,如果不清理,轻则上下文串数据,重则长期积累导致内存问题。
需要特别说明的是:ThreadLocal 对象通常声明为 private static final,这样多个线程可以复用同一个 key。但即使是 static final,也不代表不需要 remove(),因为 value 仍然可能随着线程长期存活而滞留。
6. 常见使用场景
-
线程不安全的对象按线程复用
例如SimpleDateFormat不是线程安全的,可以为每个线程维护一个独立实例,避免并发共享。不过更现代的做法也可以考虑DateTimeFormatter。 -
同一线程内的上下文传递
Web 请求中经常需要把用户 ID、追踪 ID、事务上下文等信息从入口层传到业务层。如果不想层层传参,可以放入 ThreadLocal,在当前请求线程内各层直接获取。 -
框架中的事务、会话、日志追踪
很多框架会借助 ThreadLocal 保存当前线程绑定的连接、事务状态或 MDC 日志上下文。
但要注意:ThreadLocal 只在当前线程内有效。跨线程、异步任务、线程池提交任务时,不会自动把父线程的 ThreadLocal 带过去。InheritableThreadLocal 也只能在创建子线程时复制一份,对于线程池复用场景并不总是适用。
易错点与对比
| 易错说法 | 正确理解 |
|---|---|
| ThreadLocal 让多个线程共享同一个变量 | ThreadLocal 让每个线程拥有各自独立的 value,共享的只是 ThreadLocal 这个 key 对象 |
| ThreadLocalMap 存在 ThreadLocal 对象里 | ThreadLocalMap 存在 Thread 对象里,ThreadLocal 只是 key |
| 用了弱引用就不会内存泄漏 | key 是弱引用,但 value 是强引用,线程长期存活时仍可能泄漏,需要 remove() |
| 线程池里不 remove 也没关系 | 线程会被复用,容易造成上下文串数据和 value 堆积 |
| ThreadLocal 可以解决共享变量的并发问题 | ThreadLocal 是避免共享,不是安全地共享;共享数据仍需锁、原子类或并发容器 |
| 只要用了 ThreadLocal,value 就一定线程安全 | ThreadLocal 隔离的是 value 的引用槽位;如果多个线程 set 了同一个可变对象,仍然会共享同一个对象 |
和锁对比:
synchronized/Lock解决的是“多个线程访问共享数据时如何互斥”;- ThreadLocal 解决的是“每个线程各存一份数据,从根上减少共享”;
- ThreadLocal 用空间换隔离,适合线程私有上下文;锁用阻塞换安全,适合必须共享的资源。
小结
- ThreadLocal 的底层依托于线程内部的
ThreadLocalMap存储数据; - 同一个 ThreadLocal 实例作为 key,实际 value 保存在当前线程自己的 Map 中;
- 它并不会让多个线程访问同一个变量,而是让每个线程拥有各自副本,从而实现线程隔离;
- 弱引用 key 降低了 ThreadLocal 对象本身无法回收的风险,但 value 仍可能滞留,线程池场景下应养成
remove()的习惯; - 需要共享数据时不要误用 ThreadLocal,应该选择锁、
volatile、原子类或并发容器。