volatile 的实现原理

并发编程中经常会使用到 volatile 关键字,使用该关键字修饰的共享变量可以保证多线程之间的可见性,也就是说当一个线程修改了变量的值,另一个线程能够读到修改后的值。

volatile 是怎么实现的呢?这里我们从一个应用场景说起:

volatile 在 DCL 中的应用

volatile 的一个比较经典的场景是在 DCL 中防止获取到未初始化的单例对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {
private static volatile Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
// 如果为空则new一个
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}

return instance;
}
}

在这个场景中,如果不用 volatile 修饰 instance 变量,那么可能会导致出现以下情况:

  1. 由于重排序使得第一个线程先将未初始化的 Singleton 对象赋值给 instance,然后再执行初始化
  2. 另一个线程在获取单例对象时,instance 已经不为 null,所以可以直接获取到并进行后续操作,然而此时 instance 还未初始化。

很明显加了 volatile 后禁止了特定的重排序,使得程序能够正常执行。

那么什么是重排序?重排序后对程序会有哪些影响呢?

重排序的影响

重排序是指编译器处理器为了优化程序性能而对指令序列进行的重新排序。

重排序会导致实际执行的指令顺序与代码中定义的顺序不一致。

重排序的分类

重排序包括两种:编译器重排序和处理器重排序,这可以从代码编译到执行的过程来理解。

一段 Java 代码从编译到最终执行会经历 3 种重排序:编译器重排序、指令重排序、内存重排序,其中后两者可归类为处理器重排序。

  1. 编译器重排序: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

    也就是说不影响单线程的执行结果下,可以对语句的执行顺序进行调整。

  2. 指令重排序: 如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  3. 内存重排序: 由于缓存的存在,使得处理器执行的加载和存储操作看上去可能是在乱序执行。

    比如对一个变量执行了写操作写到了自己的缓存中,又经历了若干个指令后刷新到内存。对外表现上是在这若干个指令执行完后才进行了写操作。

其中数据依赖的含义为:如果两个操作访问同一个变量,其中一个为写操作或者两个都为写操作,那么这两个操作就存在数据依赖性。

重排序对单线程的影响

重排序不会改变单线程程序的执行结果,所有的重排序都必须遵守 as-if-serial 语义

as-if-serial 语义

as-if-serial 语义是指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

可以从以下两方面去理解:

  1. 对于存在数据依赖关系的操作,编译器和处理器不会进行重排序,否则会改变执行结果。

  2. 对于不存在数据依赖关系的操作则不做要求,就是说可以进行重排序。

重排序对多线程的影响

as-if-serial 语义保证了单线程情况下,重排序后的执行结果与原始顺序执行结果一致。但是它不能保证多线程的执行顺序。

多线程情况下,对不存在数据依赖的操作重排序,也会影响执行结果。

比如如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
}
}
}

这里 1 和 2 不存在数据依赖,可以进行重排序,3 和 4 也不存在数据依赖,同样可以进行重排序(通过猜测提前计算出 a * a 的值并缓存起来)。

假设 writer() 和 reader() 是在两个线程中执行,1 和 2 进行了重排序,则 reader() 可能会读到 writer() 还未写入的变量 a(也就是 1 执行前的值)。

总结

重排序不会改变单线程的执行结果,但是对于多线程的情况则会导致执行结果不可预知

解决重排序引起的并发问题

要解决重排序引起的并发问题,很显然需要对重排序加以限制,使得其遵守一定的规范。重排序之所以导致多线程执行存在问题,主要是因为存在数据竞争。

数据竞争

数据竞争是指未同步的情况下,多个线程同一个变量的读写竞争,它的定义如下:

  1. 在一个线程中写一个变量
  2. 在另一个线程中读同一个变量
  3. 上述两个操作未正确同步

如果存在数据竞争,程序的执行结果是没有保证的。

相反,如果程序是正确同步的,就不存在数据竞争,程序的执行将具有顺序一致性,执行结果与在顺序一致性内存模型中的执行结果相同

顺序一致性内存模型

所谓顺序一致性内存模型,其实是一个理论参考模型,它对指令的执行加了比较多的限制,从而提供了一个比较强的内存可见性保证。

从抽象层面来看,顺序一致性模型使用了一个全局性的内存。

这个全局内存可以通过一个开关连接到任意一个线程,确保了线程之间的有序性。

每一个线程必须按照程序的顺序来执行内存读/写操作,从而保证了线程内部的有序性。

顺序一致性内存模型

顺序一致性模型使得所有内存读/写操作串行化。它有以下两个特点:

  1. 一个线程中的所有操作必须按照程序的顺序来执行。

  2. 不管程序是否正确同步,所有线程都只能看到同一个操作执行顺序。每个操作都必须原子执行且立刻对所有线程可见

假设有两个线程 A 和 B 并发执行。其中 A 线程的顺序是:A1→A2→A3,B线程的顺序是:B1→B2→B3

正确同步的程序

在顺序一致性内存模型中,每个线程的执行顺序都是有序的,整体的执行顺序也是有序的。

如下是一种可能的执行顺序:其中 A 线程先进入临界区,按顺序执行完所有指令并退出临界区,然后 B 线程进入临界区按顺序执行所有指令,执行完后退出临界区。两个线程看到的是同样的执行顺序

使用监视器锁同步

未同步的程序

在顺序一致性模型中,整体执行顺序是无序的,但所有线程都只能看到同一个整体执行顺序。

如下所示是一种可能的执行顺序:每个线程内部的指令都是按照顺序执行的。从整体上看是无序的,两个线程的指令执行存在交叉。但是对于线程 A 和 B,它们看到的是同样的整体执行顺序。

JMM 的思路

