ThreadLocal 深入解析:从第一性原理到源码

ThreadLocal 深入解析:从第一性原理到源码

问题的原点

多线程访问同一个变量时,需要同步——锁、CAS、volatile。但有一类场景,变量本身就是属于线程的,不需要共享:

  • 每个请求携带的用户上下文(UserHolder)

  • 每个线程自己的数据库连接 / Session

  • 每个线程独享的 SimpleDateFormat(非线程安全)

  • 每个线程的独立计数器

对这些场景加锁是过度同步——明明不需要共享,却让所有线程去竞争同一把锁。

ThreadLocal 的解法:不共享,就不需要同步。 每个线程持有自己的变量副本,读自己的、写自己的,从来不看别人的。

这个思路对应了并发编程中一个核心矛盾:"我同时需要多线程访问一个变量"和"我希望每个线程看到不同的值"这两个需求看上去矛盾,但 ThreadLocal 用"把你的私有数据挂在你自己的线程对象上"的方式解决了它。


直觉模型(大多数人的第一印象)

绝大多数人第一次接触 ThreadLocal 会建立一个直觉模型:

 ThreadLocal<String> tl = new ThreadLocal<>();
 tl.set("hello");         // 把 "hello" 放进 ThreadLocal
 String s = tl.get();     // 从 ThreadLocal 取出 "hello"

这个模型把 ThreadLocal 想象成一个持有变量的 Map——ThreadLocal 装数据

            ThreadLocal (装着各个线程的值)
            ┌──────────────────────────┐
            │ 线程 A → "hello"         │
            │ 线程 B → "world"         │
            │ 线程 C → "foo"           │
            └──────────────────────────┘

这个模型是错误的。 错在哪?ThreadLocal 本身不存任何线程的数据。数据是存在线程对象(Thread)自己身上的。


正确的模型

 ThreadLocal<String> tl = new ThreadLocal<>();
 tl.set("hello");

这段代码的实际行为是:

                  Thread A                    Thread B
              ┌──────────────┐           ┌──────────────┐
              │ ThreadLocalMap│           │ ThreadLocalMap│
              │ ┌──────────┐ │           │ ┌──────────┐ │
              │ │ tl ─→ "hello"│ │         │ │ tl ─→ "world"│ │
              │ └──────────┘ │           │ └──────────┘ │
              ├──────────────┤           ├──────────────┤
              │ threadLocals │           │ threadLocals │
              └──────────────┘           └──────────────┘
                        ↑                       ↑
                  Thread.threadLocals       Thread.threadLocals

核心物理因果链:

  1. 真正承载数据的那个 Map(ThreadLocalMap),其物理内存是开辟在每一个单独的 Thread 实例堆内存里的。线程不死,这个 Map 就绝不对旁人开放,实现了物理隔离。

  2. 那么我们在代码里声明的那个 ThreadLocal tl = new ThreadLocal(); 扮演了什么角色?它仅仅扮演了这笔私有数据的“Key(工牌)”。

  3. 当你调用 tl.set("张三") 时,底层其实发生了这样一幕跨对象调用:

    • 内核先通过 Thread.currentThread() 抓到当前正在运行的线程肉身。

    • 然后直接撕开这个线程的肚皮,找到它里面的 threadLocals(那个 Map)。

    • 最后把当前的 tl 实例作为 Key,把 "张三" 作为 Value,塞进这个线程自己的 Map 里。

一针见血的结论: 数据的宿主是 ThreadThreadLocal 仅仅是一把跨对象去帮线程存取私有货物的“精确定位钥匙”。

每个 Thread 对象内部有一个 ThreadLocalMap 字段(叫 threadLocals)。tl.set(value) 的实际逻辑是:获取当前线程的 ThreadLocalMap,以 tl 自身为 key,value 为值,存进去。

 ThreadLocal.set(value) 的真实含义:
  当前线程.threadLocals.put(this, value)
 
 ThreadLocal.get() 的真实含义:
  当前线程.threadLocals.get(this)

ThreadLocal 只是"钥匙"(key),不是"容器"。 容器在 Thread 身上。

这个设计的直接推论:一个线程访问同一个 ThreadLocal 只能看到一个值——因为它访问的是它自己 threadLocals 里以这个 ThreadLocal 为 key 的条目。两个线程访问同一个 ThreadLocal,看到的是各自的条目,互相看不见。


