为了解决 CPU 访问主存时主存读写性能的短板,在 CPU 中增加了高速缓存,但这带来了可见性问题。而 Java 的 volatile 关键字可以保证共享变量的主存可见性,也就是将共享变量的改动值立即刷新回主存。在正常情况下,系统操作并不会校验共享变量的缓存一致性,只有当共享变量用 volatile 关键字修饰了,该变量所在的缓存行才被要求进行缓存一致性的校验。

volatile 底层原理

通过 volatile 关键字的汇编代码分析 volatile 的底层原理。参考如下代码:

  1. public class VolatileVar {
  2. volatile int var = 0;
  3. public void setVar(int var) {
  4. System.out.println("setVar = " + var);
  5. this.var = var;
  6. }
  7. }

涉及到的汇编指令如下:
image.png
由于共享变量 var 加了 volatile 关键字,因此在汇编指令中,操作 var 之前多出一个 lock 前缀指令 lock addl,该 lock 前缀指令有三个功能:

  1. 将当前 CPU 缓存行的数据立即写回主内存

在对 volatile 修饰的共享变量进行写操作时,其汇编指令前用 lock 前缀修饰。lock 前缀指令使得在执行指令期间,CPU 可以独占共享内存(即主存)。对共享内存的独占,老的 CPU(如 Intel 486)通过总线锁方式实现。由于总线锁开销比较大,因此新版 CPU(如 IA-32、Intel 64)通过缓存锁实现对共享内存的独占性访问,缓存锁(缓存一致性协议)会阻止两个 CPU 同时修改共享内存的数据。

  1. lock 前缀指令会引起在其他 CPU 中缓存了该内存地址的数据无效

写回操作时要经过总线传播数据,而每个 CPU 通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当 CPU 发现自己缓存行对应的内存地址被修改时,就会将当前 CPU 的缓存行设置为无效状态,当 CPU 要对这个值进行修改的时候,会强制重新从系统内存中把数据读到 CPU 缓存。

  1. lock 前缀指令禁止指令重排

lock 前缀指令的最后一个作用是作为内存屏障(Memory Barrier)使用,可以禁止指令重排序,从而避免多线程环境下程序出现乱序执行的现象。

volatile 不具备原子性

volatile 能保证数据的可见性和有序性,但不能完全保证数据的原子性,对于 volatile 类型的变量进行复合操作(如++),其仍存在线程不安全的问题。

  1. public class VolatileTest {
  2. private volatile long value;
  3. private static VolatileTest test = new VolatileTest();
  4. private static CountDownLatch latch = new CountDownLatch(10);
  5. public static void main(String[] args) {
  6. for (int i = 0; i < 10; i++) {
  7. new Thread(() -> test.calculate()).start();
  8. }
  9. try {
  10. latch.await();
  11. } catch (InterruptedException e) {
  12. e.printStackTrace();
  13. }
  14. System.out.println(test.value);
  15. }
  16. private void calculate() {
  17. for (int i = 0; i < 1000; i++) {
  18. value++;
  19. }
  20. latch.countDown();
  21. }
  22. }

程序运行结果不是 10000,因此可以证明 i++ 不是原子操作。

volatile 变量的复合操作不具备原子性原理

首先回顾一下 JMM 对变量进行读取和写入的流程:
image.png
图 - JMM 对变量进行读取和写入的操作流程

对于非 volatile 修饰的普通变量而言,在读取变量时,JMM 要求保持 read、load 有相对顺序即可。例如,若从主存读取 i、j 两个变量,可能的操作是 read i=>readj=>load j=>load i,并不要求 read、load 操作是连续的。

但是对于 volatile 变量而言,具有两个重要语义:

  1. 使用 volatile 修饰的变量在变量值发生改变时,会立刻同步到主存,并使其他线程的变量副本失效
  2. 禁止指令重排序:用 volatile 修饰的变量在硬件层面上会通过在指令前后加入内存屏障来实现,编译器级别是通过下面的规则实现的

为了实现 volatile 的这些语义, JMM 对 volatile 变量会有特殊的约束:

  1. 使用 volatile 修饰的变量其 read、load、use 都是连续出现的,所以每次使用变量的时候都要从主存读取最新的变量值,替换私有内存的变量副本值(如果不同的话)
  2. 其对同一变量的 assign、store、write 操作都是连续出现的,所以每次对变量的改变都会立马同步到主存中

虽然 volatile 修饰的变量可以强制刷新内存,但是其并不具备原子性。虽然其要求对变量的(read、load、use)、(assign、store、write)必须是连续出现,但是在不同 CPU 内核上并发执行的线程还是有可能出现读取脏数据的时候。

假设有两个线程 A、B 分别运行在 Core1、Core2 上,并假设此时的 value 为0,线程 A、B 也都读取了 value 值到自己的工作内存。现在线程 A 将 value 变成 1 之后,完成了 assign、store 的操作,假设在执行 write 指令之前,线程 A 的 CPU 时间片用完,线程 A 被空闲,但是线程 A 的 write 操作没有到达主存。由于线程 A 的 store 指令触发了写的信号,线程 B 缓存过期,重新从主存读取到 value 值,但是线程 A 的写入没有最终完成,线程 B 读到的 value 值还是0。线程 B 执行完成所有的操作之后,将 value 变成 1 写入主存。线程 A 的时间片重新拿到,重新执行 store 操作,将过期了的 1 写入主存。

image.png
图 - 线程 A、B 并发操作 value 时可能发生脏数据写入的流程

对于复合操作,volatile 变量无法保障其原子性,如果要保证复合操作的原子性,就需要使用锁。并且,在高并发场景下,volatile 变量一定需要使用 Java 的显式锁结合使用。

什么时候使用 volatile

  1. 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是 assign—store—write 三步操作,这三步操作不是原子性的,而 volatile 不保证原子性
  2. 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为 volatile 的