你真的会用ThreadLocal吗——原理篇

253 阅读11分钟

前言:从“怎么用”到“为什么”

上一篇《使用篇》,我们通过一个“月黑风高”的线上事故,了解了 ThreadLocal 的基本用法、核心API(set, get, remove),以及那个极其重要的最佳实践:用完一定要 remove() !我们还探讨了 initialValuewithInitialInheritableThreadLocal

相信你现在已经知道怎么用 ThreadLocal 了,也明白了不 remove() 可能导致线程复用时数据串了,甚至引发内存泄漏。

但是,你不好奇吗?🤔

  • ThreadLocal 变量明明是我们在类里定义的静态变量(通常是),它是怎么做到让每个线程都有一份独立副本的?数据到底存在哪儿了?

  • ThreadLocal 内部到底是什么结构?ThreadLocalMap 又是个啥玩意儿?

  • 为什么 ThreadLocal 的 key 要设计成弱引用(WeakReference)?这和内存泄漏有什么关系?

  • 即使 key 是弱引用,为什么忘了 remove() 还是可能内存泄漏?泄漏的根源到底在哪?

  • InheritableThreadLocal 又是怎么实现父子线程数据传递的?

如果你对这些问题充满了好奇,那么恭喜你,这篇《原理篇》就是为你准备的!

准备好了吗?我们要开始深入 ThreadLocal 的五脏六腑了!🚀

文章有点长,倒杯水,我们慢慢聊。

耐心看完,你一定有所收获。

2253.gif

正文

数据到底存在哪?解密 Thread 类里的 threadLocals

我们先来破除一个常见的误解:ThreadLocal 变量本身并不存储数据。它更像是一个钥匙 🔑 或者说是一个访问凭证

那真正存储数据的地方在哪里呢?答案是:在每个线程(Thread 对象)自己身上!

打开 Java Thread 类的源码(你可以用 IDE 很方便地查看),你会发现它有这样两个成员变量(不同 JDK 版本可能略有差异,但核心思想一致):

public class Thread implements Runnable {
    // ... 其他成员变量 ...

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null; // <--- 看这里!

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; // <--- 和这里!

    // ... 其他方法 ...
}

看到了吗?每个 Thread 对象内部都维护了一个 ThreadLocal.ThreadLocalMap 类型的变量 threadLocals(还有一个用于 InheritableThreadLocalinheritableThreadLocals)。

这才是关键!

当你调用 threadLocalVariable.set(value) 时,ThreadLocal 内部的逻辑大致是:

  1. 获取当前正在执行这个代码的线程 (Thread.currentThread())。
  2. 从这个线程对象中,拿到它的 threadLocals 这个 Map。
  3. 如果这个 Map 不存在(第一次使用 ThreadLocal),就为这个线程创建一个新的 ThreadLocalMap
  4. 当前的 ThreadLocal 实例本身作为 key,你要存储的 value 作为值,存入这个线程专属的 ThreadLocalMap 中。

当你调用 threadLocalVariable.get() 时:

  1. 获取当前线程。
  2. 拿到它的 threadLocals Map。
  3. 如果 Map 存在,就以当前的 ThreadLocal 实例本身作为 key,从 Map 中查找对应的 value 并返回。
  4. 如果 Map 不存在,或者 Map 里没有这个 key,就会(如果是第一次 get 且没 set 过)调用 initialValue()withInitial() 提供的逻辑来初始化一个值,存入 Map 并返回。

当你调用 threadLocalVariable.remove() 时:

  1. 获取当前线程。
  2. 拿到它的 threadLocals Map。
  3. 如果 Map 存在,就以当前的 ThreadLocal 实例本身作为 key,从 Map 中移除对应的键值对。

所以,ThreadLocal 的核心思想是: 数据并不存储在 ThreadLocal 实例中,而是存储在每个线程自己维护的一个 Map 里。ThreadLocal 实例仅仅扮演了一个定位这个 Map 中特定条目的钥匙角色。

这就完美解释了为什么每个线程通过同一个 ThreadLocal 变量 setget 的是不同的值——因为它们操作的是各自线程内部的 ThreadLocalMap

深入 ThreadLocalMap:一个特殊的“哈希表”

现在我们知道了数据存在线程的 threadLocals 这个 Map 里。那这个 ThreadLocalMap 又是什么样的呢?

它并不是我们常用的 java.util.HashMap,而是 ThreadLocal 类的一个静态内部类。它实现了一个定制化的哈希表,专门用于存储线程的局部变量。

我们来看看它的核心结构(简化版):

static class ThreadLocalMap {

    /**
     * The entries in this map extend WeakReference, using
     * its main ref field as the key (which is always a
     * ThreadLocal object). Note that null keys (i.e. entry.get()
     * == null) mean that the key is no longer referenced, so the
     * entry can be expunged from table. Such entries are referred to
     * as "stale entries" in the code.
     */
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value; // <--- 真正存储的值

