前言:从“怎么用”到“为什么”
上一篇《使用篇》,我们通过一个“月黑风高”的线上事故,了解了 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()
时:
- 获取当前线程。
- 拿到它的
threadLocals
Map。 - 如果 Map 存在,就以当前的
ThreadLocal
实例本身作为 key,从 Map 中查找对应的 value 并返回。 - 如果 Map 不存在,或者 Map 里没有这个 key,就会(如果是第一次 get 且没 set 过)调用
initialValue()
或withInitial()
提供的逻辑来初始化一个值,存入 Map 并返回。
当你调用 threadLocalVariable.remove()
时:
- 获取当前线程。
- 拿到它的
threadLocals
Map。 - 如果 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
实例的强引用,但只要这个线程还在运行(特别是线程池里的线程),线程的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
实例) 是弱引用,但是 Entry
的 value
(你 set
进去的那个对象) 却是强引用!
现在,我们来描绘一下内存泄漏的完整过程:
- 线程 T 运行中,通过
threadLocalRef.set(someBigObject)
设置了一个值。线程 T 的threadLocals
Map 中创建了一个Entry
,其 key 是threadLocalRef
的弱引用,value 是someBigObject
的强引用。 - 后来,你的代码不再持有
threadLocalRef
的强引用了 (比如threadLocalRef
是个局部变量,方法结束了)。 - 发生 GC,由于
threadLocalRef
只有来自Entry
的弱引用指向它,threadLocalRef
这个ThreadLocal
对象本身被回收了。 - 此时,线程 T 的
threadLocals
Map 中的那个Entry
,它的 key (WeakReference.get()
) 变成了null
。 - 但是!但是!但是! 这个
Entry
对象本身还存在于threadLocals
Map 的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
,并将父线程inheritableThreadLocals
Map 中的所有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
的小小探索,能为你点亮明灯。
同时带着这份对原理的敬畏之心,也能在日常开发中,写出更健壮、更优雅、更经得起推敲的代码。
保重,朋友!我们下次再会!👋