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
核心物理因果链:
真正承载数据的那个 Map(
ThreadLocalMap),其物理内存是开辟在每一个单独的Thread实例堆内存里的。线程不死,这个 Map 就绝不对旁人开放,实现了物理隔离。那么我们在代码里声明的那个
ThreadLocal tl = new ThreadLocal();扮演了什么角色?它仅仅扮演了这笔私有数据的“Key(工牌)”。当你调用
tl.set("张三")时,底层其实发生了这样一幕跨对象调用:内核先通过
Thread.currentThread()抓到当前正在运行的线程肉身。然后直接撕开这个线程的肚皮,找到它里面的
threadLocals(那个 Map)。最后把当前的
tl实例作为 Key,把"张三"作为 Value,塞进这个线程自己的 Map 里。
Thread,ThreadLocal 仅仅是一把跨对象去帮线程存取私有货物的“精确定位钥匙”。每个 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,是"用弱引用解引用链"和"线程长期存活"这两个条件组合下的固有代价。
评论
发表评论