synchronized 关键字和 volatile 关键字的区别
一、前言
在 Java 并发编程中,synchronized 和 volatile 是两个极其重要的关键字,它们都与多线程间的内存可见性和线程安全息息相关。然而,它们的作用机制、适用场景以及底层实现有着本质的区别。本文将深入剖析这两个关键字的异同,帮助读者在实际开发中做出正确的选择。
二、Java 内存模型(JMM)基础
在理解 synchronized 和 volatile 之前,必须先了解 Java 内存模型(Java Memory Model,JMM)。
JMM 规定了:
- 所有共享变量存储在主内存(Main Memory)中。
- 每个线程拥有自己的工作内存(Working Memory),线程对变量的所有操作都必须在工作内存中进行,不能直接操作主内存。
- 不同线程之间无法直接访问对方的工作内存,线程间变量值的传递需要通过主内存完成。
这种模型导致了三大并发问题:
- 可见性:一个线程修改了共享变量,另一个线程能否立即看到修改?
- 原子性:多个操作是否会被中断?
- 有序性:编译器/处理器重排序是否会导致意外结果?
三、synchronized 关键字详解
3.1 基本概念
synchronized 是 Java 提供的一种互斥锁(Mutex)机制,它保证同一时刻最多只有一个线程可以执行被修饰的代码块或方法。
3.2 使用方式
// 1. 修饰实例方法 —— 锁是当前实例对象
public synchronized void instanceMethod() {
// 线程安全的操作
}
// 2. 修饰静态方法 —— 锁是当前类的 Class 对象
public static synchronized void staticMethod() {
// 线程安全的操作
}
// 3. 修饰同步代码块 —— 锁是括号内的对象
public void blockMethod() {
synchronized (this) {
// 线程安全的操作
}
}
3.3 三大特性保证
| 特性 | synchronized 的支持情况 |
|---|---|
| 可见性 | ✅ 保证。线程释放锁前,会将工作内存中的共享变量刷新到主内存;线程获取锁时,会清空工作内存中共享变量的值,从主内存重新读取。 |
| 原子性 | ✅ 保证。被 synchronized 包裹的代码块是原子操作,不会被其他线程中断。 |
| 有序性 | ✅ 保证。synchronized 内的代码通过"同一锁同一时刻只有一个线程执行"来保证有序性,内部的代码也可能重排序,但由于单线程语义,结果一致(as-if-serial)。 |
3.4 底层实现
synchronized 在 JVM 层面通过 Monitor(监视器锁) 实现。在对象头中存储锁标记,依赖操作系统底层的 Mutex Lock 实现,因此是重量级操作。JDK 6 以后引入了锁升级机制:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
四、volatile 关键字详解
4.1 基本概念
volatile 是 Java 提供的轻量级同步机制,它主要用于保证共享变量的可见性和有序性,但不保证原子性。
4.2 使用方式
public class VolatileExample {
// 保证可见性:volatile 变量的修改会立即同步到主内存
private volatile boolean flag = false;
public void writer() {
flag = true; // 写操作
}
public void reader() {
if (flag) { // 读操作
// 一定能看到 writer 线程的修改
}
}
}
4.3 三大特性保证
| 特性 | volatile 的支持情况 |
|---|---|
| 可见性 | ✅ 保证。对 volatile 变量的写操作,会立即刷新到主内存;读操作会从主内存重新读取,而不是使用工作内存中的缓存值。 |
| 原子性 | ❌ 不保证。对 volatile 变量的复合操作(如 count++、count = count + 1)不是原子操作。 |
| 有序性 | ✅ 保证。通过**内存屏障(Memory Barrier)**禁止指令重排序。 |
4.4 底层实现
volatile 通过在指令序列中插入内存屏障来实现:
- 写操作:在写操作后插入 StoreStore 屏障 + StoreLoad 屏障,保证 volatile 写之前的所有普通写操作已经同步到内存,并且 volatile 写之后的代码不会重排序到写之前。
- 读操作:在读操作前插入 LoadLoad 屏障 + LoadStore 屏障,保证 volatile 读之后的所有普通读操作不会重排序到读之前。
4.5 volatile 的典型使用场景
// 场景一:状态标志位
volatile boolean shutdown = false;
// 线程1
public void shutdown() { shutdown = true; }
// 线程2
public void run() {
while (!shutdown) {
// do work
}
}
// 场景二:Double-Checked Locking(单例模式)
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
五、核心区别对比
| 对比维度 | synchronized | volatile |
|---|---|---|
| 性质 | 互斥锁(重量级) | 轻量级同步机制 |
| 可见性 | 保证(通过锁的释放/获取) | 保证(通过内存屏障) |
| 原子性 | 保证(代码块级原子性) | 不保证(仅保证读写操作的单个原子性) |
| 有序性 | 保证(as-if-serial) | 保证(禁止指令重排序) |
| 是否阻塞 | 是(线程阻塞/唤醒) | 否(线程不阻塞) |
| 是否可修饰 | 方法、代码块 | 仅变量 |
| 开销 | 较高(涉及线程上下文切换) | 较低(无锁,仅内存屏障) |
| 能否修饰 null | 可以锁定任意对象 | 不能修饰 null 值 |
六、代码对比示例
6.1 volatile 无法保证原子性
public class VolatileNotAtomic {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++; // 非原子操作:读-改-写
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("count = " + count);
// 期望值:10000,实际值可能小于 10000
}
}
输出示例(每次运行可能不同):
count = 9987
6.2 synchronized 保证原子性
public class SynchronizedAtomic {
private static int count = 0;
public static synchronized void increment() {
count++; // 原子操作
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
increment();
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("count = " + count);
// 始终输出:10000
}
}
输出(始终正确):
count = 10000
6.3 volatile 保证可见性
public class VolatileVisibility {
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("工作线程开始执行...");
while (running) {
// volatile 保证能及时看到主线程的修改
}
System.out.println("工作线程收到停止信号,退出。");
});
worker.start();
Thread.sleep(1000);
running = false; // 主线程修改标志位
System.out.println("主线程已发送停止信号。");
}
}
输出:
工作线程开始执行...
主线程已发送停止信号。
工作线程收到停止信号,退出。
注意:如果去掉
volatile,工作线程可能永远无法看到running的修改,从而陷入死循环。
七、常见面试问题
Q1: synchronized 和 volatile 有什么区别?
答: 这是面试中最基础的问题。主要区别如下:
- 性质不同:
synchronized是互斥锁,volatile是轻量级同步机制。 - 原子性:
synchronized保证代码块的原子性;volatile不保证复合操作的原子性。 - 使用范围:
synchronized可修饰方法和代码块;volatile只能修饰变量。 - 阻塞:
synchronized会导致线程阻塞;volatile不会。 - 开销:
synchronized开销较大(涉及锁竞争、线程上下文切换);volatile开销较小。 - 底层实现:
synchronized基于 Monitor 和操作系统 Mutex;volatile基于内存屏障。
Q2: volatile 能保证原子性吗?如果不能,请举例说明。
答: 不能。volatile 只能保证单个读/写操作的原子性,但无法保证 count++ 这类"读-改-写"复合操作的原子性。例如 count++ 实际上包含三个步骤:
- 读取
count的值 - 将
count + 1 - 将结果写回
count
这三个步骤之间可能被其他线程中断,导致数据不一致。具体示例见上文 6.1 volatile 无法保证原子性。
Q3: volatile 和 synchronized 各自是如何保证可见性的?
答:
- synchronized:在线程进入 synchronized 代码块时,会清空工作内存中共享变量的值,强制从主内存重新加载;在线程退出 synchronized 代码块时,会将对共享变量的修改刷新到主内存中。
- volatile:对 volatile 变量的写操作会立即刷新到主内存,读操作会直接从主内存读取,而不是从工作内存的缓存中读取。
Q4: 什么场景下应该用 volatile 而不是 synchronized?
答: 以下场景优先考虑 volatile:
- 状态标志位:如
volatile boolean shutdown,只有一个线程写,多个线程读。 - Double-Checked Locking 单例模式:防止指令重排序导致返回未完全初始化的对象。
- 纯赋值操作:如
volatile int x = 1,不依赖当前值的复合操作。 - 读多写少且写操作不依赖当前值的场景。
如果涉及复合操作(如 count++)或需要保证多个操作的原子性,则必须使用 synchronized 或 Lock。
Q5: volatile 关键字的内存屏障是如何工作的?
答: volatile 通过在指令序列中插入内存屏障来禁止指令重排序:
-
volatile 写操作:
- 在写操作前插入 StoreStore 屏障,禁止前面的普通写与后面的 volatile 写重排序。
- 在写操作后插入 StoreLoad 屏障,禁止 volatile 写与后面的读操作重排序,并保证写入对其他线程可见。
-
volatile 读操作:
- 在读操作后插入 LoadLoad 屏障,禁止后面的普通读与 volatile 读重排序。
- 在读操作后插入 LoadStore 屏障,禁止后面的写操作与 volatile 读重排序。
Q6: 为什么 DCL 单例模式中 instance 必须加 volatile?
答: 因为 instance = new Singleton() 在 JVM 层面不是原子操作,它大致分为三步:
- 分配内存空间
- 初始化对象(调用构造方法)
- 将引用指向分配的内存地址
如果不加 volatile,JVM 可能将步骤 2 和 3 重排序为 1→3→2,导致另一个线程在步骤 3 之后、步骤 2 之前读取到 instance 不为 null,从而返回一个还未构造完成的对象,引发问题。volatile 通过禁止指令重排序保证了先初始化后赋值。
private static volatile Singleton instance;
Q7: synchronized 在 JDK 6 之后有哪些优化?
答: JDK 6 对 synchronized 进行了大量优化,引入了:
- 偏向锁:无竞争时,偏向第一个获得锁的线程,减少 CAS 开销。
- 轻量级锁:通过 CAS 自旋获取锁,避免线程阻塞。
- 锁消除:JIT 编译器检测到不可能存在共享数据竞争时,消除锁。
- 锁粗化:将多个连续的加锁-解锁操作合并为一个更大的锁范围。
- 自适应自旋:根据上次自旋的成功率动态调整自旋次数。
锁的升级过程为:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,且锁只能升级不能降级。
Q8: 有了 synchronized 为什么还需要 volatile?
答: 虽然 synchronized 能同时保证可见性、原子性和有序性,但它在某些场景下过于"重量级":
- 性能开销:synchronized 涉及线程阻塞、上下文切换,而 volatile 不需要。
- 使用简便:volatile 只需在变量前加关键字,无需包裹代码块。
- 死锁风险:synchronized 使用不当可能导致死锁,volatile 没有锁自然没有死锁问题。
- 适用场景不同:对于单纯需要可见性的场景(如状态标志位),volatile 是最轻量、最合适的选择。
八、总结
| 维度 | synchronized | volatile |
|---|---|---|
| 可见性 | ✅ JMM 规范保证 | ✅ 内存屏障保证 |
| 原子性 | ✅ 代码块级别 | ❌ 仅单次读写 |
| 有序性 | ✅ as-if-serial | ✅ 禁止重排序 |
| 阻塞 | ✅ 会阻塞 | ❌ 不会阻塞 |
| 开销 | 较高 | 较低 |
| 适用场景 | 复合操作、互斥访问 | 状态标志位、可见性保障 |
选择建议:
- 需要原子性或互斥访问 → 使用
synchronized - 需要可见性且操作是单一赋值 → 使用
volatile - 仅靠
volatile无法保证线程安全时,不要犹豫,使用synchronized、Lock或Atomic类
参考资料:
- 《Java 并发编程的艺术》
- 《深入理解 Java 虚拟机》(第3版)
- Oracle Java Language Specification