在 Java 并发编程中,volatile 关键字是一个比较诡谲的关键字。它的适用场景不多,不如 synchronized 来得简单粗暴,但使用得当的话又是一把性能利器。如果要仔细剖析 volatile 的原理,需要较多而且细碎的背景知识,在本文中我们只介绍一些必要的内容。我们还是按照浅入深的层次,从 JMM 模型、字节码和 JVM 实现来剖析 volatile 关键字。

1. 背景知识

1.1 CPU 缓存结构

在 CPU 的运行过程中,必定会有很多读取和写入内存的操作,而物理内存的响应速度大约是 CPU 运行速度的百分之一,如果每次读写都跟物理内存交互的话,势必会影响整个程序运行效率。因此,CPU 在内部做了多级缓存,每次先从自己的缓存内存取数据,然后再写回主内存。

现代 CPU 的缓存结构大多是下面这样(图片来自《深入理解计算机系统》):

CPU 缓存

其中寄存器、L1 和 L2 在 CPU 内部,L3 及以上是 CPU 间共享的。每次 CPU 需要某个数据的时候都是先从寄存器开始找,如果找不到就再向上一级缓存查询。在本文中,为了方便讨论,我们将把这个模型简化为 CPU 内部缓存CPU 外部内存两部分。

1.2 缓存一致性

按照我们前面描述的 CPU 缓存结构,我们可以发现一个非常明显的问题。如果不同的 CPU 读取了同一块内存数据并存储在自己的高速缓存中,然后使用自己缓存内的数据进行操作,这显然会出现数据一致性的问题。

为了避免这样的问题,CPU 有两个主要的手段

  1. 在总线上加锁
  2. 缓存一致性协议

在总线上加锁是比较简单粗暴的办法,相当于直接占用了 CPU 内部缓存和外部内存的通道,效率较低,但也没有完全被抛弃,算是最终办法之一。

缓存一致性协议是比较通用的做法。目前市面上有各种不同的缓存一致性协议,其中最出名的是MESI 协议,也是 Intel 处理器采用的协议。

简单来说,MESI 协议就是在某个 CPU 操作共享变量的时候,同时通知其它 CPU 关于该变量的缓存都是无效的,以此来达到数据的一致性。

1.3 Java 内存模型(JMM)

Java 内存模型是由 JVM 规范制订的一个理想化模型,其设计初衷是屏蔽掉不同系统和硬件实现对 Java 内存访问的区别。我们这里主要讲三个基础概念:原子性可见性有序性

1.3.1 原子性

原子性指的是一个操作或者多个操作的集合,其结果要么全部成功要么全部失败

在 Java 中,对一个变量的读和写是最基本原子操作,例如:

int i = 0; // 原子操作, 将0写入变量i
int j = i; // 非原子操作,先读取i的值,再将i的值写入j
i++; // 非原子操作,先读取i的值,再将这个值+1后写入i
j = i + 1; // 非原子操作,先读取i的值,再将这个值+1后写入j

1.3.2 可见性

Java 内存模型的结构和前面讲到的 CPU 缓存结构类似。每个线程有自己的工作内存区域,同时线程间也会共享一片内存区域。每次线程去读取变量的时候,首先到自己的工作内存区域去查询该变量,如果找不到才会去共享内存区域读取,读取后会在自己的工作内存里创建一个该变量的副本。相应地,线程对变量的操作也是先操作自己工作内存里变量副本,然后再找时机写回主内存。

可见性指的是当多个线程访问操作同一个变量的时候,某个线程对变量的修改结果可以被其它线程看到

例如下面这段代码:

public class Test {

    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (flag) {

            }

            System.out.println("end");
        }).start();

        Thread.sleep(1000L);

        flag = false;
    }
}

如果允许这段代码,JVM 永远不会退出,并且也永远不会看到“end”打印。原因就是我们创建的线程内,一直使用的是 flag 变量的副本,而永远看不到主线程对 flag 的修改,因此永远不会输出 end 字样。

1.3.3 有序性

有序性的概念说起来比较简单,甚至有些理所当然。

有序性指的是程序的执行顺序是按照代码的先后顺序来执行的

但是这个概念起源是 CPU 优化有关的。在 CPU 运行时,出于提高效率的原因,如果两条指令前后没有依赖关系,则 CPU 可以选择指令执行的顺序。在 Java 内存模型中,也允许 CPU 对指令进行优化重排序的,这对单线程运行的程序没有影响,但是多线程环境下就有可能出现问题。

我们来看这段代码:

public class Test {

    private static int x = 0;
    private static int y = 0;
    private static int a = 0;
    private static int b = 0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;

