ThreadLocal 与 CAS 原理
在 Java 并发编程中,除了使用锁(如 synchronized、ReentrantLock)来保证线程安全外,还有两种非常重要的无锁化/弱锁化技术:ThreadLocal(线程本地变量) 和 CAS(Compare And Swap,比较并交换)。本篇将深入剖析它们的底层实现、内存模型以及高频面试痛点。
一、 ThreadLocal 核心原理与内存泄漏
ThreadLocal 提供了线程隔离的局部变量,每个线程都可以通过 get() 和 set() 来访问属于自己的变量副本,从而避免了多线程竞争。
1. ThreadLocal 的内部结构
在早期的 JDK 设计中,ThreadLocal 内部维护了一个 Map,以 Thread 为 Key。
而在 JDK 8 中,这个设计被反转了:每个 Thread 线程内部都持有一个 ThreadLocalMap 成员变量,而这个 Map 的 Key 是 ThreadLocal 对象本身,Value 是真正要存储的值。
public class Thread implements Runnable {
// 每个线程持有的 ThreadLocalMap
ThreadLocal.ThreadLocalMap threadLocals = null;
}
2. 为什么 Entry 的 Key 要设计为弱引用?
ThreadLocalMap 的内部类 Entry 继承了 WeakReference:
static class Entry extends WeakReference {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal k, Object v) {
super(k); // Key 被包装为弱引用
value = v;
}
}
为什么 Key 要设计为弱引用?
- 如果 Key 是强引用,当我们在业务代码中将
ThreadLocal引用置为null(即不再使用它)时,由于ThreadLocalMap中的Entry依然强引用着ThreadLocal,导致ThreadLocal对象无法被垃圾回收,从而造成内存泄漏。 - 设计为弱引用后,一旦外部对
ThreadLocal的强引用断开,在下一次 GC 时,ThreadLocal对象就会被回收,此时Entry中的 Key 变为null。
3. ThreadLocal 内存泄漏的本质与解决方案
虽然 Key 被设计为弱引用,但 Value 是强引用。
当 Key 变为 null(被 GC 回收)后,只要当前线程不结束,这条强引用链就会一直存在:
Thread -> ThreadLocalMap -> Entry -> Value
由于 Value 无法被回收,而 Key 已经是 null,我们再也无法访问到这个 Value,这就导致了内存泄漏。
解决方案:
-
手动调用
remove():在使用完ThreadLocal变量后,必须显式调用其remove()方法,清除ThreadLocalMap中对应的 Entry。private static final ThreadLocal userHolder = new ThreadLocal();try {userHolder.set(currentUser);// 业务逻辑} finally {userHolder.remove(); // 极其重要!防止内存泄漏和线程复用时的数据污染}
AQS/线程池复用问题:在线程池场景下,线程是被复用的。如果不调用 remove(),下一个任务可能会读取到上一个任务遗留的数据,造成业务逻辑混乱。
二、 CAS (Compare And Swap) 与 Unsafe 类
CAS 是一种无锁(Lock-Free)的原子操作,它利用 CPU 的硬件指令来实现并发安全。
1. CAS 的工作原理
CAS 操作包含三个操作数:
- 内存位置(V):要读写的变量内存地址。
- 预期原值(A):进行比较的旧值。
- 拟写入新值(B):准备写入的新值。
当且仅当内存位置 V 的值等于预期原值 A 时,CAS 才会通过原子指令将 V 的值设为 B。否则,什么都不做。整个比较并替换的操作是一个原子操作。
2. Unsafe 类与底层实现
在 Java 中,CAS 的底层实现依赖于 sun.misc.Unsafe 类。Unsafe 类提供了硬件级别的原子操作,其方法大多是 native 方法。
以 AtomicInteger 的 getAndAddInt 为例:
// JDK 8 源码
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = this.getIntVolatile(o, offset);
} while(!this.compareAndSwapInt(o, offset, v, v + delta)); // 自旋尝试
return v;
}
在 CPU 层面,对于 x86 架构,CAS 底层对应的是 lock cmpxchg 指令。lock 前缀指令会锁住系统总线(或缓存行),确保多核 CPU 下的内存操作原子性。
三、 CAS 的三大缺点及解决方案
3. CAS 的三大缺点及解决方案
1. ABA 问题
问题描述: 线程 1 读取到变量的值为 A。在线程 1 准备进行 CAS 更新之前,线程 2 将变量的值改为了 B,随后又改回了 A。当线程 1 进行 CAS 时,发现值依然是 A,于是更新成功。但实际上,这个变量已经被修改过了。
解决方案:
引入版本号(Version)或时间戳。每次修改变量时,版本号加 1。
Java 提供了 AtomicStampedReference 来解决 ABA 问题:
// 核心 Entry 结构
private static class Pair {
final Object reference;
final int stamp; // 版本戳
private Pair(Object reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static Pair of(Object reference, int stamp) {
return new Pair(reference, stamp);
}
}
通过比较引用和版本戳双重维度,确保 CAS 的绝对安全。
2. 循环时间长、CPU 开销大
问题描述: 如果高并发下竞争极其激烈,CAS 会一直失败并处于自旋状态(死循环),这会给 CPU 带来极大的计算开销。
解决方案:
- 自适应自旋:限制自旋次数,超过限制后挂起线程。
- 分段锁思想(
LongAdder):- 核心思想是热点分散:内部维护了一个
Cell数组和一个base变量。当没有竞争时,直接累加到base;当出现竞争时,将不同的线程分流到不同的Cell里面进行累加,最后求和时将base和所有Cell的值相加。这样将单点的 CAS 竞争分摊到了多个 Cell 上,极大地提高了高并发下的吞吐量。
- 核心思想是热点分散:内部维护了一个
3. 只能保证一个共享变量的原子操作
问题描述: CAS 只能对单个内存地址进行原子操作,无法同时保证多个变量的联合原子性。
解决方案:
- 封装对象:将多个变量封装到一个对象中,然后使用
AtomicReference来对该对象进行 CAS 操作。 - 使用锁:使用锁(如
synchronized或ReentrantLock)来保证多变量操作的原子性。