        Entry(ThreadLocal<?> k, Object v) {
            super(k); // <--- key (ThreadLocal实例) 被包装成了弱引用!
            value = v;
        }
    }

    /**
     * The table, resized as necessary.
     * table.length MUST be a power of two.
     */
    private Entry[] table; // <--- 底层存储结构是数组

    /**
     * The number of entries in the table.
     */
    private int size = 0;

    // ... 其他方法,如 set(key, value), getEntry(key), remove(key), rehash(), expungeStaleEntries() ...
}

划重点:

  1. 底层结构是数组ThreadLocalMapHashMap 类似,底层也是用数组(Entry[] table)来存储数据。

  2. Entry 结构:数组里存的是 Entry 对象。每个 Entry 包含:

    • Key: 指向 ThreadLocal 实例的弱引用 (WeakReference<ThreadLocal<?>>)。
    • Value: 实际存储的用户数据(强引用)。
  3. 哈希冲突解决:它不像 HashMap 那样用链表或红黑树解决冲突,而是使用线性探测法(如果哈希到的位置被占了,就往后找空位)。

  4. Key 是弱引用:这是 ThreadLocalMap 最特殊、也是最关键的设计之一!我们马上详细解释。

关键设计:为什么 Key 是 WeakReference?

还记得什么是弱引用(WeakReference)吗?

当一个对象仅仅被弱引用指向时,如果发生垃圾回收(GC),无论当前内存是否充足,这个对象都会被回收掉

ThreadLocalMapEntryThreadLocal 实例作为 key,并且用 WeakReference 包装它。这样做有什么好处呢?

考虑这样一种情况:你在代码里创建了一个 ThreadLocal 实例,用它存了些数据。后来,你不再需要这个 ThreadLocal 实例了,把所有指向它的强引用都置为 null 了(比如方法执行完了,局部变量的引用没了;或者静态变量被重新赋值了)。

void someMethod() {
    ThreadLocal<MyData> localData = new ThreadLocal<>();
    localData.set(new MyData());
    // ... use localData ...

    // 当 someMethod 执行完毕,localData 这个强引用就消失了
} // 假设没有其他地方引用这个 ThreadLocal 实例了

如果 ThreadLocalMap 的 key 是强引用指向这个 ThreadLocal 实例,那么会发生什么?

  • 即使你的代码不再持有 ThreadLocal 实例的强引用,但只要这个线程还在运行(特别是线程池里的线程),线程的 threadLocals Map 里的 Entry 就会一直强引用着这个 ThreadLocal 实例(作为 key)。
  • 这就导致:这个 ThreadLocal 实例永远无法被 GC 回收!即使你再也用不到它了。如果这样的 ThreadLocal 实例不断创建(虽然不常见,但可能发生),就会造成内存泄漏

而使用了 WeakReference 作为 Key

  • ThreadLocal 实例在外部的强引用全部消失后,ThreadLocalMap 中的 Entry 对它的引用只是弱引用。
  • 当下一次 GC 发生时,这个 ThreadLocal 实例就可以被正常回收了!
  • 回收之后,ThreadLocalMap 中那个 Entry 的 key 就会变成 null (因为 WeakReference.get() 会返回 null)。

这种设计,在一定程度上避免了因为 ThreadLocal 对象本身无法回收而导致的内存泄漏。💡

内存泄漏的真正风险点:Value

等一下!既然 Key ( ThreadLocal 实例 ) 被设计成弱引用,可以在外部强引用消失后被 GC 回收,那为什么我们在《使用篇》里反复强调,remove() 还是会内存泄漏呢?泄漏的是什么?

关键在于 Entry 中的 value

看回 Entry 的定义:

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value; // <--- 这个是强!引!用!
    // ...
}

虽然 Entry 的 key (ThreadLocal 实例) 是弱引用,但是 Entryvalue (你 set 进去的那个对象) 却是强引用

现在,我们来描绘一下内存泄漏的完整过程:

  1. 线程 T 运行中,通过 threadLocalRef.set(someBigObject) 设置了一个值。线程 T 的 threadLocals Map 中创建了一个 Entry,其 key 是 threadLocalRef 的弱引用,value 是 someBigObject 的强引用。
  2. 后来,你的代码不再持有 threadLocalRef 的强引用了 (比如 threadLocalRef 是个局部变量,方法结束了)。
  3. 发生 GC,由于 threadLocalRef 只有来自 Entry 的弱引用指向它,threadLocalRef 这个 ThreadLocal 对象本身被回收了
  4. 此时,线程 T 的 threadLocals Map 中的那个 Entry,它的 key (WeakReference.get()) 变成了 null
  5. 但是!但是!但是! 这个 Entry 对象本身还存在于 threadLocals Map 的 table 数组中!并且,它依然强引用着你当初 set 进去的 someBigObject
  6. 只要线程 T 不结束(比如它是线程池里的核心线程,会一直存在),并且你没有调用 remove() 方法来把这个 Entrytable 数组中清理出去,那么这个 keynullEntry 就会一直存在,它引用的 someBigObject 也就永远无法被 GC 回收

