前言
前面博客中,重点说明了volatile
的相关特性。
比如:保证共享变量在其他线程中的及时可见性,不能保证原子性。以及包括计算机指令重排
的优点和缺点,以及案例说明了指令重排出现的可能性等。
但是,volatile读写和普通读写之间,指令重排的规则又是如何判断的?
本篇博客将围绕这个技术点做相关说明。
JMM针对编译器制定的volatile重排序规则
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可重排 | 可重排 | 不可重排 |
volatile读 | 不可重排 | 不可重排 | 不可重排 |
volatile写 | 可重排 | 不可重排 | 不可重排 |
以第一规则做案例分析理解
上面的规则初步看来不容易理解,下面通过案例分析上述的规则表:
当第一个操作是普通读写
当第一操作为普通读写
时,此时的重排规则如下所示:
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可重排 | 可重排 | 不可重排 |
-
1、第一个操作:普通读写;第二个操作:普通读写。——可以重排
案例如下所示:public class MemoryBarrier { // 初始化变量(普通类型) int a = 0; int x = 1,y =2; void readAndWrite(){ int c = 2; // 第一个操作:普通写 a = x + y; // 第二个操作:普通读/写 } }
c = 2
表示一个普通写操作
,a = x + y
则表示普通读+普通写操作
,那么这两个执行顺序可以重排
。a = x + y
,因为涉及到计算操作,首先需要获取x
和y
对应的值,其中x和y
只是一个普通的变量,所以是普通读
。
计算之后,将结果赋值给a
,这个赋值操作就是写操作
,由于变量a无volatile
修饰,则表示普通写
。 -
第一个操作:普通读写;第二个操作:volatile读。——可重排
看下列案例:public class MemoryBarrier { // 初始化变量(普通类型) int a = 0; int x = 1,y =2; int c = 0; // volatile 修饰变量 volatile int m1 = 1; volatile int m2 = 2; void readAndWrite(){ // 第一操作:普通读写;第二操作:普通读写。则可重排 //c = 2; // 第一个操作:普通写 //a = x + y; // 第二个操作:普通读/写 //第一操作:普通读写;第二操作:volatile读。则可重排 c = 2; // 普通写 int i = m1; // volatile读+普通写(先读m1的值,再赋值给i) } }
int i = m1;
进行赋值操作,其实是两个操作的综合,m1变量被volatile修饰
,将值进行赋值操作前,需要先读取m1
的值,这里是volatile读
。再将读取的数据赋值
给i
,此时的变量i
只是一个普通变量
。所以,
int i = m1;
的操作为volatile读和普通写
。第一个操作
c = 2
是普通写
,第二个操作int i = m1
是volatile读(加上普通写)
,所以可以指令重排
。 -
第一操作:普通读写;第二操作:volatile写。不可重排
案例代码如下所示:public class MemoryBarrier { // 初始化变量(普通类型) int a = 0; int x = 1,y =2; int c = 0; // volatile 修饰变量 volatile int m1 = 1; volatile int m2 = 2; void readAndWrite(){ // 第一操作:普通读写;第二操作:普通读写。则可重排 //c = 2; // 第一个操作:普通写 //a = x + y; // 第二个操作:普通读/写 //第一操作:普通读写;第二操作:volatile读。则可重排 //c = 2; // 普通读写 //int i = m1; // volatile读+普通写(先读m1的值,再赋值给i) // 第一操作:普通读写;第二操作:volatile写。不可重排 c = 2; // 普通写 m1 = 2; // volatile 写 } }
由于第一个操作
c = 2
,这是一个普通写操作
;
第二个操作m1 = 2
,由于m1
被volatile修饰
,表示该变量是volatile 变量
,重新赋值则表示volatile写
。这两个操作指令之间,
不可重排
。
什么是读?什么是写?
上述的案例中,频繁的出现读操作
和写操作
字眼,相信很多人看了会很困惑。下面解释什么是读什么是写。代码如下:
public class Te {
public static void main(String[] args) {
int b = 1;
int c = b;
}
}
以赋值符号 =
为界,
int b = 1
在计算机中,= 符号右
中,并不需要从内存中读具体的信息,所以int b = 1
只是一个写操作
。
int c = b
在计算机中,= 符号右
中,由于需要进行赋值操作,将变量b
赋值给变量c
,但计算机并不知道
变量b的值
,所以需要去进行读操作
,将变量b
的具体值从内存中获取。当获取到了变量b
的值后,再将该数据值赋值给变量c
,此时的赋值操作就是一个写操作
。
int c = b;的总体操作为:读操作+写操作。
关于第一步第二步
第一步和第二步,并不是指代码
中的第一行代码、第二行代码。
而是两行代码执行顺序比较时,
相对而言
。
JMM内存屏障插入策略
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列
中插入内存屏障
来禁止特定类型
的处理器重排序
。
之前博客中有说到
volatile采取内存屏障来保证禁止计算机指令重排优化
。
volatile禁止重排优化
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略
。
基于保守策略,JMM内存屏障插入规则有如下几种规则:
- 在每个volatile
写操作 前
,插入StoreStore 内存屏障
。 - 在每个volatile
写操作 后
,插入StoreLoad 内存屏障
。 - 在每个volatile
读操作 后
,插入LoadLoad 内存屏障
。 - 在每个volatile
读操作 后
,插入LoadStore 内存屏障
。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
下面是保守策略下,volatile写
插入内存屏障
后生成的指令序列
示意图:
这个是
volatile 写
- 在每个volatile
写操作 前
,插入StoreStore 内存屏障
。- 在每个volatile
写操作 后
,插入StoreLoad 内存屏障
。
上图中StoreStore屏障可以保证在volatile写之前
,其前面
的所有普通写操作
已经对任意处理器可见
了。
因为
StoreStore屏障
将保障上面所有的普通写
在volatile写之前
刷新到主内存
。
【疑问:】为什么 volatile写 后面加的是 storeload 屏障?而不是其他的?
storeload屏障的作用是避免volatile写与后面可能
有的volatile读/写操作重排序。
因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)
为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:
在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。
从整体执行效率
的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障
。
volatile写-读
内存语义的常见使用模式是:
一个写线程写volatile变量,
多个读线程读同一个volatile变量。
当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。
首先确保正确性,然后再去追求执行效率。
下面是保守策略下,volatile读
插入内存屏障
后生成的指令序列示意图:
这个是
volatile 读
。
- 在每个volatile
读操作 后
,插入LoadLoad 内存屏障
。- 在每个volatile
读操作 后
,插入LoadStore 内存屏障
。
上图中LoadLoad屏障
用来禁止
处理器把上面的volatile读
与下面的普通读
重排序
。
上图中LoadStore屏障
用来禁止
处理器把上面的volatile读
与下面的普通写
重排序
。
代码案例分析
上面的概念过于抽象,下面以java代码案例的形式,将其进行插入内存屏障进行拆解分析:
案例如下所示:
class Test {
int a; // 普通变量
volatile int v1 = 1; // volatile 修饰的变量
volatile int v2 = 2; // volatile 修饰的变量
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
}
上述代码中的readAndWrite()
,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。其中插入内存屏障大致如下所示:
【注意:】最后的StoreLoad屏障不能省略!
因为第二个volatile写之后,方法立即 return。
此时编译器可能无法准确断定后面是否会有volatile读或写
,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。
参考资料
《并发编程的艺术》