volatile为什么能禁止指令重排

在回答题目之前我们先来思考这样一个问题,就是指令重排是什么,我们为什么又要禁止它,以及在什么时候要禁止指令重排?

我们都知道,像Java等高级语言最后都要被编译器转换为机器语言,或者称作机器指令,以便能被机器直接执行。所以,我们编写的程序最后都是一条条机器指令,从而能被CPU执行。在计算机组成原理这门课中,我们知道,这些指令被放在指令寄存器中,由程序计数器PC去指示CPU下一条该执行那条指令。CPU先从程序计数器中拿到下一条指令在指令寄存器的地址,计数器加一,然后把指令从指令寄存器中读出来,最后执行,然后又循环这个过程。又由于CPU的速度远大于寄存器的速度,所以为了增加CPU的并发度,在多核CPU中可能会有多核去拿指令,然后执行,所以在这个过程中,原本的代码顺序可能会被重新排序,这就是指令重排。

当然,在单线程下,有些语句的顺序天然不能被重排,比如下面这种:

1
2
3
int a = 1;		// A
int b = 2; // B
int c = a * b; // C

C的执行顺序必须在A和B后面,因为C依赖A和B,这种情况叫做as-if-serial语义。但是A和B的顺序可以改变,因为不管是A在前还是B在前都影响程序的执行结果,所以CPU允许A和B指令重排。因为在单线程下编译器或者CPU遵循as-if-serial语义,所以在单线程情况下我们发现不了这种指令重排序。但是在多线程情况下那就不是这样了,指令重排很快会被暴露出来,让我们以下面的代码为例,看看CPU做了什么“马脚”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class zhilngchongpai {
private static int x = 0, y = 0;
private static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
for (int i = 0; ; i++) {
x = 0; y = 0;
a = 0; b = 0;

Thread one = new Thread(() -> {
a = 1;
x = b;
});

Thread other = new Thread(() -> {
b = 1;
y = a;
});

one.start(); other.start();;
one.join(); other.join();
if (x == 0 && y == 0) {
String result = "第" + i + "次(" + x + ", " + y + ")";
System.out.println(result);
}
}
}
}

本来如果不发生指令重排的话,正常的执行结果是:x=0,y=1。但是我们实际运行过程中会碰到这种情况,我把它打印了出来,如下图:

image-20200705231526713

我们可以看到竟然出现了x=0,y=0的情况,这种现象只有在x=b这条语句跑到a=1前面,并且y=a跑到b=1前面执行才有可能产生的,这说明CPU给指令进行了重排!

所以以上实验结果也从真实情况证明了CPU或编译器会给指令进行重排。

指令重排有它的好处,但是它的坏处就是在多线程环境下可能会出现一些不符合期望的结果,那么我们要怎么禁止这种情况发生呢?在java中就有一个关键字能解决这种问题,那就是volatile。

我们可能会在设计单例模式的时候使用到这个关键字,比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
private static Singleton instance = null;

public static Singleton getInstance() {
if(null == instance) { // 线程二检测到instance不为空
synchronized (Singleton.class) {
if(null == instance) {
instance = new Singleton(); // 线程一执行这条语句并不是原子性的,所以会出现instance不为空,但其实对象并没有初始化的情况
}
}
}

return instance; // 后面线程二执行时将引发:对象尚未初始化错误
}
}

我们看instance这个变量,在执行new Singleton()语句,即创建Singleton对象的时候,其实在jvm虚拟机中是进行了下面这三件事:

1、先在内存中开辟一个空间分配给Singleton对象;

2、执行构造方法,初始化成员变量;

3、将instance对象指向分配的内存空间。

如果这三件事按顺序执行倒也无妨,但是jvm通常会给指令进行重排序,所以就有可能出现这种情况:

a、先在内存中开辟一个空间分配给Singleton对象;

b、将instance对象指向分配的内存空间;

c、执行构造方法,初始化成员变量。

所以这个时候如果A线程执行到了b,另外一个B线程来看instance已经非空了,于是就返回instance了,但其实这个时候对象还没有完全创建完成,接着就会报对象尚未初始化的错误。

而如果我们使用volatile关键字修饰instance变量,这种情况就可以避免,因为被volatile修饰的变量不会被jvm进行指令重排序,所以也就不会出现先执行3,后执行2的情况发生。所以instance只要是非空,就一定是已经初始化完毕的。

回到题目问的问题:那么volatile为什么能禁止指令重排呢?

因为这个涉及计算机硬件的知识,所以我们需要看一下instance = new Singleton();在汇编代码中是怎么表示的。

20180616155807673

在写操作(putstatic instance)之前使用了lock前缀,锁住了总线和对应的地址,这样其他的CPU写和读都要等待锁的释放。当写完成后,释放锁,把缓存刷新到主内存。这样只有前面的步骤都完成后,instance才会不为空且有值。


volatile为什么能禁止指令重排
https://www.chuckfang.com/2020/07/05/volatile/
作者
方程
发布于
2020年7月5日
许可协议