CPU的乱序执行,内存屏障,JVM的指令重排,JSR内存屏障,Java中volatile的底层实现

2022-07-24,,,,

在了解本文内容之前,要先搞懂计算机内存模型,Cache, Cache Line, MESI协议,伪共享问题,缓存行对齐等问题。

CPU的乱序执行

一般来说,CPU在执行指令时,是按照指令的先后顺序执行的。
但是在某些时候,为了提高CPU的执行效率,这些指令的执行顺序可能会被打乱(前提是指令之间不相互依赖)。
单线程的情况下,CPU的乱序执行, 不会影响最终的执行结果。(as-if-serial)
但是在多线程情况下,如果多个线程涉及到对共享数据的操作,那么就可能会导致错误的结果

CPU的内存屏障 (禁止乱序执行)

如果不想让CPU在执行某些指令时乱序执行,就到用到内存屏障。
在CPU对内存做操作时添加一个屏障,这个屏障前后的指令不能被打乱。
如下图,在指令1和指令2之间添加了屏障,则指令1 和指令2 的执行顺序不可颠倒。

Intel处理器实现内存屏障的原语指令(汇编指令)

lfence:读屏障,在lfence指令前的写操作必须在lfence指令后的写操作之前完成
mfence:读写屏障, 在mfence指令前的读写操作必须在mfence指令后的读写操作之前完成
sfence:写屏障,在sfence指令前的写操作必须在sfence指令后的写操作之前完成

除此之外,总线锁的方式(Lock指令)也可以禁止乱序执行。但是效率相对较低。因为Lock指令会在执行时锁住内存子系统来确保执行顺徐和缓存数据一致性。

JVM指令重排

JVM在执行Java代码时,会对代码进行编译优化,也会出现指令重排序的问题。
不同的是,CPU乱序执行是硬件级别的,而JVM的指令重排是虚拟机级别的。

public class TestOutOfOrder {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            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();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                //System.out.println(result);
            }
        }
    }
}

上面这段代码可以验证JVM指令重排序的存在,根据代码逻辑来看,在没有乱序执行的情况下,不可能会出现x=0,y=0的情况,如果出现了这种情况,那么说明两个线程中所执行的代码发生了重排序。

PS:因为代码量较少,加之电脑性能的原因,可以在短时间内验证的几率很小。我在我的电脑上实验多次,终于出现了一次指令重排的情况。感兴趣的话可以自行测试。

JSR内存屏障

JSR (JavaSpecification Requests) 译为Java 规范提案。是java开发者以及授权者指定的标准。现已称为Java界的一个重要标准。

JSR规范了4种JVM层级的内存屏障:

LoadLoadBarrier: LoadLoad屏障,  屏障后的 读操作 要在屏障前的 读操作 完成之后执行。 
LoadStoreBarrier: LoadStore屏障, 屏障后的 写操作 要在屏障前的 读操作 完成之后执行。
StoreStoreBarrier: StoreStore屏障,屏障后的 写操作 要在屏障前的 写操作 完成之后执行。
StoreLoadBarrier: StoreLoad屏障, 屏障后的 读操作 要在屏障前的 写操作 完成之后执行。

这4种内存屏障是JVM的规范,具体的实现方式还是依据不同CPU各自的实现为准。

happens-before原则(JVM规定的指令重排序必须遵守的规则)

在Java内存模型中,happens-before的意思是前一个操作的结果可以被后续操作获取。为了避免编译优化导致的指令重排序对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景。

程序次序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
管程锁定规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)
volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。
传递性规则:这个简单的,就是happens-before原则具有传递性,即hb(A, B) , hb(B, C),那么hb(A, C)。
对象终结规则:这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。

CPU原语lock指令

lock指令是CPU级别的汇编指令,在Java中volatile和synchronized关键字的底层实现都用到了这个指令。
在执行lock后面指令时,会设置处理器的LOCK#信号。这个信号会锁定总线,阻止其它CPU通过总线访问内存,直到这些指令执行结束。这些指令的执行是原子性的。

lock指令主要有两大作用:

保持CPU缓存的数据一致性
起到内存屏障的作用,LOCK指令前后的指令无法乱序执行

volatile的底层实现

JVM层级:

被volatile修饰的变量,JVM编译器会做如下处理:

在涉及到该变量的读操作之前,加LoadLoad屏障, 写操作之后, 加LoadStore屏障
在涉及到该变量的写操作之前,加StoreStore屏障, 写操作之后, 加StoreLoad屏障
CPU层级:
使用cpu原语lock指令(lock addl$0x0,(%esp)), 把CPU中ESP寄存器的值加0
其实lock后面部分命令并没有什么作用,主要起作用的是这个LOCK指令。这个指令上面已经说过

总结:volatile其实就是在底层使用 lock 指令来实现 「防止指令重排」和「内存可见」的特性。

关于volatile的深入探讨,参考:
深度解析volatile—底层实现
volatile的内存语义

本文地址:https://blog.csdn.net/weixin_48024348/article/details/114273005

《CPU的乱序执行,内存屏障,JVM的指令重排,JSR内存屏障,Java中volatile的底层实现.doc》

下载本文的Word格式文档,以方便收藏与打印。