问题引入

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 可能把它重排成:

  1. 分配内存
  2. 将引用指向内存地址
  3. 调用构造方法初始化对象

如果步骤 2 和 3 被重排序,其他线程在步骤 2 之后、步骤 3 之前看到 instance != null,就会拿到一个未初始化的对象。

理解这个问题的关键,不是记住"加 volatile"这个答案,而是理解Java 内存模型(JMM)为什么要允许重排序、重排序的边界在哪里、以及 volatile 如何用内存屏障划定这个边界。

核心概念

JMM:主内存与工作内存

Java 内存模型(Java Memory Model)定义了多线程环境下共享变量的访问规则。它的核心抽象是主内存(Main Memory)与工作内存(Working Memory):

  • 主内存:所有共享变量的"官方"存储位置
  • 工作内存:每个线程私有的缓存副本(对应 CPU 缓存和寄存器)
graph TD subgraph "JMM 内存模型" MM[主内存 shared variables] T1[线程1 工作内存] T2[线程2 工作内存] T3[线程3 工作内存] MM -->|read| T1 T1 -->|write| MM MM -->|read| T2 T2 -->|write| MM MM -->|read| T3 T3 -->|write| MM end style MM fill:#f9f,stroke:#333,stroke-width:2px style T1 fill:#bbf,stroke:#333,stroke-width:2px style T2 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是主内存——共享变量的唯一官方存储。蓝色节点是每个线程的工作内存——线程对变量的读写先作用于工作内存,再与主内存同步。不同线程的工作内存彼此不可见,这就是"缓存一致性"问题的根源。

为什么需要工作内存? 因为 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
graph LR subgraph "happens-before 传递性" A[线程1 写 x=1] -->|hb| B[线程1 写 volatile v=2] B -->|hb| C[线程2 读 volatile v=2] C -->|hb| D[线程2 读 x] A -.->|传递性| D end style B fill:#f9f,stroke:#333,stroke-width:2px style C fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点是 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 中最轻量级的同步机制。它保证两件事:

  1. 可见性:一个线程修改了 volatile 变量,其他线程立即可见
  2. 有序性:禁止指令重排序(通过内存屏障实现)

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 是最昂贵的屏障,它会让写缓冲区的数据全部刷到主内存。

graph TD subgraph "volatile 读写屏障" subgraph "写 volatile" W1[普通写 A] -->|StoreStore 屏障| W2[写 volatile V] W2 -->|StoreLoad 屏障| W3[后续读写] end subgraph "读 volatile" R1[前面的读] -->|LoadLoad 屏障| R2[读 volatile V] R2 -->|LoadStore 屏障| R3[后续写] end end style W2 fill:#f9f,stroke:#333,stroke-width:2px style R2 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是 volatile 写,蓝色节点是 volatile 读。屏障像一堵墙,阻止箭头(指令)跨越。StoreLoad 屏障最昂贵,因为它需要清空写缓冲区。

面试官视角:问 "volatile 能保证原子性吗" 是经典陷阱。答案是不能volatile++ 不是原子操作,它包含读-改-写三步,多线程下会丢失更新。需要原子性请用 AtomicIntegersynchronized

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 层。

graph TD subgraph "CAS 操作流程" A[读取当前值 V] -->|比较| B{E == V?} B -->|是| C[原子写入 N] B -->|否| D[失败,重试] D -->|重新读取| A end style B fill:#f9f,stroke:#333,stroke-width:2px style C fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是比较点——如果预期值 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() 在字节码层面分为三步:

  1. new:分配内存空间
  2. invokespecial:调用构造方法初始化对象
  3. 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 写完成。

graph TD subgraph "DCL 指令重排序" subgraph "无 volatile(危险)" A1[分配内存] -->|允许重排| A3[引用赋值] A3 --> A2[构造方法] A3 -.->|线程2读到| A4[半初始化对象] end subgraph "有 volatile(安全)" B1[分配内存] -->|StoreStore 屏障| B2[构造方法] B2 -->|StoreLoad 屏障| B3[引用赋值] end end style A3 fill:#f9f,stroke:#333,stroke-width:2px style A4 fill:#f9f,stroke:#333,stroke-width:2px style B3 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是危险场景——引用赋值(步骤3)在构造方法(步骤2)之前完成,线程2读到半初始化对象。蓝色节点是安全场景——volatile 的 StoreStore 屏障确保构造方法在引用赋值之前完成。

内存重排序的本质

现代 CPU 采用**乱序执行(Out-of-Order Execution)写缓冲区(Store Buffer)**来提升性能:

  • 指令重排序:CPU 在执行指令时不一定按程序顺序,只要保证单线程语义正确即可
  • 写缓冲区:CPU 不会立即将写操作刷到内存,而是先存入写缓冲区,稍后批量写入
  • 缓存一致性协议(MESI):多核 CPU 通过总线嗅探保证缓存一致性,但存在延迟
graph TD subgraph "CPU 写缓冲区" C1[CPU0 写 x=1] -->|存入写缓冲| B1[写缓冲区] B1 -->|延迟刷新| M[主内存] C2[CPU1 读 x] -->|可能读到旧值| M end style B1 fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点是写缓冲区——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 避免伪共享
}
graph TD subgraph "LongAdder 分片计数" B[base] -->|低竞争| V1[直接 CAS] B -->|高竞争| CS["Cell[]"] CS -->|线程1| C1[cell0] CS -->|线程2| C2[cell1] CS -->|线程3| C3[cell2] end style B fill:#f9f,stroke:#333,stroke-width:2px style CS fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是 base——低竞争时直接 CAS。蓝色节点是 Cell 数组——高竞争时每个线程映射到不同的 cell,消除热点。求和时遍历所有 cell 加 base。

关键优化Cell@sun.misc.Contended 注解填充缓存行,避免伪共享(False Sharing)——如果两个 cell 在同一个 64 字节缓存行中,一个线程修改 cell0 会导致 cell1 在另一个 CPU 的缓存失效,被迫重新加载。

暗面LongAddersum() 不是原子操作——求和时可能有线程在修改 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++ 在字节码层面是:

  1. iload_0:读取变量值到操作数栈
  2. iinc:栈顶值加 1
  3. istore_0:写回变量

三步之间可能被其他线程打断。volatile 保证每步的可见性,但不保证三步整体的原子性。

Q3:CAS 的底层 CPU 指令是什么?

x86 架构上是 LOCK CMPXCHGLOCK 前缀保证指令的原子性(锁定总线或缓存行),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"到"懂得为什么必须加",是跨越初级和中级开发者的分水岭。