        while (true) {
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            Thread t1 = new Thread(() -> {
                b = 1; // 1
                y = a; // 2
            });

            Thread t2 = new Thread(() -> {
                a = 1; // 3
                x = b; // 4
            });

            t1.start();t2.start();
            t1.join();t2.join(); // 等待两个线程执行结束

            if (x == 0 && y == 0) {
                System.out.println("i: " + i);
                break;
            }
            i++;
        }
    }
}

在我们创建的线程内,操作 1 和操作 2 没有依赖关系,同理 3 和 4 也没有。若不存在重排序现象,则操作 1234 的相对顺序应该不变,即 1 永远先于 2,3 永远先于 4。那么不论如何排列,两个线程执行结束后,x 和 y 的值绝不可能同时为 0,程序也永远不会退出。

然鹅实际来运行程序的时候,程序是可以结束的。笔者运行了多次,结束时 i 的值从几千到数十万不等。从而我们证明了重排序现象的存在。

2. volatile 关键字的实现原理

2.1 JMM 层面

从 JMM 模型层面来说,volatile 关键字其实是保证了变量访问时的可见性有序性

我们重新回看一下前面两段程序,如果我们把类变量都增加 volatile 关键字,那么:

  1. 第一段程序会在 1s 后退出并打印“end”
  2. 第二段程序永远也不会退出

说明 volatile 关键字是分别保证了可见性和有序性。

那么在 JMM 中是如何保证这两点特性的呢?

对于可见性,JMM 规定线程内变量副本无效并且对变量的写操作立即刷入主内存。这个比较好理解,其实本质就是不再使用本地缓存,线程直接操作原始数据。

对于有序性,JMM 规定在volatile 操作前后需要加入内存屏障

这里就需要解释一下什么是内存屏障了。

内存屏障就如同它的名字一样,是一道屏障,屏障前后的操作不可以重排序。内存屏障有以下 4 种:

  • LoadLoad 屏障,读操作与读操作不可重排序。
  • LoadStore 屏障,读操作与写操作不可重排序。
  • StoreLoad 屏障,写操作与读操作不可重排序。
  • StoreStore 屏障,写操作与写操作不可重排序。

具体在 volatile 这里,内存屏障是这样加入的:

  • LoadLoad -> volatile 读 -> LoadStore
  • StoreStore -> volatile 写 -> StoreLoad

这样可以保证 volatile 的读写不被重排序。

2.2 字节码层面

字节码层面的 volatile 实现是比较简单的,只是相比普通变量,插入了一条字节码指令。

我们看下面这个类:

public class Test {
    volatile int x;
    int y;
}

我截取了该类字节码文件的一部分

{
  volatile int x;
    descriptor: I
    flags: ACC_VOLATILE

  int y;
    descriptor: I
    flags:

  public Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LTest;
}

在第三行,相比于 y,x 变量的 flags 增加了一条ACC_VOLATILE

2.3 JVM 层面

我们前面讲到 JMM 是如何规范 volatile 的读写操作的,那么具体到 JVM 中,又是如何实现这些内存屏障的呢?

我们看bytecodeinterpreter.cpp中的一个片段(原函数很长,就不全贴了)

//
// Now store the result on the stack
//
TosState tos_type = cache->flag_state();
int field_offset = cache->f2_as_index();
if (cache->is_volatile()) {
  if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
    OrderAccess::fence();
  }
  if (tos_type == atos) {
    VERIFY_OOP(obj->obj_field_acquire(field_offset));
    SET_STACK_OBJECT(obj->obj_field_acquire(field_offset), -1);
  } else if (tos_type == itos) {
  //。。。other code        

我们可以看到第三行有一个判断is_volatile(),就是在查看变量是否加了 volatile 修饰。接着后面就有一个调用OrderAccess::fence()。这里的 fence 就是屏障的意思。

当然不同的系统和硬件会有不同实现的方式,我们以 Linux 系统为例,在orderAccess_linux_x86.inline.hpp文件中可以找到 fence()函数。

inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

可以看出,屏障的实现就是依赖于这样一条汇编指令:

lock; addl

3. 总结一下

volatile 关键字可以实现轻量级的线程同步。在虚拟机规范中,volatile 前后加入了内存屏障以保证可见性有序性。内存屏障在 JVM 或者汇编层面,就靠一条lock;addl指令。如果再到硬件层面的话,会有 CPU 的缓存一致性协议(比如 MESI 协议)作为支撑。

4. 写在最后

Java 高并发与多线程系列暂时告一段落了,当然本身相关的话题还有很多,线程池、AQS 和 JUC 包等等等等,以后再有空闲的时候或许会继续更吧。茫茫软件行业,笔者只是 Java 小白一枚,如在文中出现任何逻辑或知识性错误,我在此表示抱歉并欢迎各位读者留言指正。本系列内容主要参考马士兵老师的网课《多线程与高并发》,在此也感谢马老师的视频。