顺序一致性模型对读写操作的限制比较严,编译器和处理器难以针对执行效率进行优化。

JMM 一方面要提供足够强的内存可见性保证,使得易于编写正确的并发程序。另一方面又要尽可能放松对编译器和处理器的限制,以便利用更多的执行效率优化手段。

针对上面的情况,看下 JMM 是如何处理的。

正确同步的程序

在 JMM 中,和顺序一致性内存模型不同的是,每个线程内部(也就是临界区内部)是允许重排序,比如 A1、A2、A3 在线程 A 的同步块内,允许重排序。但是不允许临界区内的指令和临界区外的指令重排序。另外由于监视器锁的互斥特点,每个线程是看不到其他线程内部的执行顺序的。

未同步的程序

在 JMM 中,整体执行顺序是无序的,每个线程看到的顺序也可能不一致。注意有以下几个差异:

  1. JMM不保证单线程内的操作会按程序的顺序执行
  2. JMM不保证所有线程能看到一致的操作执行顺序
  3. JMM不保证对 64 位的 long 型和 double 型变量的写操作具有原子性

总结

JMM 通过禁止特定的重排序,实现了在正确同步的情况下,具有与顺序一致性内存模型一致的执行效果

volatile 的实现

JMM 定义了 volatile 的内存语义,当一个变量声明为 volatile 时,它的读写操作将具有特殊的含义。

内存语义

volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存

volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量

基于 volatile 写-读的内存语义,可以实现线程间的通信:一个线程往 volatile 变量写数据,另一个变量从 volatile 变量读数据,实际上就实现了消息的发送和接收

内存语义的实现

通过对编译器和处理器的重排序进行限制,从而实现了 volatile 的内存语义。

对编译器重排序的限制

JMM 针对编译器制定了禁止重排序的规则,如下表所示:

行表示第二个操作
列表示第一个操作普通读/写volatile 读volatile 写
普通读/写
volatile 读
volatile 写

从表中可以看出,该规则禁止如下的重排序:

  1. volatile 写与之前的操作
  2. volatile 读与之后的操作
  3. volatile 写 - volatile 读

一句话说明就是禁止3种重排序:volatile 写之前、volatile 读之后、volatile 写-读

对处理器重排序的限制

编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

屏障类型指令示例说明
LoadLoad屏障Load1; LoadLoad; Load2确保 Load1 的数据装载先于 Load2 及所有后续装载指令
StoreStore屏障Store1; StoreStore; Store2确保 Store1 的数据刷新到内存先于 Store2 及所有后续存储指令
LoadStore屏障Load1; LoadStore; Store2确保 Load1 的数据装载先于 Store2 及所有后续存储指令刷新到内存
StoreLoad屏障Store1; StoreLoad; Load2确保 Store1 数据刷新到内存先于 Load2 及所有后续装载指令。该屏障会使它前面的所有内存访问(包括 Load 和 Store)指令执行完,然后再执行后面的内存访问指令

其中 StoreLoad 屏障同时具有其他 3 个屏障的效果。

JMM 的内存屏障插入策略如下:

需要的屏障行表示第二个操作
列表示第一个操作普通读普通写Volatile 读 / MonitorEnterVolatile 写 / MonitorExit
普通读LoadStore
普通写StoreStore
Volatile 读 / MonitorEnterLoadLoadLoadStoreLoadLoadLoadStore
Volatile 写 / MonitorExitStoreLoadStoreStore
  1. 在每个 volatile 写操作的前面插入一个 LoadStore 屏障。
  2. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  3. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  4. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  5. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

X86 处理器仅会对写-读操作做重排序,因此 JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写-读的内存语义

对于 volatile 写-读重排序的处理,实际上有两种方案:

  1. volatile 写之后插入 StoreLoad 屏障
  2. volatile 读之前插入 StoreLoad 屏障

JMM 选择的是方案 1,这是一个保守策略,因为 volatile 写之后不一定有 volatile 读。

但是,这也有一个好处,当 volatile 读较多的时候效率会相对比较高,因为不用每次读之前都执行内存屏障

与锁的相同点

从实现角度分析

根据 volatile 内存语义的实现可以发现,对于 volatile 写 - volatile 读 操作,这两个操作本身不会重排序,并且还会禁止前面的操作和 volatile 写 重排序,同时会禁止后面的操作和 volatile 读 重排序。

这也就是说 volatile 写 - volatile 读 操作起到了禁止前后重排序的效果,这是从实现角度的理解。

从 happens-before 视图理解

JMM 通过 happens-before 规则对程序员提供了一种更容易理解的方式,其中一个规则是:

volatile 变量规则:对一个 volatile 变量的写,happens-before 于任意后续对这个 volatile 变量的读。

ReorderExample 代码中的 flag 改为 volatile

1
2
3
4
5
6
7
8
9
10
11
12
13
class ReorderExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
}
}
}

根据 happens-before 规则

  1. 根据程序顺序规则(一个线程中的每个操作,happens-before于该线程中的任意后续操作)可以得到:

    1 happens-before 2

    3 happens-before 4

  2. 根据 volatile 规则可以得到:

    2 happens-before 3

  3. 根据 happens-before 的传递性规则,结合前面的结果:

    1 happens-before 4

总结

两个线程操作同一个 volatile 变量,一个线程执行读操作,另一个线程执行写操作

  1. 写线程在所有操作的最后执行 volatile 写操作

  2. 读线程在所有操作中,将 volatile 读放在第一个位置

通过这种方式可以实现类似 解锁-加锁 的效果,也就是说 volatile 的 写-读 与锁的 释放-获取 有相同的内存效果

参考

程晓明,方腾飞,魏鹏. Java并发编程的艺术

The JSR-133 Cookbook for Compiler Writers

0%