" />
文章

ThreadLocal 如何实现“多个线程访问同一个变量”?——从 ThreadLocalMap 到线程隔离

ThreadLocal 如何实现“多个线程访问同一个变量”?——从 ThreadLocalMap 到线程隔离

面试指北 · 淘天

题意

这道题表面在问“多个线程访问同一个变量”,但这里有一个很容易被忽略的表述陷阱:ThreadLocal 并不是让多个线程读写同一份数据,而是让同一个 ThreadLocal 实例在不同线程中对应不同的 value。

面试官通常想确认几件事:

  1. ThreadLocal 的数据到底存在哪里,是存在 ThreadLocal 对象里,还是存在 Thread 对象里;
  2. 为什么多个线程调用同一个 ThreadLocal 的 set() / get() 不会互相覆盖;
  3. 是否了解 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. 常见使用场景

  1. 线程不安全的对象按线程复用
    例如 SimpleDateFormat 不是线程安全的,可以为每个线程维护一个独立实例,避免并发共享。不过更现代的做法也可以考虑 DateTimeFormatter。

  2. 同一线程内的上下文传递
    Web 请求中经常需要把用户 ID、追踪 ID、事务上下文等信息从入口层传到业务层。如果不想层层传参,可以放入 ThreadLocal,在当前请求线程内各层直接获取。

  3. 框架中的事务、会话、日志追踪
    很多框架会借助 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、原子类或并发容器。
License:  CC BY 4.0