ThreadLocalMap——一个从零手写的 HashMap

ThreadLocalMap 是 ThreadLocal 的内部类,不是继承 java.util.HashMap 的。它是一个专门为 ThreadLocal 场景定制的、开放地址法(open addressing)的哈希表。

 static class ThreadLocalMap {
     private Entry[] table;       // 哈希桶数组(不是链表!)
     private int size;            // 实际元素个数
     private int threshold;       // 扩容阈值(size 的 2/3)
     
     static class Entry extends WeakReference<ThreadLocal<?>> {
         Object value;
         Entry(ThreadLocal<?> k, Object v) {
             super(k);   // key 是弱引用
             value = v;
        }
    }
 }

为什么不用 HashMap(拉链法)而用开放地址法?

拉链法(HashMap):hash 冲突时,在桶位置挂一个链表/红黑树。

开放地址法(ThreadLocalMap):hash 冲突时,往下一个桶找空位(线性探测)。

 ThreadLocalMap 的 table(长度为 16):
 索引:   0   1   2   3   4   5   6   7   8   9   10   11   12   13   14   15
      ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
      │   │ tlA │   │   │ tlC │   │ tlB │   │   │   │   │   │   │   │   │   │
      ├────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┼────┤
      │   │ valA│   │   │ valC│   │ valB│   │   │   │   │   │   │   │   │   │
      └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
                                ↑
                          计算出的索引 = 4,但被 tlA 占了
                          探测找到索引 6 的空位

为什么 ThreadLocalMap 不用拉链法?

开放地址法的优点:不需要额外的链表节点(Entry 就是节点本身),内存紧凑,缓存友好(Entry 数组连续存储,遍历时 CPU cache miss 少)。ThreadLocalMap 里 Entry 数量通常很少(大多数线程只使用几个 ThreadLocal),线性探测冲突概率低,用拉链法反而浪费。

拉链法的优点(HashMap 选它的原因):冲突严重时性能仍然稳定(链表/红黑树挂)。HashMap 是给通用场景用的,Entry 数量可以很大;ThreadLocalMap 是给每个线程自己用的,Entry 数量通常很小。 场景不同,选型不同。


哈希值——黄金分割 + 自增 ID

 // ThreadLocal 中
 private final int threadLocalHashCode = nextHashCode();
 
 private static AtomicInteger nextHashCode = new AtomicInteger();
 private static final int HASH_INCREMENT = 0x61c88647;  // 黄金分割数
 
 private static int nextHashCode() {
     return nextHashCode.getAndAdd(HASH_INCREMENT);
 }

0x61c88647 是黄金比例 φ ≈ 1.618 在 32 位空间的映射(2^32 * (1 - 1/φ))。用这个递增量生成的哈希值,在数组长度是 2 的幂时,分布极其均匀,冲突概率最小。这是 ThreadLocalMap 只用开放地址法(不用链)的另一个前提——哈希冲突少。 如果冲突多,开放地址法退化为 O(n),但 ThreadLocal 的设计从一开始就确保冲突尽可能少。

set 方法

 private void set(ThreadLocal<?> key, Object value) {
     Entry[] tab = table;
     int len = tab.length;
     int i = key.threadLocalHashCode & (len - 1);  // 计算桶位
 
     // 线性探测:从计算出的桶位开始,找空位或 key 相等的 Entry
     for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
         ThreadLocal<?> k = e.get();        // 拿到 key(弱引用,可能已被回收)
 
         if (k == key) {                     // 找到了 → 替换 value
             e.value = value;
             return;
        }
 
         if (k == null) {                    // key 已被 GC 回收(弱引用断了)
             replaceStaleEntry(key, value, i);  // 替换掉这个过期条目
             return;
        }
    }
 
     // 找到了空位
     tab[i] = new Entry(key, value);
     int sz = ++size;
     if (!cleanSomeSlots(i, sz) && sz >= threshold)  // 启发式清理 + 判断是否需要扩容
         rehash();
 }

get 方法

 private Entry getEntry(ThreadLocal<?> key) {
     int i = key.threadLocalHashCode & (table.length - 1);
     Entry e = table[i];
     if (e != null && e.get() == key)    // 直接在桶位命中
         return e;
     else
         return getEntryAfterMiss(key, i, e);  // 冲突了,线性探测
 }

