深度理解 volatile 与 synchronized:并发编程的两把钥匙
在 Java 并发编程中,volatile和synchronized是保证线程安全的两大核心关键字。它们如同两把钥匙,分别应对不同场景下的并发问题,但很多开发者对其底层原理和适用场景一知半解,导致使用时频繁踩坑。本文将从原理到实践,全面解析这两个关键字的本质区别与协同作用。
一、volatile:轻量级的可见性保证
volatile是 Java 提供的最轻量级的同步机制,它的核心作用是保证变量的 "可见性" 和 "有序性",但不保证原子性。
1. 什么是可见性?
在多线程环境中,每个线程都有自己的工作内存(高速缓存的抽象),变量的读取和修改会先在工作内存中进行,再同步到主内存。当一个线程修改了共享变量的值,其他线程可能因未及时读取主内存的新值而导致数据不一致 ------ 这就是 "可见性问题"。
volatile的作用正是强制线程每次读取变量时都从主内存获取最新值,修改后立即同步回主内存,确保所有线程看到的变量值是一致的。
arduino
public class VolatileDemo {
// 用volatile修饰共享变量
private static volatile boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!flag) {
// 循环等待flag变为true
}
System.out.println("线程1检测到flag变化");
}).start();
Thread.sleep(1000);
// 主线程修改flag
flag = true;
System.out.println("主线程修改flag为true");
}
}
若flag不加volatile,线程 1 可能永远无法退出循环(因未感知到主内存的变化);加了volatile后,线程 1 会立即感知到变化并退出。
2. 什么是有序性?
有序性指程序执行的顺序与代码顺序一致。但编译器或 CPU 为了优化性能,可能对指令进行 "重排序",在单线程中这没问题,但多线程中可能导致逻辑错误。
volatile通过禁止指令重排序保证有序性。例如,在双重检查锁单例模式中,volatile修饰的实例变量可避免因重排序导致的空指针问题:
csharp
public class Singleton {
// 必须用volatile修饰,防止指令重排序
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
// 若不加volatile,可能发生"半初始化"问题
instance = new Singleton();
}
}
}
return instance;
}
}
new Singleton()可分解为 3 步:分配内存→初始化对象→赋值给引用。若发生重排序,可能出现 "赋值先于初始化",导致其他线程拿到未初始化的对象。volatile禁止了这种重排序。
3. volatile 的局限性:不保证原子性
volatile无法解决多线程对变量的 "复合操作" 原子性问题。例如i++看似简单,实则包含 "读取 - 修改 - 写入" 三步,多线程并发时仍会出现数据不一致:
ini
public class VolatileAtomicDemo {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
count++; // 非原子操作,volatile无法保证线程安全
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count最终值:" + count); // 可能小于2000
}
}
上述代码中,count加了volatile,但最终结果仍可能小于 2000,因为count++的三步操作可能被线程交替执行。
二、synchronized:全能型的同步锁
synchronized是 Java 中最常用的重量级同步机制,它通过 "加锁 - 解锁" 的方式,保证同一时刻只有一个线程能执行特定代码块,从而解决可见性、有序性和原子性问题。
1. synchronized 的三种用法
- 修饰实例方法:锁是当前对象实例(this)。
- 修饰静态方法:锁是当前类的 Class 对象。
- 修饰代码块:锁是 synchronized(lockObj)中的lockObj。
csharp
public class SynchronizedDemo {
// 1. 修饰实例方法
public synchronized void instanceMethod() {
// 临界区代码
}
// 2. 修饰静态方法
public static synchronized void staticMethod() {
// 临界区代码
}
// 3. 修饰代码块
public void blockMethod() {
synchronized (this) { // 锁对象为当前实例
// 临界区代码
}
}
}
2. synchronized 的底层原理:对象头与监视器锁
synchronized的实现依赖于对象头中的 Mark Word 和监视器锁(Monitor) :
- Mark Word:存储对象的锁状态(无锁、偏向锁、轻量级锁、重量级锁),是实现锁升级的关键。
- Monitor:操作系统提供的同步机制,重量级锁会关联一个 Monitor 对象,线程通过竞争 Monitor 的所有权获得执行权。
锁升级过程(JDK 6 + 的优化):
- 无锁状态:对象刚创建时,无锁。
- 偏向锁:若只有一个线程多次获取锁,会记录线程 ID,避免每次加锁解锁的开销。
- 轻量级锁:当有多个线程竞争时,偏向锁升级为轻量级锁,通过 CAS 操作尝试获取锁。
- 重量级锁:当 CAS 失败(竞争激烈),升级为重量级锁,依赖操作系统的互斥量,开销最大。
3. synchronized 的内存语义
synchronized不仅保证原子性,还隐含着与volatile类似的内存语义:
- 进入同步块:相当于对变量加锁,线程会清空工作内存,从主内存读取最新值。
- 退出同步块:相当于对变量解锁,线程会将工作内存的修改同步回主内存,其他线程可见。
这意味着synchronized天然解决了可见性问题,同时因互斥执行保证了有序性。
三、volatile 与 synchronized 的核心区别
特性 | volatile | synchronized |
---|---|---|
原子性 | 不保证(仅修饰单个变量的读 / 写) | 保证(临界区代码的原子执行) |
可见性 | 保证(强制读写主内存) | 保证(解锁时同步主内存) |
有序性 | 保证(禁止重排序) | 保证(互斥执行 + 隐含内存屏障) |
性能开销 | 轻量级(无锁) | 重量级(可能升级为 Monitor 锁) |
使用场景 | 多线程读、单线程写的变量 | 多线程读写的临界区代码 |
是否可中断 | 不可中断 | 不可中断(除非设置超时) |
是否可重入 | 无锁概念,不存在重入 | 可重入(同一线程可重复获取锁) |
四、实践中的选择与协同
1. 何时用 volatile?
- 变量由单个线程修改,多个线程读取(如状态标记位)。
- 需禁止指令重排序(如单例模式的双重检查锁)。
- 替代synchronized以减少性能开销(仅适用于简单场景)。
2. 何时用 synchronized?
- 涉及复合操作(如i++、多变量修改)。
- 需保证代码块的原子执行(如转账、库存扣减)。
- 多线程同时读写共享资源的场景。
3. 两者协同的典型案例
在生产者 - 消费者模式中,volatile可用于标记队列状态(空 / 满),synchronized用于保证队列操作的原子性:
arduino
public class ProducerConsumer {
private final Queue<Integer> queue = new LinkedList<>();
private static final int MAX_SIZE = 10;
// 用volatile标记队列状态(也可通过synchronized实现,但前者更轻量)
private volatile boolean isRunning = true;
public void produce(int value) throws InterruptedException {
synchronized (queue) {
while (queue.size() == MAX_SIZE) {
queue.wait(); // 队列满时等待
}
queue.add(value);
queue.notifyAll(); // 通知消费者
}
}
public int consume() throws InterruptedException {
synchronized (queue) {
while (queue.isEmpty()) {
queue.wait(); // 队列空时等待
}
int value = queue.poll();
queue.notifyAll(); // 通知生产者
return value;
}
}
public void stop() {
isRunning = false; // 线程安全的状态修改
}
}
五、常见误区与最佳实践
误区 1:volatile 能替代 synchronized
错。volatile仅解决可见性和有序性,无法保证原子性。对于i++这类复合操作,必须用synchronized或原子类(如AtomicInteger)。
误区 2:synchronized 会导致性能暴跌
不完全对。JDK 6 后引入的锁升级机制(偏向锁、轻量级锁)大幅降低了synchronized的开销,在低竞争场景下性能接近volatile。
误区 3:所有共享变量都要加 volatile 或 synchronized
不必。仅当变量被多个线程同时访问且至少有一个线程修改时,才需要同步机制。
最佳实践:
- 优先使用更轻量的方案:能用volatile解决的场景(如状态标记),就不用synchronized。
- 减少同步范围:synchronized代码块应尽可能小,只包含必要的临界区代码。
- 避免嵌套锁:嵌套synchronized可能导致死锁,如需多层同步,需严格控制锁的获取顺序。
- 结合 JUC 工具类:高并发场景下,ReentrantLock、Atomic系列可能比synchronized更灵活。
六、总结
volatile和synchronized是 Java 并发编程的基础,二者并非对立关系,而是互补的工具:
- volatile是 "轻骑兵",适用于简单的可见性和有序性需求,开销小但功能有限。
- synchronized是 "重装甲",能解决复杂的原子性问题,功能全面但开销较高(优化后可接受)。
理解它们的底层原理(内存模型、锁机制)是正确使用的前提。在实际开发中,应根据场景选择合适的工具,必要时让它们协同工作,才能写出高效且安全的并发代码。
记住:没有最好的同步机制,只有最适合的场景。深入理解并发的本质,才能在多线程世界中游刃有余。