volatile关键字的内存语义

缘起

volatile是面试中的老朋友了.

分析

【1】中我们提及了happens-before规则中的volatile法则,其实这基本上就是volatile关键字的内存语义了. 那么volatile法则(内存语义)是怎么实现的呢?

答案是防止重排序. 【1】中提到过重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的volatile重排序规则表:

也就是

第一个操作是volatile读的话,第二个操作是啥都不能重排序.

第二个操作是volatile写的话,第一个操作是啥都不能重排序

第一个操作是volatile写,第二个是volatile读,就不能重排序.

那么再进一步问:这种禁止重排序是怎么实现的呢?

答案是【1】中提及的内存屏障——memory fence,也就是一些CPU指令,但是JMM为我们做了屏蔽. 所以 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的重排序以及达到某种内存可见性。

内存屏障的具体规则如下

1
2
3
4
5
6
7
8
9
10
11
内存屏障可以被分为以下几种类型:
1. LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
2. StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
3. LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
4. StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

JSR-133的规定,Java编译器会这样使用内存屏障。下面是JMM内存屏障插入策略:
• 在每个volatile写操作的前面插入一个StoreStore屏障。
• 在每个volatile写操作的后面插入一个StoreLoad屏障。
• 在每个volatile读操作的后面插入一个LoadLoad屏障。
• 在每个volatile读操作的后面插入一个LoadStore屏障。

注意,上面的屏障已经插在中间意味着已经阻止了重排序. 例如LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

上面的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。volatile写后面的StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面,是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。

为了保证能正确实现volatile的内存语义,JMM在这里采取了保守策略:在每个volatile写的后面或在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile 变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。

上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileExample {
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; //4
……
}
}

在旧的内存模型中,当上述源码中1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改(即写线程A已经将flag设置为了true,而且这种写入因为旧模型依旧禁止volatile读写之间的重排序,所以读线程B在第11行是可以看到的,但是错过了写线程A对a的赋值)。
因此在旧的内存模型中 ,volatile的写-读没有锁(synchronized)的释放-获取所具有的内存语义。为了提供一种比锁(synchronized)更轻量级(在大多数主流的处理器架构中,存储模型已经足够强大——以应付读取volatile变量与非volatile变量之间的性能差异)的线程之间通信的机制,JSR-133增强了volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取一样,具有相同的内存语义。

其实,如果从主内存和工作内存之间的交互的八种操作的角度看就更明显了. 参见【2】最后一段

1
2
3
4
当一个变量被声明为volatile之后,JMM对其做了特殊规则:

1. volatile变量的操作必须严格按load->use顺序,前一个动作是load时才能执行use动作,后一个动作是use时才能执行load动作,即每次在工作内存中使用变量前必须先从主内存中刷新最新的值,以保证能看到其他线程对变量的最新修改。
2. volatile变量的操作必须严格按assign->store顺序,前一个动作是assign时才能执行store动作,后一个动作是store时才能执行assign动作,即每次在工作内存为变量赋值之后必须将变量的值同步回主内存,以保证让其他线程能看到变量的最新修改。

参考

【1】https://yfsyfs.github.io/2019/06/24/java-%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B%EF%BC%88JMM%EF%BC%89-%E4%B9%8B%E6%88%91%E4%B9%8B%E8%A7%81/

【2】https://blog.csdn.net/zero__007/article/details/53025425