线性探测——内存布局的连续性

 private static int nextIndex(int i, int len) {
     return ((i + 1 < len) ? i + 1 : 0);  // 环形向前找
 }

线性探测在 ThreadLocalMap 中天然适合,因为每个线程的 ThreadLocalMap 很小(一般不超过十几个 Entry),探测几步就找到空位了。


WeakReference——为什么 key 是弱引用

这是 ThreadLocal 设计中最容易被误解也最关键的部分。

 static class Entry extends WeakReference<ThreadLocal<?>> {
     Object value;
 }

Entry 的 key 是 WeakReference<ThreadLocal<?>>,意思是:这个 Entry 不会阻止 ThreadLocal 被 GC 回收。

为什么要设计成弱引用?

考虑强引用的情况:

 // 假设 Entry 用强引用
 void method() {
     ThreadLocal<String> tl = new ThreadLocal<>();  // 强引用
     tl.set("hello");
     // 方法结束,tl 出了作用域
     // 如果 Entry 是强引用 → Thread -> ThreadLocalMap -> Entry -> key(tl)
     // 这条引用链导致 tl 无法被 GC 回收!
 }

方法的栈帧结束后,tl 这个引用消失了。但如果 Entry 的 key 是强引用,Thread → ThreadLocalMap → Entry → key 这条引用链仍然存在——ThreadLocal 对象永远无法被 GC 回收。线程池里的线程是长期存活不销毁的,ThreadLocalMap 一直存在,强引用会导致 ThreadLocal 对象的内存泄漏

 强引用(假设情况):
    Thread → ThreadLocalMap → Entry → (强引用) ThreadLocal 对象
    方法结束后 tl 消失,但 Entry 强引用还在 → ThreadLocal 无法 GC → 泄漏
 
 实际:弱引用:
    Thread → ThreadLocalMap → Entry → (弱引用) ThreadLocal 对象
    方法结束后 tl 消失,下次 GC 弱引用断开 → Entry.key = null

用弱引用后:栈帧结束,tl 的强引用消失,ThreadLocal 对象在堆上只剩 Entry 的弱引用。下一次 GC 时,ThreadLocal 对象被回收,Entry 的 key 变为 null。 这就消除了 ThreadLocal 对象本身的内存泄漏。

弱引用解决的问题和引入的新问题

弱引用解决了"ThreadLocal 对象泄漏"的问题,但引入了新问题——value 泄漏

 GC 后:
  Entry.key = null(弱引用断了)
  Entry.value = 仍在(强引用存在!)
   
 引用链:
  Thread → ThreadLocalMap → Entry → value(强引用)
  这条链还在,value 无法被 GC

这个 Entry(key = null, value ≠ null)被称为 stale entry。如果线程生命周期很长(比如线程池中的线程),stale entry 会持续累积,最终撑爆内存。

ThreadLocal 的处理方式:在 set、get、remove 时尽力清理 stale entry,但不是即时清理。

 // 在 set 时如果探测过程中发现 stale entry,会触发清理
 private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
     // 向前扫描找到最早的 stale entry
     // 清理它并移动相关 Entry 重新探测
     // 同时调用 cleanSomeSlots 做启发式扫描
 }

最可靠的解决方案:主动调用 remove()。

 try {
     tl.set(value);
     // 使用...
 } finally {
     tl.remove();  // 务必在 finally 中清理
 }

remove() 删除当前线程的 ThreadLocalMap 中的整个 Entry(key 和 value 一起),彻底切断引用链,双重保护。


四种引用回顾与对比

 // 强引用:GC 永不回收(除非引用断了)
 Object obj = new Object();
 
 // 软引用:内存不足时回收
 SoftReference<Object> sr = new SoftReference<>(new Object());
 
 // 弱引用:下次 GC 就回收
 WeakReference<Object> wr = new WeakReference<>(new Object());
 
 // 虚引用:最弱,get 永远返回 null,用于跟踪对象被回收
 PhantomReference<Object> pr = new PhantomReference<>(new Object(), queue);

ThreadLocalMap.Entry 的 key 用弱引用。value 是强引用,需要 remove 清理。


