美文网首页多线程
Java指令重排序与volatile关键字

Java指令重排序与volatile关键字

作者: kkyeer | 来源:发表于2019-07-29 16:11 被阅读3次

    Java指令重排序与volatile关键字

    1. 重现代码重排序

    1.1 测试代码

    完整代码参见Github,其中关键代码如下:

    Thread thread1 = new Thread(
            () -> {
                a = 1;
                y = b;
            }
    );
    Thread thread2 = new Thread(
            () -> {
                b = 1;
                x = a;
            }
    );
    

    1.2 理论推断

    因为thread1和thread2都join到当前线程,则代码执行到这里以后,两个线程都执行完毕,因为多线程的原因,代码执行顺序不同,理论上xy的值可能为(1,0)(0,1)或者(1,1),分别对应如下的执行顺序(从上到下)

    • 1,0的情况
    线程1 线程2 x y
    a=1; 0 0
    y=b; 0 0
    b=1; 0 0
    x=a; 1 0
    • 0,1的情况
    线程1 线程2 x y
    b=1; 0 0
    x=a; 0 0
    a=1; 0 0
    y=b; 0 1
    • 1,1的情况
    线程1 线程2 x y
    b=1; 0 0
    a=1; 0 0
    x=a; 1 0
    y=b; 1 1

    1.3 指令重排序导致的特殊情况

    实际运行中,运行上述的代码足够长的时间后,会有某个线程进入错误分支,打印如下错误并关闭线程池

    Wrong,x = 0 and y = 0
    Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@d716361 rejected from java.util.concurrent.ThreadPoolExecutor@3764951d[Shutting down, pool size = 12, active threads = 12, queued tasks = 23280, completed tasks = 2713]
        at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
        at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
        at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
        at java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:112)
        at concurrent.reorder.Reveal.main(Reveal.java:59)
    Wrong,x = 0 and y = 0
    

    表明实际运行中会有两个线程都执行完毕,然而x和y都是0的情况,这时就是发生了指令重排序,即代码运行的顺序,与源代码的顺序不一致,具体到测试代码,即可能实际运行顺序如下

    • 0,0的情况(发生指令重排序)
    线程1 线程2 x y
    y=b; 0 0
    x=a; 0 0
    a=1; 0 0
    b=1; 0 0

    此时最终打印x=0;y=0;

    2. 什么是指令重排序

    2.1 Java源代码到运行时指令

    Java编译出来的class文件,仅能被Java虚拟机(JVM)识别,实际在运行时,会由实际运行的JVM编译成机器码运行,粗浅的理解为:Java.class文件 -> JVM运行时解析为机器码 (-> JIT优化过后的机器码) -> 操作系统的CPU指令,其中JVM解析为机器码、JIT优化成机器码,CPU执行CPU指令的过程中均有可能发生指令重排序

    2.2 宿主机的内存模型与变量操作

    所谓宿主机,即运行JVM的机器,可能是个人开发的电脑,线上的生产服务器,Docker容器等,操作系统、硬件的不同,内存模型和指令也不尽相同,鉴于目前多核CPU无论在开发环境和生产环境均为主流,一般认为宿主机的内存模型简化为主内存和多级CPU内部缓存再到寄存器,简化后的模型如下

    寄存器 <-> CPU内部共享高速缓存(L1\L2\L3 Cache) <-> 主内存(RAM)

    高速缓存仅仅是用作寄存器和主内存之间缓存用,CPU通过各种技术保证寄存器读取时缓存内的值与主内存的对应值一致,因此进一步简化为

    寄存器 <-> 内存(Cache和RAM)

    在此简化模型下,假设当前主内存中一个变量x初始值为0,一个简单的赋值操作a=0;x=a+1的执行顺序如下

    1. 从内存读取a的值0到CPU Processor的寄存器,并赋予临时地址r1,可看作 r1 = a;
    2. 寄存器内累加r1 = r1 + 1;
    3. r1的值写回内存,x=1

    假设MOV [v1, v2]代表v2变量复制到v1变量,S1表示Step1,r开头的变量表示寄存器变量,则上述步骤简写为

    • S1: MOV [r1, a]
    • S2: MOV [x, ++r1]

    执行顺序为 S1 -> S2 ,后面也按此约定说明

    2.3 CPU指令重排序

    2.3.0 测试程序

    将1.1中的测试程序改写为

    public Test{
        int a = 0,b = 0,x = 0,y = 0;
    
        void test1(){
            a = 1;
            x = b;
        }
    
        void test2()[
            b = 1;
            y = b;
        ]
    }
    

    则按照2.2的写法,将test1方法内部CPU指令简写为:

    • S1: MOV[a, 1]
    • S2: MOV[r1, b]
    • S3: MOV[x, r1]

    test2方法内部CPU指令简写为:

    • S4: MOV[b, 1]
    • S5: MOV[r2, a]
    • S6: MOV[y, r2]

    后面的程序均围绕此程序展开

    CPU指令重排序的定义为:CPU允许在某些条件下进行指令重排序,仅需保证重排序后单线程下的语义一致,这句话比较绕口,其中有三个加粗后的关键字,具体解释如下:

    2.3.1 某些条件

    我们把变量读到寄存器的操作称为Load,把变量从寄存器写出到内存的操作称之为Store,则下面的操作称之为Store-Load操作:

    • MOV[r1, x]
    • MOV[y, r1]

    类似的还有Load-Load,Load-Store,Store-Store操作,对于这几种操作,Intel规定Store-Load操作,且Store中涉及到的外存变量与Load中涉及到的外存变量不同的情况下,可以发生指令重排序,当然对于不同的CPU、指令集,可重排序的指令不同,一般情况下认为大多数CPU均支持Store-Load重排序,具体的支持操作请参考最后的参考资料或自行查阅相关网站

    2.3.2 指令重排序

    假设一个线程执行2.3.0中程序的test1()方法,由于S1为Store指令,S2为Load指令,且涉及的外存变量不同,根据2.3.1的说明,允许发生重排序,即允许指令执行顺序为S2 -> S1 -> S3,注意,由于S3语句中r1的值只跟S2位置有关,因此,重排序后的语句执行效果类似x=b;a=1;,类似的test2()方法可被重排序为S5 -> S4 -> S6,执行效果看上去像y=a;b=1;,注意,这里的看上去像仅仅是指最终执行顺序看上去的样子

    2.3.3 重排序后单线程下的语义一致

    如果仅有一个线程顺序执行test1()和test2()方法,正常执行的结果为a=1;b=1;x=0;y=1;即使指令被重排序为S2 -> S1 -> S3 -> S5 -> S4 -> S6,最终执行结果仍旧为a=1;b=1;x=0;y=1;,与源码中直接推导或者说重排序前的执行结果是一致的,这就叫做重排序后单线程下的语义一致

    2.3.4 指令重排序与多线程程序

    2.3.3中阐明了,指令重排序对于单线程程序没有影响,但是假如有两个线程分别运行test1()方法和test2()方法,假设发生指令重排序,由于多线程程序执行顺序的不确定性,可能的一种执行顺序为:

    线程1 线程2 r1 r2 x y a b
    S2( MOV[r1, b] ) 0 - 0 0 0 0
    S5( MOV[r2, a] ) 0 0 0 0 0 0
    S1( MOV[a, 1] ) 0 0 0 0 1 0
    S3( MOV[x, r1] ) 0 0 0 0 1 0
    S4( MOV[b, 1] ) 0 0 0 0 1 1
    S6( MOV[x, r2] ) 0 0 0 0 1 1

    在这种情况下,最终x=0;y=0;这就是1.3中出现反直觉的结果的原因,最终展现出的效果就类似下面的表格,看上去是两个线程的代码进行了重排序

    线程1 线程2 x y
    y=b; 0 0
    x=a; 0 0
    a=1; 0 0
    b=1; 0 0

    3. 如何避免多线程程序中指令重排序造成的错误

    3.1 Java内存模型(JMM)

    为了保证JVM的跨平台性,把Java业务代码与操作系统或硬件的指令解耦,JSR规定了一系列Java代码在多线程程序中与内存交互中的原则,如happens-before原则,serial-as-if原则,JVM实现必须遵循这些原则,同时,没有在JSR133中禁止的指令重排序、优化等等均是被允许的

    3.1.1 JMM的happens-before原则

    如果两个动作符合happens-before原则,则两个操作互相间指令重排序受到限制,如果一个动作happens-before另一个动作,则第一个对第二个可见,且第一个排在第二个之前

    • 一个线程的各个action happens-before 这个线程的subsequent action
    • 一个monitor的unlock happens-before 这个monitor的subsequent lock
    • 对一个volatile变量的write happens-before 这个变量的read
    • 对一个线程的start()操作happens-before开启的线程里的action
    • 一个线程的所有action happens-before 其他join这个线程的action
    • happens-before有传递性,即如果a happens-before b,b happens-before c,则a happens-before c

    3.2 volatile关键字

    JMM对于volatile关键字的规定,可以归结为两层:
    1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的
    2)当指令进行到volatile变量的Store操作时,在此之前的所有指令必须执行完毕,且在此之后的指令尚未执行

    3.2.1 volatile关键字对指令重排序的影响

    从3.1.1可知,JMM规定如果一个变量被volatile修饰,则Store-Load操作不会被指令重排序

    3.2.2 验证volatile关键字对内存的影响

    将1.1中测试代码里的变量a,b用volatile修饰,则无论运行多久,都不会再出现x=0;y=0;的情况,但仅修饰a和b其中一个不会有此效果

    3.2.3 验证代码解析

    对volatile变量a,b的操作S1和S2之间,因为S1是volatile变量a的Store操作,因此S1不可和S2进行重排序,类似的,S4和S5也不可进行重排序,这就避免了2.3.4中重排序后指令的执行可能,因而不会出现x=0;y=0;的情况

    4 参考

    相关文章

      网友评论

        本文标题:Java指令重排序与volatile关键字

        本文链接:https://www.haomeiwen.com/subject/nxnbrctx.html