1. 引言

在Java并发编程中,volatilesynchronized是两个最基础也最常用的关键字。它们都涉及线程间的内存可见性,但底层实现和适用场景截然不同。本文将从JMM(Java内存模型)出发,深入分析volatile的可见性保证机制,以及synchronized在JDK 6之后的锁升级路径,帮助读者理解两者的本质区别与最佳实践。

2. volatile的可见性保证

2.1 问题背景:缓存不一致

在多核CPU架构下,每个线程可能将共享变量缓存在自己的CPU缓存中,导致一个线程对变量的修改对其他线程不可见。这就是经典的"缓存一致性"问题。

2.2 volatile的语义

当一个共享变量被volatile修饰时,JVM会保证:

  • 写可见性:对一个volatile变量的写操作,会立即刷新到主内存中。
  • 读可见性:对一个volatile变量的读操作,会从主内存中重新读取,而不是从CPU缓存中读取。
  • 禁止指令重排序:volatile变量的读写操作前后会插入内存屏障,阻止编译器或CPU对指令进行重排序优化。

2.3 内存屏障的实现

在JVM层面,volatile的读写通过插入内存屏障(Memory Barrier)来实现:

  • 写操作:在写操作之后插入StoreStore屏障和StoreLoad屏障,确保写操作的结果对其他处理器可见。
  • 读操作:在读操作之前插入LoadLoad屏障和LoadStore屏障,确保读取的是最新值。

以x86架构为例,volatile写操作实际上对应一条lock addl $0x0, (%rsp)指令,该指令会触发缓存行失效(cache line invalidation),从而保证其他CPU核心能立即看到最新值。

2.4 volatile的局限性

volatile虽然保证了可见性,但不保证原子性。例如:

public class Counter {
    private volatile int count = 0;
    
    public void increment() {
        count++; // 非原子操作:读-改-写三步
    }
}

上述count++包含"读取-修改-写入"三个步骤,volatile只能保证每一步的可见性,但无法阻止多个线程同时执行这三步导致的竞态条件。因此,volatile适合一写多读的场景,不适合需要复合操作的场景。

3. synchronized的锁升级路径

3.1 传统synchronized的痛点

在JDK 6之前,synchronized是重量级锁,直接依赖操作系统的mutex互斥量实现,线程阻塞和唤醒需要在内核态和用户态之间切换,开销极大。

3.2 锁升级的四个阶段

JDK 6引入了锁升级机制,根据竞争激烈程度,锁会经历以下四个状态的升级(不可逆):

  1. 无锁:对象刚创建,没有线程竞争。
  2. 偏向锁:只有一个线程访问同步块时,通过CAS在对象头的Mark Word中记录线程ID,避免真正的加锁操作。
  3. 轻量级锁:当第二个线程尝试获取偏向锁时,偏向锁撤销,升级为轻量级锁。通过CAS自旋尝试获取锁,避免线程阻塞。
  4. 重量级锁:当自旋超过一定次数(或自旋线程数超过CPU核数的一半)时,升级为重量级锁,线程进入阻塞状态。

3.3 对象头与Mark Word

锁状态信息存储在Java对象的Mark Word中(32位JVM占32位,64位JVM占64位)。不同锁状态下Mark Word的布局如下:

锁状态 Mark Word内容(32位JVM)
无锁 hashCode + 分代年龄 + 001
偏向锁 线程ID + epoch + 分代年龄 + 101
轻量级锁 指向栈中锁记录的指针 + 00
重量级锁 指向互斥量(monitor)的指针 + 10

3.4 锁升级的触发条件

  • 偏向锁 → 轻量级锁:另一个线程尝试获取偏向锁时,偏向锁撤销,升级为轻量级锁。
  • 轻量级锁 → 重量级锁:自旋等待超过阈值(默认10次,可通过-XX:PreBlockSpin调整),或自旋线程数超过CPU核数的一半。
  • 偏向锁的批量重偏向与批量撤销:当某个类的对象频繁发生偏向锁撤销时,JVM会触发批量重偏向(默认20次)或批量撤销(默认40次),避免频繁撤销带来的性能损耗。

3.5 锁消除与锁粗化

除了锁升级,JVM还会在编译期进行两种优化:

  • 锁消除:如果JVM通过逃逸分析发现同步块中的对象不会被其他线程访问,则直接消除锁。
  • 锁粗化:如果JVM检测到同一个对象在一段连续代码中被反复加锁解锁,会将多个锁合并为一个更大范围的锁,减少加锁解锁次数。

4. volatile与synchronized的对比

特性 volatile synchronized
可见性 保证 保证
原子性 不保证 保证
禁止指令重排序 保证 保证(通过锁的互斥语义)
性能开销 低(仅内存屏障) 较高(锁升级路径,但轻量级锁下开销可控)
适用场景 一写多读、状态标志、double-checked locking 需要原子性操作的临界区

5. 实战建议

  • 优先使用volatile:如果只需要保证可见性,且操作是原子的(如对boolean标志的赋值),优先使用volatile。
  • 使用synchronized保护复合操作:对于count++check-then-act等复合操作,必须使用synchronized或java.util.concurrent包中的原子类。
  • 避免过度优化:不要手动禁用偏向锁(-XX:-UseBiasedLocking),除非经过性能测试确认偏向锁带来了负面影响。
  • 关注锁升级的监控:通过-XX:+PrintSafepointStatistics-XX:+PrintGCApplicationStoppedTime等JVM参数,可以观察锁升级导致的STW(Stop-The-World)暂停。

6. 总结

volatile通过内存屏障保证了可见性和有序性,但无法保证原子性;synchronized通过锁升级路径(偏向锁→轻量级锁→重量级锁)在低竞争场景下实现了接近volatile的性能,在高竞争场景下则退化为操作系统级别的互斥锁。理解两者的底层机制,有助于在并发编程中做出正确的选择。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