在 Java 并发编程的江湖中,volatile 和 synchronized 是两位最核心的“护法”。许多开发者虽然日常使用它们,但往往只知其然不知其所以然。本文将从 JVM 内存模型、底层硬件实现以及设计哲学三个维度,深度解析这两者的区别与联系。
一、 核心概念与 JMM 背景
Java 内存模型(JMM)定义了线程与主内存之间的抽象关系。为了解决多线程环境下的可见性、有序性和原子性问题,JVM 提供了不同的同步机制。
- volatile:轻量级同步机制,保证可见性和有序性,不保证原子性。
- synchronized:重量级互斥锁,保证可见性、有序性和原子性。
二、 volatile:轻量级的可见性守护者
1. 底层实现原理
volatile 的实现依赖于两个核心底层机制:
- 内存屏障(Memory Barrier):
JVM 会在编译后的字节码中插入特定的内存屏障指令,禁止指令重排序。- StoreStore 屏障:确保 volatile 写操作之前的普通写操作已刷新到主存。
- LoadLoad 屏障:确保 volatile 读操作之后的普通读操作从主存重新加载。
- CPU 缓存一致性协议(MESI):
当线程修改 volatile 变量时,JVM 会发出lock前缀指令。该指令会锁定当前缓存行,并将数据立即写回主内存。同时,基于 MESI 协议,其他 CPU 核心中缓存了该变量的缓存行会被标记为“失效”,迫使其他线程重新从主内存读取 。
2. 代码示例:状态标志位
volatile 最适合用于不依赖当前值的状态标记。
public class VolatileFlagDemo {
// volatile 保证 visibleThread 能立即看到 flag 的变化
private volatile boolean flag = false;
public void change() {
flag = true; // 线程 A 修改
}
public void check() {
while (!flag) {
// 线程 B 循环等待
// 如果没有 volatile,线程 B 可能永远看不到 flag 变为 true
}
System.out.println("Flag changed!");
}
}
3. 陷阱:为什么 volatile 不保证原子性?
以下代码展示了 volatile 在复合操作下的失效:
public class VolatileAtomicityFail {
private volatile int count = 0;
public void increment() {
count++; // 非原子操作:Read -> Modify -> Write
}
}
count++ 分为三步:读取主存值、在寄存器中加 1、写回主存。如果两个线程同时读取到 count=0,各自加 1 后写回,结果将是 1 而不是 2。volatile 只能保证每一步的可见性,无法保证这三步作为一个整体不被打断 。
三、 synchronized:全能型的互斥锁
1. 底层实现:Monitor 与对象头
synchronized 的底层依赖于对象监视器(Object Monitor)。每个 Java 对象头(Object Header)中都包含一个 Mark Word,它记录了锁的状态。
JVM 为了性能,实现了锁升级机制 :
- 偏向锁(Biased Locking):
- 场景:只有一个线程访问。
- 实现:Mark Word 记录线程 ID。无需 CAS,无需系统调用,性能极高。
- 轻量级锁(Lightweight Locking):
- 场景:轻微竞争。
- 实现:通过 CAS 操作将 Mark Word 替换为指向栈帧中锁记录的指针。若 CAS 失败,则自旋尝试。
- 重量级锁(Heavyweight Locking):
- 场景:激烈竞争。
- 实现:Mark Word 指向堆中的
ObjectMonitor对象。竞争失败的线程进入_EntryList阻塞,涉及操作系统内核态切换,开销大 。
2. 可见性与有序性的保证
synchronized 的可见性源于 JMM 的规定:
- 解锁前:必须将工作内存中的共享变量刷新回主内存。
- 加锁后:必须清空工作内存,从主内存重新加载变量。
这种机制天然保证了有序性,因为同一时刻只有一个线程执行同步块,逻辑上是串行的 。
3. 代码示例:双重检查锁定(DCL)
这是 volatile 与 synchronized 结合使用的经典案例,体现了两者互补的设计思想。
public class Singleton {
// volatile 禁止指令重排,防止其他线程拿到半初始化的对象
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:无锁,高性���
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:加锁,保证原子性
// 1. 分配内存
// 2. 初始化对象
// 3. 赋值引用
// 若无 volatile,步骤 2 和 3 可能重排
instance = new Singleton();
}
}
}
return instance;
}
}
四、 设计哲学:权衡与演进
1. 乐观与悲观的权衡
- volatile 是乐观的:它假设冲突很少发生,因此不提供互斥,只提供最基础的可见性保障。它的设计哲学是 “最小化开销”,适用于读多写少或简单状态同步的场景。
- synchronized 是悲观的:它假设冲突可能发生,因此通过互斥来保证安全。但它并非一直“悲观”,通过锁升级机制,它在无竞争时表现得像乐观锁(偏向/轻量级),只有在真正竞争时才暴露出悲观锁的本质。这体现了 “按需付费” 的设计智慧 。
2. 分层抽象与硬件亲和性
Java 并发工具的设计紧密贴合硬件特性:
volatile直接映射到 CPU 的缓存一致性协议和内存屏障,利用了硬件层面的原子能力。synchronized的轻量级锁利用 CAS 指令(用户态),重量级锁利用 OS Mutex(内核态)。这种分层设计使得 Java 程序在不同竞争强度下都能获得较好的性能表现 。
3. 简单性与正确性的平衡
volatile 用法简单但容易误用(如误用于计数);synchronized 用法稍显笨重但语义明确、不易出错。Java 的设计者通过提供这两种工具,让开发者可以根据场景在性能与安全性之间做出选择。
五、 总结
| 特性 | volatile | synchronized |
|---|---|---|
| 底层核心 | 内存屏障 + MESI 协议 | Monitor + 锁升级 (CAS/Mutex) |
| 原子性 | ❌ 不保证 | ✅ 保证 |
| 可见性 | ✅ 保证 | ✅ 保证 |
| 有序性 | ✅ 禁止重排 | ✅ 串行化保证 |
| 阻塞性 | 非阻塞 | 可能阻塞 |
| 适用场景 | 状态标志、DCL 单例 | 复合操作、互斥访问 |
理解 volatile 和 synchronized 不仅是掌握两个关键字,更是理解 JVM 如何在复杂的硬件架构之上,构建出一套既高效又安全的并发抽象体系。在实际开发中,应根据是否涉及复合操作、竞争程度高低来灵活选择,必要时也可结合 java.util.concurrent 包中的更高级工具(如 ReentrantLock、AtomicInteger)以获得更细粒度的控制。
评论区