完整生命周期

 1. ThreadLocal<String> tl = new ThreadLocal<>();
    → ThreadLocal 对象创建,分配 threadLocalHashCode(自增黄金分割步长)
 
 2. tl.set("hello")
    → 获取当前线程 Thread.currentThread()
    → 获取该线程的 threadLocals(ThreadLocalMap)
      若为 null:创建 ThreadLocalMap,tl 为 key,"hello" 为 value
      若不为 null:以 tl 为 key,往 ThreadLocalMap 中 set
   
 3. tl.get()
    → 获取当前线程的 threadLocals
    → 以 tl 为 key 查找
    → 找到返回 value,找不到返回 null(或调用 initialValue)
   
 4. 方法结束,tl 引用消失
    → ThreadLocal 对象只剩 Entry 的弱引用
    → 下次 GC:ThreadLocal 对象被回收
    → Entry.key = null,value 还在
 
 5. 其他线程继续调 tl.set/get
    → 探测时遇到 key=null 的 stale entry
    → 触发清理(replaceStaleEntry / cleanSomeSlots)
    → 或者主动调 remove 清理

InheritableThreadLocal——跨线程传递

普通 ThreadLocal 只能在当前线程内传递取值。当你创建子线程时,子线程的 threadLocals 是空的——父线程在 ThreadLocal 里存的数据对子线程不可见。

 ThreadLocal<String> tl = new ThreadLocal<>();
 tl.set("parent-value");
 
 new Thread(() -> {
     System.out.println(tl.get());  // null!子线程看不到父线程的值
 }).start();

InheritableThreadLocal 解决了这个问题:

 InheritableThreadLocal<String> tl = new InheritableThreadLocal<>();
 tl.set("parent-value");
 
 new Thread(() -> {
     System.out.println(tl.get());  // "parent-value"
 }).start();

原理

Thread 对象有两个 ThreadLocalMap 字段:

 class Thread implements Runnable {
     ThreadLocalMap threadLocals;          // 普通 ThreadLocal
     ThreadLocalMap inheritableThreadLocals;  // 可继承 ThreadLocal
 }

创建子线程时,Thread.init() 方法会检查父线程的 inheritableThreadLocals 是否为空。不为空则将其拷贝到子线程的 inheritableThreadLocals 中:

 // Thread.init() 中的关键逻辑
 if (inheritThreadLocals && parent.inheritableThreadLocals != null) {
     this.inheritableThreadLocals =
         ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
 }

拷贝是浅拷贝——Entry 数组被复制了一份,但 key 和 value 指向的是同一个对象。父线程和子线程各自持有独立的引用但不共享 Entry 本身。

局限性

只支持第一次创建子线程时的传递。如果父线程在子线程创建后修改了 InheritableThreadLocal,子线程不会感知到。要支持后续传递,需要引入框架(如阿里的 TransmittableThreadLocal,TTL)。


从第一性原理收束 ThreadLocal

设计决策解决的根本问题为什么这样设计
数据存在 Thread 身上,不在 ThreadLocal 里避免共享变量,消除同步ThreadLocal 只是 key,Thread 才是容器——天生线程隔离
ThreadLocalMap 用开放地址法(非拉链法)小数据量下的紧凑、快速访问大多数线程只有几个 ThreadLocal,线性探测够用,缓存友好
哈希值用黄金分割递增量减少开放地址法的冲突概率0x61c88647 确保在 2 的幂数组上均匀分布
Entry 的 key 用 WeakReference防止 ThreadLocal 对象泄漏弱引用让 ThreadLocal 可以被 GC 回收,只有 value 仍需 remove
set/get 中途清理 stale entry缓解 value 泄漏尽力而为的清理,不是强保证
不自动调用 finalize 清理JDK 不保证 finalize 执行时机要求用户主动 remove()
InheritableThreadLocal 拷贝父子线程间传递上下文创建子线程时复制一份 Entry 数组给子线程

最终总结:ThreadLocal 不是一个"存储"数据的容器,而是一个在线程和它的私有数据之间建立映射关系的钥匙。它不做同步,因为根本没有共享——每个 Thread 有自己的 ThreadLocalMap,ThreadLocal 只是那把用来在 Map 中定位的钥匙。它的内存泄漏风险不是 bug,是"用弱引用解引用链"和"线程长期存活"这两个条件组合下的固有代价。用完调 remove() 是从源头切断这个代价的唯一可靠手段。

评论

此博客中的热门博文

我写了半年 prompt,最后发现最好的技巧就三个