前言:从“怎么用”到“为什么”
上一篇《使用篇》,我们通过一个“月黑风高”的线上事故,了解了 ThreadLocal 的基本用法、核心API(set, get, remove),以及那个极其重要的最佳实践:用完一定要 remove() !我们还探讨了 initialValue、withInitial 和 InheritableThreadLocal。
相信你现在已经知道怎么用 ThreadLocal 了,也明白了不 remove() 可能导致线程复用时数据串了,甚至引发内存泄漏。
但是,你不好奇吗?🤔
-
ThreadLocal变量明明是我们在类里定义的静态变量(通常是),它是怎么做到让每个线程都有一份独立副本的?数据到底存在哪儿了? -
ThreadLocal内部到底是什么结构?ThreadLocalMap又是个啥玩意儿? -
为什么
ThreadLocal的 key 要设计成弱引用(WeakReference)?这和内存泄漏有什么关系? -
即使 key 是弱引用,为什么忘了
remove()还是可能内存泄漏?泄漏的根源到底在哪? -
InheritableThreadLocal又是怎么实现父子线程数据传递的?
如果你对这些问题充满了好奇,那么恭喜你,这篇《原理篇》就是为你准备的!
准备好了吗?我们要开始深入 ThreadLocal 的五脏六腑了!🚀
文章有点长,倒杯水,我们慢慢聊。
耐心看完,你一定有所收获。
正文
数据到底存在哪?解密 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(还有一个用于 InheritableThreadLocal 的 inheritableThreadLocals)。
这才是关键!
当你调用 threadLocalVariable.set(value) 时,ThreadLocal 内部的逻辑大致是:
- 获取当前正在执行这个代码的线程 (
Thread.currentThread())。 - 从这个线程对象中,拿到它的
threadLocals这个 Map。 - 如果这个 Map 不存在(第一次使用
ThreadLocal),就为这个线程创建一个新的ThreadLocalMap。 - 将当前的
ThreadLocal实例本身作为 key,你要存储的value作为值,存入这个线程专属的ThreadLocalMap中。
当你调用 threadLocalVariable.get() 时:
- 获取当前线程。
- 拿到它的
threadLocalsMap。 - 如果 Map 存在,就以当前的
ThreadLocal实例本身作为 key,从 Map 中查找对应的 value 并返回。 - 如果 Map 不存在,或者 Map 里没有这个 key,就会(如果是第一次 get 且没 set 过)调用
initialValue()或withInitial()提供的逻辑来初始化一个值,存入 Map 并返回。
当你调用 threadLocalVariable.remove() 时:
- 获取当前线程。
- 拿到它的
threadLocalsMap。 - 如果 Map 存在,就以当前的
ThreadLocal实例本身作为 key,从 Map 中移除对应的键值对。
所以,ThreadLocal 的核心思想是: 数据并不存储在 ThreadLocal 实例中,而是存储在每个线程自己维护的一个 Map 里。ThreadLocal 实例仅仅扮演了一个定位这个 Map 中特定条目的钥匙角色。
这就完美解释了为什么每个线程通过同一个 ThreadLocal 变量 set 和 get 的是不同的值——因为它们操作的是各自线程内部的 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() ...
}
划重点:
-
底层结构是数组:
ThreadLocalMap和HashMap类似,底层也是用数组(Entry[] table)来存储数据。 -
Entry 结构:数组里存的是
Entry对象。每个Entry包含:- Key: 指向
ThreadLocal实例的弱引用 (WeakReference<ThreadLocal<?>>)。 - Value: 实际存储的用户数据(强引用)。
- Key: 指向
-
哈希冲突解决:它不像
HashMap那样用链表或红黑树解决冲突,而是使用线性探测法(如果哈希到的位置被占了,就往后找空位)。 -
Key 是弱引用:这是
ThreadLocalMap最特殊、也是最关键的设计之一!我们马上详细解释。
关键设计:为什么 Key 是 WeakReference?
还记得什么是弱引用(WeakReference)吗?
当一个对象仅仅被弱引用指向时,如果发生垃圾回收(GC),无论当前内存是否充足,这个对象都会被回收掉。
ThreadLocalMap 的 Entry 将 ThreadLocal 实例作为 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实例的强引用,但只要这个线程还在运行(特别是线程池里的线程),线程的threadLocalsMap 里的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 实例) 是弱引用,但是 Entry 的 value (你 set 进去的那个对象) 却是强引用!
现在,我们来描绘一下内存泄漏的完整过程:
- 线程 T 运行中,通过
threadLocalRef.set(someBigObject)设置了一个值。线程 T 的threadLocalsMap 中创建了一个Entry,其 key 是threadLocalRef的弱引用,value 是someBigObject的强引用。 - 后来,你的代码不再持有
threadLocalRef的强引用了 (比如threadLocalRef是个局部变量,方法结束了)。 - 发生 GC,由于
threadLocalRef只有来自Entry的弱引用指向它,threadLocalRef这个ThreadLocal对象本身被回收了。 - 此时,线程 T 的
threadLocalsMap 中的那个Entry,它的 key (WeakReference.get()) 变成了null。 - 但是!但是!但是! 这个
Entry对象本身还存在于threadLocalsMap 的table数组中!并且,它依然强引用着你当初set进去的someBigObject! - 只要线程 T 不结束(比如它是线程池里的核心线程,会一直存在),并且你没有调用
remove()方法来把这个Entry从table数组中清理出去,那么这个key为null的Entry就会一直存在,它引用的someBigObject也就永远无法被 GC 回收!
这就是内存泄漏的根源! 💥
虽然 ThreadLocal 本身(钥匙)被回收了,但它锁在线程“保险箱”(ThreadLocalMap) 里的那个值 (value),因为对应的 Entry (锁孔和锁芯) 没被清理掉,导致这个值一直占用着内存。如果这样的 Entry 越来越多(比如每次请求都创建一个临时的 ThreadLocal 并 set 值,但请求结束不 remove),最终就可能导致 OOM (Out Of Memory)。
expungeStaleEntries():亡羊补牢,但不够可靠
ThreadLocalMap 的设计者也意识到了这个问题。为了减轻内存泄漏的风险,他们在 ThreadLocalMap 的 get(), set(), remove() 等方法中,会顺手检查一下 table 数组中的 Entry,如果发现某个 Entry 的 key 变成了 null(即对应的 ThreadLocal 对象被回收了),就会尝试清理掉这些“陈旧”的 Entry(stale entries),这个清理过程叫做 expungeStaleEntries。
这算是一种启发式的清理机制。但是,它并不能保证所有 key 为 null 的 Entry 都被及时清理:
- 清理动作只在调用
get/set/remove时触发,并且不一定会扫描整个table。 - 如果一个线程执行完任务后被放回线程池,之后很长时间都没有再用到这个线程,或者后续的任务没有再操作这个
ThreadLocalMap的同一个槽位,那么那个 key 为null的Entry可能就一直孤零零地待在那里,它引用的 value 也无法释放。
所以,依赖这种自动清理机制是不可靠的!
最安全、最稳妥的方式,就是在代码层面确保,当你不再需要 ThreadLocal 变量时,显式调用 remove() 方法! ✅
remove() 方法会直接根据当前的 ThreadLocal 实例(作为 key)找到对应的 Entry,并将其从 table 中移除,同时触发一次 expungeStaleEntries 清理。这样就能确保与该 ThreadLocal 相关的 Entry 和 value 都能被及时回收。
InheritableThreadLocal 的原理:创建时的拷贝
简单提一下 InheritableThreadLocal。它的原理相对简单:
InheritableThreadLocal的值是存在Thread对象的inheritableThreadLocals这个ThreadLocalMap里的。- 当父线程创建子线程时(在
Thread的构造函数或init方法里),Thread类会检查父线程的inheritableThreadLocals是否存在。 - 如果存在,子线程会创建一个新的
ThreadLocalMap,并将父线程inheritableThreadLocalsMap 中的所有Entry拷贝到这个新的 Map 中。注意,这里是浅拷贝,如果 value 是引用类型,父子线程共享同一个对象引用。 - 这个新的 Map 就成为了子线程的
inheritableThreadLocals。
这就是为什么子线程能“继承”父线程的 InheritableThreadLocal 值。
线程池问题:在线程池场景下,线程是复用的。如果父任务设置了 InheritableThreadLocal 值,然后创建了一个子任务提交到线程池,子任务执行时可能会继承父任务的值。但当这个线程被回收并分配给下一个完全不相关的任务时,那个继承来的值可能还残留在线程的 inheritableThreadLocals Map 中,导致数据污染或内存泄漏。这就是为什么在复杂场景下,推荐使用阿里巴巴开源的 TransmittableThreadLocal (TTL),它能更好地处理线程池环境下的值传递问题。
结尾
好了,朋友们,关于 ThreadLocal 的这两篇故事,到这里就真的要和大家说再见了。
我们一起潜入了 JDK 源码的深处,顺藤摸瓜,找到了数据真正的“藏身之处”——每个 Thread 对象里的那个ThreadLocalMap。我们拆解了 ThreadLocalMap 的内部结构,理解了 WeakReference 的设计巧思,也看清了它为了防止自身(ThreadLocal 实例)内存泄漏所做的努力。
更重要的是,我们搞明白了为什么即使 Key 是弱引用,忘记 remove() 仍然可能导致内存泄漏——那是因为 Entry 对 Value 的强引用,在 Entry 未被清理前,会一直阻止 Value 被回收。我们也了解了 expungeStaleEntries 这个“亡羊补牢”的机制,以及它为何不够可靠。
至此,上一篇反复强调的“必须 remove() ”,相信在你心中,已经不再仅仅是一个需要死记硬背的概念了。✅
故事讲完了,愿这段关于 ThreadLocal 的小小探索,能为你点亮明灯。
同时带着这份对原理的敬畏之心,也能在日常开发中,写出更健壮、更优雅、更经得起推敲的代码。
保重,朋友!我们下次再会!👋