在多线程并发编程中,传统的策略主要依靠排他锁 (如 synchronized)来保证数据安全,本质是**"时间换空间"(线程排队)。而 ThreadLocal 采取了"空间换时间"**的思路:它不共享数据,而是为每个线程提供独立的变量副本,彻底消除了锁竞争。
在正式分析原理前,先看 ThreadLocal<T> 类提供的四个核心"武器":
| 方法名 | 功能描述 |
|---|---|
void set(T value) |
将当前线程的本地变量设置为指定值。 |
T get() |
返回当前线程本地变量副本中的值。如果未设置,则触发初始化。 |
void remove() |
(极其重要) 物理移除当前线程的本地变量值,防止内存泄漏。 |
protected T initialValue() |
延迟加载方法。子类可重写此方法,用于在 get 时提供初始值。 |
static ThreadLocal withInitial(Supplier s) |
JDK 8 新增的函数式构造方法,优雅地定义初始值。 |
数据到底存在哪?(底层存储模型)
调用 ThreadLocal.set(user) 时,数据并不是存在 ThreadLocal 对象里,而是存在执行代码的线程对象 内部。每个 Thread 对象(每个线程)里都有一个成员变量:ThreadLocal.ThreadLocalMap threadLocals。
ThreadLocalMap 的底层是一个 Entry[] 数组。每一个 Entry 节点的结构非常特殊:
- Key :是一个弱引用(WeakReference) ,指向
ThreadLocal对象。 - Value :是一个强引用 ,指向你存入的业务对象(如
User)。
要彻底搞懂
ThreadLocal的安全隐患,必须先明确 Java 中的两种引用:
- 强引用 (Strong Reference): 最常规的引用。只要引用在,即便发生 OOM(内存溢出),垃圾回收器 (GC) 也绝对不回收。
- 弱引用 (Weak Reference): 极其脆弱。只要发生 GC,如果一个对象只剩下弱引用指向它,就会立刻被回收。
源码解析:
java
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // 这里的 value 是强引用
Entry(ThreadLocal<?> k, Object v) {
super(k); // Key 交给父类 WeakReference,变成了弱引用
value = v;
}
}
ThreadLocalMap 底层连链表和红黑树都没有,它就是一个纯粹的一维数组:Entry[] table。
当我们在代码里 new 出了多个不同的 ThreadLocal 对象(比如 TL_A, TL_B, TL_C),并向里面存值时,底层是通过计算各个 ThreadLocal 的 Hash 值,把它们分散放在数组的不同格子里,因此键值对可以是多个。
这就导致一个问题,比如我们为了图省事,直接在 Service 方法里定义了 ThreadLocal:
java
public void processOrder() {
// 每次请求进来,都在方法内部 new 一个"临时钥匙"
ThreadLocal<byte[]> orderContext = new ThreadLocal<>();
// 模拟存入 5MB 的订单上下文大对象
orderContext.set(new byte[1024 * 1024 * 5]);
// 执行业务逻辑...接下来调用的方法无需传这个byte[1024 * 1024 * 5]也可访问并修改
System.out.println("订单处理中...");
// 方法执行结束,局部变量 orderContext 销毁
}
我们来看这个请求结束后,JVM 内部发生的微观变化:
- 方法刚结束: 栈帧弹出,变量
orderContext销毁。此时,堆里的ThreadLocal对象身上只剩下一根绳子------来自ThreadLocalMap里的 Entry Key(弱引用)。 - GC 发生: 垃圾回收器扫过。它发现
ThreadLocal对象身上只有"弱引用",于是毫不留情地回收了它。 结果 :线程肚子里那个 Map 的格子变成了Entry(null, 5MB数据)。这就是僵尸格子。 - 内存溢出: 虽然 Key 变成了
null(弱引用的功劳,它释放了钥匙对象),但 Value(5MB数据)被 Entry 强行拽着,Entry 又被线程强行拽着。 由于是线程池 ,这个线程永远不死。这 5MB 数据就变成了"幽灵",既无法访问(因为 Key 没了),也无法回收。 后果 :200 个线程,每个请求漏 5MB,几分钟后服务器就会报出OutOfMemoryError。
为了破解上述僵尸格子的死局,我们需要从"钥匙"和"打扫"两个维度同时下手。
static final
我们将变量定义在类级别,确保它永远不会被 GC 意外回收。
Java
// static: 保证全局唯一,钥匙永远不会"自然风化"
// final: 保证钥匙不会被偷换
private static final ThreadLocal<byte[]> CONTEXT_HOLDER = new ThreadLocal<>();
try-finally remove(拒绝残留)
即使 Key 不丢,只要线程不关,Value 就一直在。必须手动清理。
Java
public void processOrder() {
try {
CONTEXT_HOLDER.set(new byte[1024 * 1024 * 5]);
// 业务逻辑...
} finally {
// 【核心】不论成功失败,走人时必须把格子彻底擦除
CONTEXT_HOLDER.remove();
}
}
底层逻辑 :remove() 会直接把整个 Entry 从数组里抹掉。它不仅清空了 Value,还把格子腾了出来。
JDK 开发者意识到僵尸格子的风险,因此在
set()、get()、rehash()等操作中加入了启发式扫描 :它会顺便检查附近的格子,如果发现Key == null,就顺手把 Value 也置为null帮助回收。警示 :这只是"随缘"的补偿机制,不能产生依赖。如果线程长期空闲且不再调用
ThreadLocal方法,清理动作就永远不会触发。