• Java并发编程原理与实战四十一:重排序 和 happens-before


    一、概念理解

    首先我们先来了解一下什么是重排序:重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

    从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图所示

          上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

    1)数据依赖性(针对单个处理器而已)

          关于重排序,这里要先讲一个概念就是数据依赖性问题。如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,如下表所示。

    上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

    2)as-if-serial语义

        as-if-serial语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

       为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。as-if-serial语义把单线程程序保护了起来,as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

    3)happens-before

           如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

         对happens-before关系的具体定义如下。

        ① 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
        ②两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

          上面的①是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!上面的②是JMM对编译器和处理器重排序的约束原则。正如前面所言,其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。因此,happens-before关系本质上和as-if-serial语义是一回事。

          ·as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
         ·as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
          as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

       happens-before规则如下:

        程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
        监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
        volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
        传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
        start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
        join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

    二、例子分析

    假设有两个线程分别调用同一个test对象的writer()和reader()。请问,b的值是什么?

    (a) 1 
    (b) 2 
    (c) 1 or 2

     public class test{
        private boolean flag = false;
        private int a = 0;
    
        public void writer(){
                a = 1;
                flag = True;
        }
        public void reader(){
            if (flag){
                b = a + 1
            }
        }
    }

    这里主要涉及的是处理器重排序问题。当前处理器为了加速指令执行,会将部分指令重排序之后执行。

    数据依赖

    数据依赖是一个简单的概念,就是判断前后两行代码在数据上有否有依赖关系。例如:

    num1 = 1                // (a)
    num2 = 2                // (b)
    result = num1 + num2    // (c)

    显然,c 语句用到的 num1 和 num2 依赖 a 和 b。

    数据依赖分三种:

    • 1 store - load
    • 2 load - store
    • 3 store - store

    如何判断是否有依赖,很简单,只用判断两个语句之间是否用到同一个变量,是否是写操作。

    Happen before

    JVM定义了一个概念叫做 happen before,意思是前一条执行的结果要对后一条执行可见。简单来说前一条执行完,才能执行后一条。但实际上为了提高处理速度,JVM弱化了这个概念,在有数据依赖的情况下,前一条执行完,才能执行后一条。

    看下面的例子:

    num1 = 1                // (a)
    num2 = 2                // (b)
    result = num1 + num2    // (c)

    对于上述三条语句 a, b, c执行,单线程顺序执行的情况。

    a happen before b       
    b happen before c。
    

    根据传递性可以得出:

    a happen before c

    c指令要用到的 num1 和 num2 显然是依赖 a 和 b 的,典型的store-load。所以c指令必须等到 a 和 b 执行完才能执行。然而 a 和 b 并没有数据依赖,于是 JVM 允许处理器对 a 和 b 进行重排序。

    a -> b -> c = 3
    b -> a -> c = 3

    那么happen before到底是什么?我的理解是happen before是JVM对底层内存控制抽象出一层概念。我们可以根据代码顺序来判断happen before的关系,而JVM底层会根据实际情况执行不同的 action (例如添加内存屏障,处理器屏障,阻止重排序又或者是不做任何额外操作,允许处理器冲排序)。通过这一层使得内存控制对程序员透明,程序员也不需要考虑代码实际执行情况,JVM会保证单线程执行成功,as-if-serial。

    既然JVM已经透明了内存控制,那为什么要搞清楚这点,那就是JVM只保证单线程执行成功,而多线程环境下,就会出各种各样的问题。

    答案

    下面就用上述讲的分析一下最初的题目。

    A线程执行:

        public void writer(){
                a = 1;              // (1)
                flag = True;        // (2)
        }

    B线程执行:

        public void reader(){
            if (flag){              // (3)
                b = a + 1           // (4)
            }
        }

    1.先考虑大多数人考虑的情况: 

    指令顺序:(1)-> (2) -> (3) -> (4),b = 1 +1 = 2

    2.意想不到的情况 
    对于A线程来说,语句 (1)和(2)并不存在任何数据依赖问题。因此处理器可以对其进行重排序,也就是指令 (2)可能会先于指令(1)执行。 
    那么当指令按照(2)-> (3) -> (4) -> (1) 顺序,b = 0 +1 = 1

    3.还有一种情况 
    对于B线程,处理器可能会提前处理 (4),将结果放到 ROB中,如果控制语句(3)为真,就将结果从ROB取出来直接使用,这是一种优化技术,预测。 
    所以指令执行顺序可能是 (4) -> x -> x ->x

    看来4条语句都有可能最先被执行。

    总结一下,在多处理器环境中,由于每个处理器都有自己的读写缓存区,所以会使部分数据不一致。JMM会有一系列 action 保证数据一致性,但是在多线程环境下,还是会有很多诡异的问题发生,这个时候就要考虑处理器,编译器重排序。

    三、知识点总结

    1,指令重排序

    大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,
    在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。
    通过乱序执行的技术,处理器可以大大提高执行效率。 除了处理器,常见的Java运行时环境的JIT编译器也会做指令重排序操作,即生成的机器指令与字节码指令顺序不一致。

    2,as-if-serial语义

    As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。
    Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。

    ps:即指令好像是连续的,是对这种执行效果特性的一个说法。

    为了保证这一语义,重排序不会发生在有数据依赖的操作之中

    3,内存访问重排序与内存可见性

    复制代码
    计算机系统中,为了尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能。
    即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。
    这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。
    
    从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。
    
    有的观点会将这种现象也视为重排序的一种,命名为“内存系统重排序”。
    因为这种内存可见性问题造成的结果就好像是内存访问指令发生了重排序一样。
    (执行了却不知道执行了和以为执行了却重排序没有执行造成相同效果)
    复制代码

    4,内存访问重排序与Java内存模型

    Java的目标是成为一门平台无关性的语言,即Write once, run anywhere. 但是不同硬件环境下指令重排序的规则不尽相同。
    例如,x86下运行正常的Java程序在IA64下就可能得到非预期的运行结果。
    
    为此,JSR-1337制定了Java内存模型(Java Memory Model, JMM),旨在提供一个统一的可参考的规范,屏蔽平台差异性。
    
    从Java 5开始,Java内存模型成为Java语言规范的一部分。

    根据Java内存模型中的规定,可以总结出以下几条happens-before规则。

    (ps:内存模型即通过运行环境把一些可见性和重排序问题统一成一个标准描述)

    Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。

    复制代码
    程序次序法则:     线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
    监视器锁法则:     对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
    volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
    线程启动法则:     在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
    线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
    中断法则:        一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
    终结法则:        一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
    传递性:          如果A happens-before于B,且B happens-before于C,则A happens-before于C
    复制代码

    Happens-before关系只是对Java内存模型的一种近似性的描述,它并不够严谨,但便于日常程序开发参考使用,

    关于更严谨的Java内存模型的定义和描述,请阅读JSR-133原文或Java语言规范章节17.4。

    除此之外,Java内存模型对volatile和final的语义做了扩展。

    对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。
    对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(前提是没有this引用溢出)。

    (ps:没有理解final的意思)

    Java内存模型关于重排序的规定,总结后如下表所示。(ps:下表没看懂)

    5,内存屏障

    内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题

    Java编译器也会根据内存屏障的规则禁止重排序。

    内存屏障可以被分为以下几种类型:

    LoadLoad  屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
    StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
    LoadStore 屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
    StoreLoad 屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
    它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

    有的处理器的重排序规则较严,无需内存屏障也能很好的工作,Java编译器会在这种情况下不放置内存屏障。
    为了实现上一章中讨论的JSR-133的规定,Java编译器会这样使用内存屏障。(ps:下表没看懂)

    四、案例参考

     https://blog.csdn.net/qq_32646795/article/details/78221064

  • 相关阅读:
    1相关介绍
    json c++处理学习
    重定向问题学习
    unordered_multimap学习
    std::bind()功能学习
    grep命令学习
    gdb调试coredump学习
    系统过载理解
    C++中int8_t int16_t、int32_t、int64_t、uint8_t等学习
    awk命令学习
  • 原文地址:https://www.cnblogs.com/pony1223/p/9567416.html
Copyright © 2020-2023  润新知