这就是内存泄漏的根源! 💥

虽然 ThreadLocal 本身(钥匙)被回收了,但它锁在线程“保险箱”(ThreadLocalMap) 里的那个值 (value),因为对应的 Entry (锁孔和锁芯) 没被清理掉,导致这个值一直占用着内存。如果这样的 Entry 越来越多(比如每次请求都创建一个临时的 ThreadLocalset 值,但请求结束不 remove),最终就可能导致 OOM (Out Of Memory)。

expungeStaleEntries():亡羊补牢,但不够可靠

ThreadLocalMap 的设计者也意识到了这个问题。为了减轻内存泄漏的风险,他们在 ThreadLocalMapget(), set(), remove() 等方法中,会顺手检查一下 table 数组中的 Entry,如果发现某个 Entry 的 key 变成了 null(即对应的 ThreadLocal 对象被回收了),就会尝试清理掉这些“陈旧”的 Entrystale entries),这个清理过程叫做 expungeStaleEntries

这算是一种启发式的清理机制。但是,它并不能保证所有 key 为 nullEntry 都被及时清理:

  • 清理动作只在调用 get/set/remove触发,并且不一定会扫描整个 table
  • 如果一个线程执行完任务后被放回线程池,之后很长时间都没有再用到这个线程,或者后续的任务没有再操作这个 ThreadLocalMap 的同一个槽位,那么那个 key 为 nullEntry 可能就一直孤零零地待在那里,它引用的 value 也无法释放。

所以,依赖这种自动清理机制是不可靠的!

最安全、最稳妥的方式,就是在代码层面确保,当你不再需要 ThreadLocal 变量时,显式调用 remove() 方法!

remove() 方法会直接根据当前的 ThreadLocal 实例(作为 key)找到对应的 Entry,并将其从 table 中移除,同时触发一次 expungeStaleEntries 清理。这样就能确保与该 ThreadLocal 相关的 Entryvalue 都能被及时回收。

InheritableThreadLocal 的原理:创建时的拷贝

简单提一下 InheritableThreadLocal。它的原理相对简单:

  1. InheritableThreadLocal 的值是存在 Thread 对象的 inheritableThreadLocals 这个 ThreadLocalMap 里的。
  2. 父线程创建子线程时(在 Thread 的构造函数或 init 方法里),Thread 类会检查父线程的 inheritableThreadLocals 是否存在。
  3. 如果存在,子线程会创建一个新的 ThreadLocalMap,并将父线程 inheritableThreadLocals Map 中的所有 Entry 拷贝到这个新的 Map 中。注意,这里是浅拷贝,如果 value 是引用类型,父子线程共享同一个对象引用。
  4. 这个新的 Map 就成为了子线程的 inheritableThreadLocals

这就是为什么子线程能“继承”父线程的 InheritableThreadLocal 值。

线程池问题:在线程池场景下,线程是复用的。如果父任务设置了 InheritableThreadLocal 值,然后创建了一个子任务提交到线程池,子任务执行时可能会继承父任务的值。但当这个线程被回收并分配给下一个完全不相关的任务时,那个继承来的值可能还残留在线程的 inheritableThreadLocals Map 中,导致数据污染或内存泄漏。这就是为什么在复杂场景下,推荐使用阿里巴巴开源的 TransmittableThreadLocal (TTL),它能更好地处理线程池环境下的值传递问题。

结尾

好了,朋友们,关于 ThreadLocal 的这两篇故事,到这里就真的要和大家说再见了。

我们一起潜入了 JDK 源码的深处,顺藤摸瓜,找到了数据真正的“藏身之处”——每个 Thread 对象里的那个ThreadLocalMap。我们拆解了 ThreadLocalMap 的内部结构,理解了 WeakReference 的设计巧思,也看清了它为了防止自身(ThreadLocal 实例)内存泄漏所做的努力。

更重要的是,我们搞明白了为什么即使 Key 是弱引用,忘记 remove() 仍然可能导致内存泄漏——那是因为 EntryValue 的强引用,在 Entry 未被清理前,会一直阻止 Value 被回收。我们也了解了 expungeStaleEntries 这个“亡羊补牢”的机制,以及它为何不够可靠。

至此,上一篇反复强调的“必须 remove() ”,相信在你心中,已经不再仅仅是一个需要死记硬背的概念了。✅

故事讲完了,愿这段关于 ThreadLocal 的小小探索,能为你点亮明灯。

同时带着这份对原理的敬畏之心,也能在日常开发中,写出更健壮、更优雅、更经得起推敲的代码。

保重,朋友!我们下次再会!👋

2326.jpg