Java - volatile

重排(Reordering)

在代码中,线程执行结果的可见性会导致另外一种叫做乱序执行的现象。首先,不可见 性表现在变量上并没有时间顺序性。举个例子,线程A先后变更了2个共享变量ab,但b 的变化可能先于a被其他线程观察到,这样就导致了在其他线程的角度来看,b是先于a被 改变的。

在Java的乱序执行例子中,有一段经典代码,即写后读的乱序执行导致的异常。代码如下:

 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
29
30
31
32
33
34
35
36
37
38
public class VolatileMain {
    private static int a = 0;
    private static int b = 0;

    private static /* volatile */ int x = 0;
    private static /* volatile */ int y = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable taskA = () -> {
            x = 3;
            a = y;
        };

        Runnable taskB = () -> {
            y = 4;
            b = x;
        };

        int count = 0;
        for (;;) {
            ++count;
            a = b = x = y = 0;
            Thread threadA = new Thread(taskA);
            Thread threadB = new Thread(taskB);

            threadA.start();
            threadB.start();

            threadA.join();
            threadB.join();
            if (a == 0 && b == 0) {
                System.out.println(count);
                break;
            }
        }
    }
}
// 38339

上述代码中,2个线程分别执行2个任务,如果程序是顺序执行的,那么无论线程之间如何交错, 都不可能出现a == 0 && b == 0成立的情况。但实际在执行一定时间后,必然会出现 a == 0 && b == 0成立的情况。

实际上,软件和硬件都会产生乱序:

  • 指令重排(Instruction Reordering)。这是编译器优化的结果,在不改变单线程执行行为 的前提下,它会将你所写的源代码编译成它认为最适合CPU执行的顺序。loadstore指令最 大的区别在于结果对后续指令的影响,一般而言load需要关注它的执行结果,因为后面的指令 极有可能依赖于它。因此,将无关的写后读进行重排,重排后能够在执行load的同时执行 store,这样load指令的延迟就被覆盖了。
  • 硬件重排序(Hardware Reordering),这就是我们上面所说的内存的可见性问题。对于CPU而言, store指令通常存储于Store Buffer中,因此在内存中的内容不会立刻被刷新。因此,对于 load指令而言,它会首先检查Store Buffer中是否存在该内存地址,如果存在则会直接从中获 取最新的值。然而,在并发环境下则会出现由于其它核中的Store Buffer未刷入内存导致的线程 见变量不可见问题。

内存屏障

由于重排序发生在软件和硬件2个层面,因此编译器和CPU提供了相应的方法去分别阻止在编译期 和运行时产生的重排问题。

编译器的指令重排针对不同的语言有不同的解决方式,例如对于Java而言, 就有JMM(Java Memory Model)帮助程序员理解和进行正确的多线程内存操作,而对于C语言, 也会有提供类似asm volatile("":::"memory")的方式显式在代码中插入内存屏障。

对于CPU而言,它提供了硬件的内存屏障去阻止屏障前后的指令进行重排,而使用内存屏障的方式 大致有2种:

  • 通常指令集中会提供指令(mfence)显式定义内存屏障,在该指令之前的所有指令都不会和 之后的指令发生重排
  • 使用指令集中提供的同步指令一般会隐式的产生内存屏障的效果,即同步指令前后的指令不会 发生重排

volatile关键字

在Java中,为了解决重排带来的问题就需要使用volatile关键字。使用这个关键字修饰的变量, 在执行storeload相关的指令时,会插入相应的内存屏障从而保证变量在内存当中的可见性。 与volatile相关的内存屏障有4个:

  1. LoadLoad. 禁止volatile变量的load操作与后续所有的load操作重排
  2. LoadStore. 禁止volatile变量的load操作与后续所有的store操作重排
  3. StoreStore. 禁止volatile变量的store操作与之前所有的store操作重排
  4. StoreLoad. 禁止volatile变量的store操作与之后所有的volatile变量的load操作重排

上面的4个屏障实际可以用代码很好的记住,StoreLoad屏障与上述的写后读代码相对应。而另外3 个屏障则可以通过下面的代码进行说明。

 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
29
30
31
32
33
34
35
36
37
38
public class VolatileMain {
    private static int a = 0;
    private static int b = 1;
    private static volatile boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable taskA = () -> {
            a = 2;
            flag = true;
        };

        Runnable taskB = () -> {
            if (flag) {
                b = a;
            }
        };

        int count = 0;
        for (;;) {
            ++count;
            a = 0;
            b = 1;
            flag = false;
            Thread threadA = new Thread(taskA);
            Thread threadB = new Thread(taskB);

            threadA.start();
            threadB.start();

            threadA.join();
            threadB.join();
            if (b == 0) {
                System.out.println(count);
                break;
            }
        }
    }
}

上述代码中,目的是通过flag变量来控制整体的一致性,也就是说flagtrue时, taskA中所做的所有事情都需要对其他线程可见,因此需要使用StoreStore屏障。而 对于taskB而言,它需要保证在flag检测为true之后才去读写取其他变量的内容,以 保证自己所读写所使用的全部变量都是最新的,因此需要LoadLoadLoadStore屏障。 (顺带一提,上述代码即使没有volatile也不会出现问题,猜测原因可能是因为x86-64只会 对写后读的指令进行重排)

总结

volatile作为一个Java中的一个关键字,最为直接的理解就是:

  1. 读取volatile变量一定能得到最新的值
  2. 写入volatile变量一定能被其它线程所见

但本质上它的使用关系到了软硬件层面上指令重排的问题。

对体系结构相关知识想要了解的可以参考下面的课程:

  1. Synchronization Without Locks
Built with Hugo
主题 StackJimmy 设计