问题引入
2004 年,一个被广泛使用的单例模式实现突然在多核服务器上出现了问题。代码看起来无懈可击——双重检查锁定(Double-Checked Locking)确保只有第一次调用时才创建实例:
java
public class Singleton {
private static Singleton instance; // 注意:没有 volatile!
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 创建对象
}
}
}
return instance;
}
}
但在高并发下,某些线程拿到了一个半初始化的对象:引用已经指向内存地址,但构造方法还没执行完。调用这个对象的任何方法都会读取到未初始化的字段值,行为完全不可预期。
问题的根源不在锁,而在内存模型。synchronized 保证了互斥,但没有禁止指令重排序。instance = new Singleton() 不是原子操作,编译器和 CPU 可能把它重排成:
- 分配内存
- 将引用指向内存地址
- 调用构造方法初始化对象
如果步骤 2 和 3 被重排序,其他线程在步骤 2 之后、步骤 3 之前看到 instance != null,就会拿到一个未初始化的对象。
理解这个问题的关键,不是记住"加 volatile"这个答案,而是理解Java 内存模型(JMM)为什么要允许重排序、重排序的边界在哪里、以及 volatile 如何用内存屏障划定这个边界。
核心概念
JMM:主内存与工作内存
Java 内存模型(Java Memory Model)定义了多线程环境下共享变量的访问规则。它的核心抽象是主内存(Main Memory)与工作内存(Working Memory):
- 主内存:所有共享变量的"官方"存储位置
- 工作内存:每个线程私有的缓存副本(对应 CPU 缓存和寄存器)
读图导引:粉色节点是主内存——共享变量的唯一官方存储。蓝色节点是每个线程的工作内存——线程对变量的读写先作用于工作内存,再与主内存同步。不同线程的工作内存彼此不可见,这就是"缓存一致性"问题的根源。
为什么需要工作内存? 因为 CPU 访问寄存器和 L1/L2/L3 缓存的速度比访问主内存快 10-100 倍。如果每次读写都直接操作主内存,程序会慢到无法接受。JMM 允许线程先在工作内存中操作,再择机刷回主内存,这是性能与一致性的权衡。
happens-before:重排序的边界
JMM 规定:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且 A 在 B 之前执行。注意:happens-before 不是时间上的先后,而是可见性和有序性的保证。
八大 happens-before 规则:
| 规则 | 含义 |
|---|---|
| 程序次序 | 同一线程内,前面的操作 happens-before 后面的操作 |
| 监视器锁 | unlock happens-before 后续对同一锁的 lock |
| volatile | 写 volatile happens-before 后续读该 volatile |
| 线程启动 | Thread.start() happens-before 新线程的任何操作 |
| 线程终止 | 线程中的所有操作 happens-before join() 返回 |
| 中断 | interrupt() happens-before 被中断线程检测到中断 |
| 对象终结 | 构造方法 happens-before finalize() |
| 传递性 | 若 A happens-before B,B happens-before C,则 A happens-before C |
读图导引:粉色节点是 volatile 写和读。线程1的 x=1 happens-before volatile v=2,线程2的 volatile v=2 happens-before 读 x。根据传递性,线程1的 x=1 对线程2的 读 x 可见——这就是 volatile 的"可见性传递"效果。
volatile:轻量级的可见性保证
volatile 是 Java 中最轻量级的同步机制。它保证两件事:
- 可见性:一个线程修改了 volatile 变量,其他线程立即可见
- 有序性:禁止指令重排序(通过内存屏障实现)
volatile 读:
java
int x = volatileVar; // 读 volatile
int y = someOther; // 后续普通读
在读 volatile 变量前插入LoadLoad 屏障(禁止前面的普通读与 volatile 读重排序),在读之后插入LoadStore 屏障(禁止 volatile 读与后面的普通写重排序)。
volatile 写:
java
someField = 42; // 前面的普通写
volatileVar = 1; // 写 volatile
在写 volatile 变量前插入StoreStore 屏障(禁止前面的普通写与 volatile 写重排序),在写之后插入StoreLoad 屏障(禁止 volatile 写与后面的读写重排序)。StoreLoad 是最昂贵的屏障,它会让写缓冲区的数据全部刷到主内存。
读图导引:粉色节点是 volatile 写,蓝色节点是 volatile 读。屏障像一堵墙,阻止箭头(指令)跨越。StoreLoad 屏障最昂贵,因为它需要清空写缓冲区。
面试官视角:问 "volatile 能保证原子性吗" 是经典陷阱。答案是不能。
volatile++不是原子操作,它包含读-改-写三步,多线程下会丢失更新。需要原子性请用AtomicInteger或synchronized。
CAS:无锁的乐观策略
CAS(Compare-And-Swap)是一种无锁的原子操作,核心思想是"先比较再替换":
java
// Unsafe 层面的 CAS(伪代码)
boolean compareAndSwapInt(Object obj, long offset, int expected, int newValue) {
if (readMemory(obj, offset) == expected) {
writeMemory(obj, offset, newValue); // 原子操作
return true;
}
return false;
}
现代 CPU 直接支持 CAS 指令(x86 上是 LOCK CMPXCHG),JVM 通过 Unsafe 类暴露给 Java 层。
读图导引:粉色节点是比较点——如果预期值 E 等于当前值 V,说明期间没有其他线程修改,可以安全写入新值 N(蓝色节点)。如果不等,说明被其他线程修改过,需要重新读取再尝试(自旋)。
ABA 问题:
CAS 的陷阱在于它只比较"值是否相等",不关心"值是否被修改过"。如果变量从 A 变成 B 又变回 A,CAS 会认为没有变化:
java
// 初始值 A = 100
// 线程1:读取 A=100,准备 CAS 为 150
// 线程2:将 A 改为 200,又改回 100
// 线程1 的 CAS 成功(A 还是 100),但中间被修改过!
解决方式是用 AtomicStampedReference——不仅比较值,还比较版本号(stamp):
java
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
int[] stampHolder = new int[1];
Integer value = ref.get(stampHolder); // 获取值和版本号
ref.compareAndSet(value, 150, stampHolder[0], stampHolder[0] + 1);
暗面:ABA 问题在实际中并不常见(需要恰好改回原值),但无锁数据结构(如链表操作)中,如果节点被删除又重新分配,ABA 会导致指针指向错误地址。
AtomicStampedReference的代价是额外的内存(每个引用需要 4 字节的 stamp),所以 JDK 中的原子类大多不处理 ABA,只在无锁数据结构中使用。
原理分析
DCL 问题:为什么单例模式需要 volatile
双重检查锁定(Double-Checked Locking)是 volatile 最典型的应用场景。
java
public class SafeSingleton {
private static volatile SafeSingleton instance;
public static SafeSingleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (SafeSingleton.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new SafeSingleton(); // 创建对象
}
}
}
return instance;
}
}
为什么不加 volatile 会出问题?因为 instance = new SafeSingleton() 在字节码层面分为三步:
new:分配内存空间invokespecial:调用构造方法初始化对象putstatic:将引用赋值给instance变量
编译器和 CPU 允许步骤 2 和 3 重排序。如果重排后执行顺序是 1→3→2:
时间线:
T1: 分配内存(步骤1)
T2: 引用赋值给 instance(步骤3)——此时 instance != null,但对象未初始化!
【线程2 进入,发现 instance != null,直接返回半初始化对象】
T3: 调用构造方法(步骤2)
volatile 通过StoreStore + StoreLoad 屏障禁止这种重排序:写 instance(volatile 写)之前的操作(包括构造方法)必须先完成,之后的操作必须等待 volatile 写完成。
读图导引:粉色节点是危险场景——引用赋值(步骤3)在构造方法(步骤2)之前完成,线程2读到半初始化对象。蓝色节点是安全场景——volatile 的 StoreStore 屏障确保构造方法在引用赋值之前完成。
内存重排序的本质
现代 CPU 采用**乱序执行(Out-of-Order Execution)和写缓冲区(Store Buffer)**来提升性能:
- 指令重排序:CPU 在执行指令时不一定按程序顺序,只要保证单线程语义正确即可
- 写缓冲区:CPU 不会立即将写操作刷到内存,而是先存入写缓冲区,稍后批量写入
- 缓存一致性协议(MESI):多核 CPU 通过总线嗅探保证缓存一致性,但存在延迟
读图导引:粉色节点是写缓冲区——CPU0 的写操作不会立即到达主内存,CPU1 可能在刷新前读到旧值。volatile 的 StoreLoad 屏障会强制清空写缓冲区,确保后续读写看到最新值。
LongAdder:比 AtomicLong 更快的高并发计数器
AtomicLong 用 CAS 实现原子自增:
java
public final long incrementAndGet() {
return unsafe.getAndAddLong(this, valueOffset, 1L) + 1L;
}
单线程下很快,但高并发下大量线程竞争同一个变量,CAS 失败率极高——每个线程都在自旋重试,CPU 大量消耗在无效的 CAS 循环上。
LongAdder 的解决方案是分片计数:
java
// LongAdder 内部结构(简化)
transient volatile Cell[] cells; // 分片数组
transient volatile long base; // 无竞争时的基数
static final class Cell {
volatile long value;
// @sun.misc.Contended 避免伪共享
}
读图导引:粉色节点是 base——低竞争时直接 CAS。蓝色节点是 Cell 数组——高竞争时每个线程映射到不同的 cell,消除热点。求和时遍历所有 cell 加 base。
关键优化:Cell 用 @sun.misc.Contended 注解填充缓存行,避免伪共享(False Sharing)——如果两个 cell 在同一个 64 字节缓存行中,一个线程修改 cell0 会导致 cell1 在另一个 CPU 的缓存失效,被迫重新加载。
暗面:
LongAdder的sum()不是原子操作——求和时可能有线程在修改 cell。所以sum()是近似值,不能替代AtomicLong在需要精确值的场景(如序列号生成)。
实战/源码
volatile 不能保证原子性的验证
java
public class VolatileNotAtomic {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
count++; // 不是原子操作!
}
}).start();
}
Thread.sleep(3000);
System.out.println(count); // 大概率 < 1,000,000
}
}
count++ 包含三步:读 count → 加 1 → 写 count。volatile 保证每步的可见性,但不保证三步的原子性。两个线程可能同时读到 100,都加 1 变成 101,然后都写回——丢失了一次更新。
正确的无锁计数器
java
// 低并发:AtomicLong
AtomicLong counter = new AtomicLong(0);
counter.incrementAndGet();
// 高并发:LongAdder
LongAdder counter = new LongAdder();
counter.increment();
System.out.println(counter.sum()); // 近似值
// 需要精确值时回到 AtomicLong
AtomicLong exact = new AtomicLong(0);
CAS 自旋的实现模式
java
public class CASLock {
private AtomicBoolean locked = new AtomicBoolean(false);
public void lock() {
while (!locked.compareAndSet(false, true)) {
// 自旋等待
Thread.yield(); // 让出 CPU,避免忙等
}
}
public void unlock() {
locked.set(false);
}
}
这种"自旋锁"在竞争不激烈时性能很好(避免了线程切换开销),但如果锁持有时间长,自旋会浪费大量 CPU。AQS 的优化是:先自旋几次(短竞争),如果还获取不到就挂起线程(长竞争)。
StampedReference:解决 ABA 的利器
java
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
int[] stamp = new int[1];
String value = ref.get(stamp); // value="A", stamp[0]=0
// 模拟 ABA:A → B → A
ref.compareAndSet("A", "B", 0, 1);
ref.compareAndSet("B", "A", 1, 2);
// 带版本号的 CAS 会失败,因为 stamp 不匹配
boolean success = ref.compareAndSet("A", "C", 0, 3); // false!
常见问题
Q1:volatile 和 synchronized 的区别?
| 维度 | volatile | synchronized |
|---|---|---|
| 可见性 | 保证 | 保证 |
| 原子性 | 不保证 | 保证 |
| 有序性 | 保证(禁止重排序) | 保证(Monitor 语义) |
| 阻塞 | 不会 | 会(获取不到锁时阻塞) |
| 适用场景 | 单一变量的读写标志 | 复合操作、临界区 |
Q2:为什么 volatile++ 不是原子操作?
i++ 在字节码层面是:
iload_0:读取变量值到操作数栈iinc:栈顶值加 1istore_0:写回变量
三步之间可能被其他线程打断。volatile 保证每步的可见性,但不保证三步整体的原子性。
Q3:CAS 的底层 CPU 指令是什么?
x86 架构上是 LOCK CMPXCHG。LOCK 前缀保证指令的原子性(锁定总线或缓存行),CMPXCHG 比较并交换。现代 CPU 用缓存一致性协议实现原子性,不需要真的锁总线。
Q4:LongAdder 什么时候比 AtomicLong 慢?
低并发时(1-2 个线程)。LongAdder 需要维护 Cell 数组,求和时要遍历所有 cell,有一定的额外开销。只有当竞争激烈(通常 > 4 个线程同时写)时,LongAdder 的分片优势才能抵消这个开销。
Q5:DCL 的 volatile 可以去掉吗?
不可以。没有 volatile,编译器/CPU 可能重排序 new 的三步,导致其他线程读到半初始化对象。这是 JMM 层面的问题,synchronized 无法阻止。
总结
JMM、volatile 和 CAS 是 Java 并发编程的三块基石:
- JMM 的主内存/工作内存抽象:揭示了多线程环境下"缓存一致性"问题的根源——每个线程有自己的缓存副本,修改不即时同步
- happens-before:不是时间先后,而是可见性和有序性的保证边界。八大规则中最常用的是监视器锁、volatile 和传递性
- volatile:通过内存屏障(LoadLoad、LoadStore、StoreStore、StoreLoad)保证可见性和有序性,但不保证原子性。DCL 中的
volatile禁止了new三步的重排序 - CAS:基于 CPU 的原子指令实现无锁更新,但存在 ABA 问题和自旋开销。JDK 用
AtomicStampedReference解决 ABA - LongAdder:用分片思想解决 CAS 热点,用
@Contended避免伪共享。sum()是近似值,不适合需要精确读的场景
理解这些机制的关键,是追问:编译器和 CPU 为什么要重排序?重排序的边界在哪里?volatile 的屏障是如何划定这个边界的? 从"知道加 volatile"到"懂得为什么必须加",是跨越初级和中级开发者的分水岭。