JMM、volatile与CAS
开篇:多线程的"诡异"问题
先看一段代码:
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) {
// 一直循环
}
System.out.println("线程退出");
}).start();
Thread.sleep(1000);
flag = false;
System.out.println("主线程已将 flag 设为 false");
}按照直觉,主线程把 flag 改为 false 后,子线程应该退出循环才对。但实际运行你会发现——子线程永远不会退出。flag 明明改了,子线程为什么看不到?
这不是 bug,而是 Java 内存模型(JMM)在起作用。要理解这个问题,我们需要搞清楚 JMM、volatile 和 CAS 三个关键概念。
一、Java 内存模型(JMM)
1.1 主内存与工作内存
想象一个办公室场景。办公室有一块公共白板(主内存),上面写着所有人共享的数据。每个员工有自己的笔记本(工作内存),他们不会每次都去看白板,而是把白板上的数据抄到自己的笔记本上,在笔记本上修改,然后择机写回白板。
JMM 就是这个模型:
- 主内存:所有线程共享的内存区域,存放共享变量
- 工作内存:每个线程私有的内存空间,存放共享变量的副本
线程对变量的所有操作(读、写)必须在工作内存中进行,不能直接读写主内存。不同线程之间也不能直接访问对方的工作内存——要通过主内存来中转。
线程A的工作内存 主内存 线程B的工作内存
flag=true ←→ flag=? ←→ flag=true回到开篇的问题:主线程把 flag 改为 false 并写回了主内存,但子线程一直在用自己工作内存中的 flag 副本(还是 true),没有去主内存刷新——所以它看不到变化。
1.2 happens-before 规则
JMM 通过 happens-before 规则来定义操作之间的内存可见性。如果操作 A happens-before 操作 B,那么 A 的结果对 B 一定是可见的。
核心规则如下(节选自 JSR-133):
程序次序规则:在单线程中,按代码顺序,前面的操作 happens-before 后面的操作。
int a = 1; // 操作1
int b = 2; // 操作2
// 操作1 happens-before 操作2Monitor 锁规则:解锁 happens-before 后续对同一个锁的加锁。
synchronized(lock) {
value = 1; // 写操作
}
// 当另一个线程获取同一个 lock 时,一定能看到 value = 1volatile 规则:对 volatile 变量的写 happens-before 对同一变量的读。
volatile boolean ready = false;
// 线程 A
data = 42;
ready = true; // volatile 写
// 线程 B
if (ready) { // volatile 读
assert data == 42; // 一定成立
}线程启动规则:Thread.start() happens-before 该线程的所有操作。
int value = 10;
new Thread(() -> {
// 这里一定能看到 value = 10
}).start();线程终止规则:线程的所有操作 happens-before Thread.join() 返回。
传递性规则:如果 A happens-before B,B happens-before C,那么 A happens-before C。
volatile boolean ready = false;
int number = 0;
// 线程 A
number = 42; // 操作 A
ready = true; // 操作 B(volatile 写)
// 线程 B
if (ready) { // 操作 C(volatile 读)
assert number == 42; // 成立!因为 A hb B hb C,所以 A hb C
}这个传递性非常重要——它意味着 volatile 不仅能保证自身变量的可见性,还能"捎带"保证它之前所有普通变量的可见性。
1.3 三大问题的根源
JMM 的设计带来了三大并发问题:
| 问题 | 根源 | 解决方案 |
|---|---|---|
| 可见性 | 工作内存缓存,修改后没有及时同步回主内存 | volatile、synchronized、final |
| 原子性 | CPU 时间片切换,操作被中断 | synchronized、CAS、原子类 |
| 有序性 | 编译器/处理器指令重排 | volatile(内存屏障)、synchronized |
二、volatile:轻量级同步
volatile 是 Java 提供的最轻量级的同步机制。它只能修饰变量,作用有两个:保证可见性和禁止指令重排。
2.1 可见性保证
对 volatile 变量的写操作会立即刷新到主内存,读操作会从主内存重新加载。
用 volatile 修复开篇的问题:
private static volatile boolean flag = true; // 加上 volatile
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) { }
System.out.println("线程退出"); // 现在可以正常退出了
}).start();
Thread.sleep(1000);
flag = false;
}volatile 是怎么做到的?当对 volatile 变量进行写操作时,JVM 会向处理器发送一条 lock 前缀指令,它做了两件事:
- 把当前缓存行的数据写回主内存
- 通过缓存一致性协议(如 MESI)使其他 CPU 缓存中该数据的副本失效
所以其他线程再读取这个变量时,会发现自己缓存中的副本已失效,不得不从主内存重新加载。
2.2 禁止指令重排(内存屏障)
编译器和处理器为了优化性能,可能会对指令进行重排。大多数时候这没问题(单线程下结果不变——as-if-serial 语义),但在多线程下可能导致诡异的 bug。
volatile 通过内存屏障来禁止重排。JMM 定义了四种屏障:
| 屏障 | 含义 |
|---|---|
| LoadLoad | 保证前面的读在后面的读之前完成 |
| LoadStore | 保证前面的读在后面的写之前完成 |
| StoreStore | 保证前面的写在后面的写之前完成 |
| StoreLoad | 保证前面的写在后面的读之前完成 |
对于 volatile 变量,JVM 会在适当位置插入这些屏障:
- 每个 volatile 写之前:StoreStore + LoadStore
- 每个 volatile 写之后:StoreLoad
- 每个 volatile 读之后:LoadLoad + LoadStore
不同操作系统的具体实现不同。比如在 x86 架构上,LoadLoad/StoreStore/LoadStore 天然保证,只需要额外处理 StoreLoad。而 ARM 架构需要更多的屏障指令。
2.3 volatile 不保证原子性
这是 volatile 最重要的限制。看一个经典例子:
volatile int count = 0;
// 10 个线程各自执行 1000 次
public void increment() {
count++; // 不是原子操作!
}count++ 分三步:读取 count 的值、加 1、写回新值。volatile 保证了每次读都是最新值,每次写都刷回主内存。但这三步之间可能被其他线程插入。
线程 A: 读 count=5
线程 B: 读 count=5
线程 A: count=5+1=6, 写回 6
线程 B: count=5+1=6, 写回 6 ← 线程 A 的加 1 被"覆盖"了所以最终结果会小于 10000。要保证 i++ 的原子性,用 AtomicInteger 或 synchronized。
2.4 DCL 单例为什么需要 volatile?
双重检查锁(DCL)单例是面试高频题:
public class Singleton {
private volatile static Singleton instance; // 必须加 volatile!
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题在这里
}
}
}
return instance;
}
}instance = new Singleton() 实际上分三步:
- 分配内存空间
- 初始化对象
- 把引用指向内存空间
如果不加 volatile,步骤 2 和 3 可能被重排为 1-3-2。当线程 A 执行完 1-3(还没初始化对象),线程 B 来了,看到 instance 不为 null,直接返回——拿到了一个未初始化的对象,调用方法时就会 NPE。
加上 volatile 后,禁止了重排序,保证执行顺序一定是 1-2-3。
2.5 volatile 的正确使用场景
volatile 适合以下场景:
- 状态标志:
volatile boolean running = true; - 一次性发布:
volatile Singleton instance; - 低开销读写策略:读多写少,用 volatile 保证读的可见性,写操作用 synchronized 保证原子性
public class Counter {
private volatile int value;
public int getValue() {
return value; // 利用 volatile 保证可见性
}
public synchronized void increment() {
value++; // 利用 synchronized 保证原子性
}
}三、CAS:无锁并发
3.1 Compare And Swap 原理
CAS 是一种硬件级别的原子操作,包含三个操作数:
- V:内存地址中的当前值
- A:期望值(你认为当前应该是什么)
- B:要更新成的新值
执行逻辑:如果 V == A,就把 V 更新为 B;否则什么都不做。 整个过程由 CPU 的 cmpxchg 指令保证原子性。
AtomicInteger ai = new AtomicInteger(5);
ai.compareAndSet(5, 10); // 期望值 5,更新为 10 → 成功
ai.compareAndSet(5, 20); // 期望值 5,但当前值是 10 → 失败CAS 通常配合自旋使用——如果 CAS 失败(说明有其他线程修改了值),就重新读取最新值再试,直到成功:
// AtomicInteger.getAndIncrement() 的核心逻辑
int current;
int next;
do {
current = get(); // 读取当前值
next = current + 1; // 计算新值
} while (!compareAndSet(current, next)); // CAS 更新,失败则重试3.2 Unsafe 类
Java 中的 CAS 操作是通过 sun.misc.Unsafe 类实现的。Unsafe 提供了直接操作内存的能力,是 Java CAS 的底层支撑。
Unsafe 的主要功能:
- CAS 操作(
compareAndSwapInt等) - 内存分配与释放
- 直接内存操作(读写任意地址)
- 线程挂起与恢复(park/unpark)
// Unsafe 中的 CAS 方法
public final native boolean compareAndSwapInt(
Object obj, // 要操作的对象
long offset, // 字段在对象中的偏移量
int expected, // 期望值
int update // 新值
);Unsafe 是一把"双刃剑":功能强大但使用不当会导致内存泄漏、段错误等问题。JDK 23 中已经开始移除 Unsafe,替代方案是 JDK 9 的 VarHandle 和 JDK 22 的 MemorySegment。
3.3 ABA 问题与解决
CAS 有一个著名的问题:ABA 问题。
假设线程 A 读到值是 A,线程 B 把值从 A 改成 B 再改回 A。线程 A 做 CAS 时发现值还是 A,认为没人修改过——但实际上中间经历了 A→B→A 的变化。
举个形象的例子:小明和小红要结婚,到了民政局,小红说"我去个洗手间"。小红趁机和小刚登记结婚,又办了离婚,然后回来继续和小明登记。对小明来说,老婆还是那个老婆,但她已经是离过婚的了。
ABA 问题的核心影响在于:中间的变化可能导致了其他副作用,仅检查最终值不够。
解决方案:版本号。每次修改都把版本号加 1,CAS 时同时检查值和版本号。
Java 提供了 AtomicStampedReference:
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 1);
int stamp = ref.getStamp(); // 获取当前版本号
// CAS 时同时检查值和版本号
boolean success = ref.compareAndSet(
100, 200, // 期望值 → 新值
stamp, stamp + 1 // 期望版本 → 新版本
);3.4 CAS 的自旋问题
CAS 失败后会不断重试(自旋)。如果竞争激烈,CAS 一直失败,就会进入"忙等待"——CPU 在空转但做不了有用的工作。
这就是为什么高竞争场景下 CAS 不一定比锁好。锁虽然会阻塞线程,但阻塞的线程不消耗 CPU。而 CAS 自旋会持续消耗 CPU 资源。
Java 的 LongAdder 就是针对这个问题的优化方案。它通过把一个计数器拆分成多个 Cell,每个线程操作不同的 Cell,减少竞争。最后求和时把所有 Cell 加起来。
四、线程中断机制
线程中断机制已在"多线程基础"篇中介绍过。这里补充一个重要细节:
中断和 volatile 的关系
之前讲停止线程有两种方式:interrupt 和 volatile 标志位。它们底层原理不同:
- volatile 标志位依赖 JMM 的可见性保证(内存屏障)
- interrupt 设置的是 JVM 内部的线程状态标志,不依赖 volatile
但从效果上,它们的目标一样:一个线程修改了某个状态,另一个线程能及时感知到。
interrupt 的优势在于它能打断阻塞操作(sleep/wait/join),而 volatile 标志位在线程阻塞时无法被检查。所以生产环境推荐 interrupt + volatile 标志位组合使用。
五、常见面试题精选
Q1:什么是 JMM?
JMM(Java Memory Model)是一套规范,定义了多线程程序中共享变量的读写规则。它规定了主内存和工作内存的交互协议,通过 happens-before 规则来保证可见性和有序性。JMM 屏蔽了不同操作系统和硬件的差异,让 Java 程序在所有平台上表现一致。
注意区分:JMM(Java内存模型)讲的是并发可见性,JVM内存结构讲的是堆栈方法区。
Q2:volatile 能保证原子性吗?
不能。volatile 只保证可见性和有序性。对于复合操作(如 i++),volatile 无法保证中间步骤不被打断。要保证原子性,用 synchronized、ReentrantLock 或原子类。
Q3:什么是 CAS?有什么问题?
CAS 是一种硬件级别的原子操作:比较并交换。主要问题有两个:(1) ABA 问题——用 AtomicStampedReference 的版本号解决;(2) 自旋开销——竞争激烈时 CPU 空转,用 LongAdder 等方案缓解。
Q4:volatile 是如何保证可见性和有序性的?
可见性:写 volatile 变量时,JVM 发送 lock 前缀指令,强制把缓存行数据写回主内存,同时通过 MESI 协议使其他 CPU 缓存失效。
有序性:通过在 volatile 变量读写前后插入内存屏障(LoadLoad/LoadStore/StoreStore/StoreLoad),禁止特定类型的指令重排。
Q5:happens-before 和 as-if-serial 有什么区别?
as-if-serial 是针对单线程的:不管怎么重排,单线程执行结果不变。
happens-before 是针对多线程的:定义了哪些操作的结果对其他线程可见。
synchronized 之所以能保证有序性,正是因为它把多线程的代码变成了"单线程执行",从而可以利用 as-if-serial 语义。
Q6:有了 CAS 为什么还需要 volatile?
CAS 保证的是原子性——一个"比较并交换"操作不会被中断。但 CAS 本身不保证修改后的值对其他线程立即可见(可见性问题),也不保证操作的有序性。
所以在 AQS 中,state 变量同时用了 volatile 和 CAS:
- volatile 保证可见性和有序性
- CAS 保证原子更新
两者互补,缺一不可。
Q7:有了 MESI 为什么还需要 JMM?
MESI 是硬件层面的缓存一致性协议,保证多核 CPU 的缓存之间数据一致。
JMM 是软件层面的内存模型,保证 Java 多线程之间的可见性、有序性、原子性。
它们解决的问题在不同层面:MESI 管的是 CPU 缓存,JMM 管的是 Java 线程的工作内存。即使有了 MESI,Java 编译器和 JIT 仍然可能做指令重排,JVM 仍然可能延迟刷新工作内存——这些都是 JMM 要处理的。
Q8:什么是伪共享?
当多个线程操作的不同变量恰好位于同一个 CPU 缓存行(通常 64 字节)时,一个线程修改变量 X 会导致整个缓存行失效,逼迫操作变量 Y 的线程也重新加载缓存——即使 Y 根本没变。这就是"伪共享"。
解决方案:
- Java 8+ 使用
@Contended注解让变量独占缓存行 - 手动填充无用字段来隔离变量
- 使用 ThreadLocal 避免共享
Java 的 LongAdder 内部的 Cell 数组就使用了 @Contended 来避免伪共享。
小结
| 概念 | 保证的特性 | 实现层面 |
|---|---|---|
| JMM | 可见性 + 有序性 + 原子性的规范 | 规范/抽象 |
| volatile | 可见性 + 有序性 | 内存屏障 |
| synchronized | 原子性 + 可见性 + 有序性 | Monitor / 锁升级 |
| CAS | 原子性 | CPU 指令 (cmpxchg) |
| happens-before | 定义操作间的可见性关系 | JMM 规则 |
核心要点:
- JMM 定义了主内存和工作内存的交互规则,是并发编程的理论基础
- volatile 适合一写多读的状态标志,不适合复合操作
- CAS 是无锁编程的基石,但要注意 ABA 问题和自旋开销
- happens-before 是判断多线程可见性的核心工具
- volatile、synchronized、CAS 三者各有侧重,常常需要组合使用
附录:深入理解 JMM 与底层原理
JMM 的前世今生
Java 内存模型不是凭空出现的,它的诞生源于硬件发展带来的三大问题。
问题一:多级缓存导致的一致性问题
现代 CPU 有多级缓存(L1/L2/L3)。CPU 不直接和主内存交互,而是先把数据加载到缓存中。在单核时代这没问题,但多核时代每个核心有自己的 L1/L2 缓存,同一个变量可能在多个核心的缓存中有不同的副本。
CPU Core 0 CPU Core 1
L1 Cache L1 Cache
count=5 count=5
↕ ↕
L2 Cache L2 Cache
↕ ↕
-------- L3 Cache (共享) --------
↕
主内存 (count=5)当 Core 0 把 count 改成 6 但还没写回主内存时,Core 1 看到的还是 5——这就是缓存一致性问题。
MESI 协议在硬件层面解决了这个问题:当一个核心修改缓存行时,其他核心的同一缓存行会被标记为 Invalid,下次访问时必须重新从修改方获取最新数据。
问题二:CPU 时间片导致的原子性问题
操作系统通过时间片来调度线程。一个线程可能执行到一半就被切走,导致"读-改-写"这类操作不原子。
问题三:指令重排导致的有序性问题
编译器和处理器为了优化性能,可能重排指令的执行顺序。单线程下有 as-if-serial 语义保证结果正确,但多线程下重排可能导致诡异的 bug。
JMM 就是为了在软件层面系统性地解决这三个问题而提出的规范。它提供了 volatile、synchronized、final 等关键字作为开发者的工具,底层通过内存屏障和锁机制来实现。
内存屏障的底层实现
内存屏障是一种 CPU 指令,用于阻止指令重排。JVM 定义了四种逻辑屏障(LoadLoad/LoadStore/StoreStore/StoreLoad),但在不同硬件上的实现差异很大。
x86 架构
x86 是强内存模型的架构,天然保证了大部分顺序性。只有 StoreLoad 需要额外的屏障指令:
// HotSpot 在 x86 上的实现
inline void OrderAccess::loadload() { compiler_barrier(); } // 编译器屏障即可
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore() { compiler_barrier(); }
inline void OrderAccess::storeload() { fence(); } // 需要真正的 CPU 屏障
// fence() 的实现
inline void OrderAccess::fence() {
__asm { lock add dword ptr [esp], 0; } // lock 前缀指令
}这里的 lock add 就是一条带 lock 前缀的空操作——它不会改变任何数据,但 lock 前缀会锁住总线(或缓存行),确保 Store 操作在 Load 之前对其他 CPU 可见。
ARM 架构
ARM 是弱内存模型的架构,几乎所有屏障都需要显式实现:
inline void OrderAccess::loadload() { dmb_ld(); } // 数据内存屏障(读)
inline void OrderAccess::storestore() { dmb_st(); } // 数据内存屏障(写)
inline void OrderAccess::storeload() { dmb_sy(); } // 全屏障这也是为什么在 ARM 处理器上(如手机),Java 程序的并发行为可能和 x86 上不同。JMM 的存在正是为了屏蔽这种差异,保证 Java 程序在所有平台上行为一致。
volatile 的读写流程
一个 volatile 变量从写到被另一个线程读到,经历以下完整流程:
线程 A (写) 线程 B (读)
1. 在工作内存中修改变量
2. 执行 StoreStore 屏障
3. 把变量写回主内存
4. 执行 StoreLoad 屏障
5. 执行 LoadLoad 屏障
6. 从主内存读取变量到工作内存
7. 执行 LoadStore 屏障
8. 使用变量中间的屏障保证了:
- 写回主内存之前,前面所有的写操作都已完成(StoreStore)
- 写回主内存之后,后面的读操作不会被提前(StoreLoad)
- 从主内存读取之后,后续的读不会用旧值(LoadLoad)
- 从主内存读取之后,后续的写不会被提前(LoadStore)
CAS 在操作系统层面如何保证原子性
CAS 的原子性由 CPU 的 cmpxchg 指令保证。这条指令在执行时:
- 处理器自动锁定总线(或锁定缓存行),防止其他 CPU 同时访问
- 执行比较和交换操作
- 释放总线/缓存行
在多核系统中,cmpxchg 指令还会配合缓存一致性协议,确保修改后的值对其他核心立即可见。
这也回答了一个常见疑问:"CAS 不就是比较然后交换两步吗,怎么保证这两步之间不被打断?"——因为 cmpxchg 是一条 CPU 指令,一条指令就是原子的,中间不可能被打断。
总线嗅探与总线风暴
总线嗅探:在 MESI 协议中,每个 CPU 核心通过"嗅探"总线上的数据传输来判断自己缓存中的数据是否还有效。
总线风暴:如果多个线程频繁修改共享变量(比如高竞争的 CAS 自旋),会导致大量的缓存失效通知在总线上传递,总线带宽被占满——这就是总线风暴。
总线风暴会严重影响性能。解决方法:
- 减少共享变量的竞争(如 LongAdder 的分段思想)
- 使用适当的退避策略(自旋失败后等一会儿再试)
- 使用
@Contended避免伪共享
CAS 的自旋锁实现
用 CAS 可以实现一个简单的自旋锁:
public class SpinLock {
private AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
Thread current = Thread.currentThread();
while (!owner.compareAndSet(null, current)) {
// 自旋等待
}
}
public void unlock() {
Thread current = Thread.currentThread();
owner.compareAndSet(current, null);
}
}工作原理:
lock():不断尝试把 owner 从 null 设为当前线程。如果 owner 不是 null(说明有人持有锁),就一直重试。unlock():把 owner 从当前线程设回 null。
自旋锁适合锁持有时间很短的场景。如果锁持有时间长,自旋会浪费大量 CPU 资源。
AtomicStampedReference 详解
AtomicStampedReference 同时维护一个引用和一个版本号(stamp),CAS 时同时检查两者:
AtomicStampedReference<String> ref =
new AtomicStampedReference<>("初始值", 0);
// 获取当前版本号
int[] stampHolder = new int[1];
String current = ref.get(stampHolder);
int currentStamp = stampHolder[0];
// CAS 更新:必须同时匹配值和版本号
boolean success = ref.compareAndSet(
current, // 期望引用
"新值", // 新引用
currentStamp, // 期望版本号
currentStamp + 1 // 新版本号
);版本号只增不减,所以即使引用值被改回来了(A→B→A),版本号也从 0 变成了 2,CAS 会检测到变化。
还有一个简化版的 AtomicMarkableReference,它不用整数版本号,而是用一个 boolean 标记,只关心"是否被修改过",不关心修改了几次。
如何不使用锁实现线程安全的单例?
除了 DCL(需要 volatile + synchronized),还有两种无锁方案:
方案一:静态内部类
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}利用了 JVM 类加载机制的线程安全性:类只会被加载一次,而且加载过程是线程安全的。
方案二:枚举
public enum Singleton {
INSTANCE;
public void doSomething() {
// ...
}
}枚举是最简洁、最安全的单例实现方式。它天然防止反射攻击和反序列化破坏,而且 JVM 保证枚举实例的唯一性。
这两种方式都不需要 volatile 也不需要 synchronized,因为它们把线程安全的保证交给了 JVM 本身的机制。