乐趣区

关于java:98的程序员都没有研究过JVM重排序和顺序一致性

文章整顿自 博学谷狂野架构师

重排序

数据依赖性

如果两个操作拜访同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称 代码示例 阐明
写后读 a = 1;b = a; 写一个变量之后,再读这个地位。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

下面三种状况,只有重排序两个操作的执行程序,程序的执行后果将会被扭转。

后面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会恪守数据依赖性,编译器和处理器不会扭转存在数据依赖关系的两个操作的执行程序。

留神,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器思考。

as-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了进步并行度),(单线程)程序的执行后果不能被扭转。编译器,runtime 和处理器都必须恪守 as-if-serial 语义。

为了恪守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会扭转执行后果。然而,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体阐明,请看上面计算圆面积的代码示例:

COPYdouble pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

下面三个操作的数据依赖关系如下图所示:

如上图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因而在最终执行的指令序列中,C 不能被重排序到 A 和 B 的后面(C 排到 A 和 B 的后面,程序的后果将会被扭转)。但 A 和 B 之间没有数据依赖关系,编译器和处理器能够重排序 A 和 B 之间的执行程序。下图是该程序的两种执行程序:

as-if-serial 语义把单线程程序爱护了起来,恪守 as-if-serial 语义的编译器,runtime 和处理器独特为编写单线程程序的程序员创立了一个幻觉:单线程程序是按程序的程序来执行的。as-if-serial 语义使单线程程序员无需放心重排序会烦扰他们,也无需放心内存可见性问题。

程序程序规定

依据 happens- before 的程序程序规定,下面计算圆的面积的示例代码存在三个 happens- before 关系:

COPYA happens- before B;
B happens- before C;
A happens- before C;

这里的第 3 个 happens- before 关系,是依据 happens- before 的传递性推导进去的。

这里 A happens- before B,但理论执行时 B 却能够排在 A 之前执行(看下面的重排序后的执行程序),如果 A happens- before B,JMM 并不要求 A 肯定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的后果)对后一个操作可见,且前一个操作按程序排在第二个操作之前。这里操作 A 的执行后果不须要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行后果,与操作 A 和操作 B 按 happens- before 程序执行的后果统一。在这种状况下,JMM 会认为这种重排序并不非法(not illegal),JMM 容许这种重排序。

在计算机中,软件技术和硬件技术有一个独特的指标:在不扭转程序执行后果的前提下,尽可能的开发并行度。编译器和处理器听从这一指标,从 happens- before 的定义咱们能够看出,JMM 同样听从这一指标。

重排序对多线程的影响

当初让咱们来看看,重排序是否会扭转多线程程序的执行后果。请看上面的示例代码:

COPYclass ReorderExample {
    int a = 0;
    boolean flag = false;

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

    public void reader() {if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

flag 变量是个标记,用来标识变量 a 是否已被写入。这里假如有两个线程 A 和 B,A 首先执行 writer()办法,随后 B 线程接着执行 reader()办法。线程 B 在执行操作 4 时,是否看到线程 A 在操作 1 对共享变量 a 的写入?

答案是:不肯定能看到。

因为操作 1 和操作 2 没有数据依赖关系,编译器和处理器能够对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也能够对这两个操作重排序。让咱们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么成果?请看上面的程序执行时序图:

如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。因为条件判断为真,线程 B 将读取变量 a。此时,变量 a 还基本没有被线程 A 写入,在这里多线程程序的语义被重排序毁坏了!

注:本文对立用红色的虚箭线示意谬误的读操作,用绿色的虚箭线示意正确的读操作。

上面再让咱们看看,当操作 3 和操作 4 重排序时会产生什么成果(借助这个重排序,能够顺便阐明管制依赖性)。

上面是操作 3 和操作 4 重排序后,程序的执行时序图:

在程序中,操作 3 和操作 4 存在管制依赖关系。当代码中存在管制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采纳猜想(Speculation)执行来克服管制相关性对并行度的影响。以处理器的猜想执行为例,执行线程 B 的处理器能够提前读取并计算 a *a,而后把计算结果长期保留到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。

从图中咱们能够看出,猜想执行本质上对操作 3 和 4 做了重排序。重排序在这里毁坏了多线程程序的语义!

在单线程程序中,对存在管制依赖的操作重排序,不会扭转执行后果(这也是 as-if-serial 语义容许对存在管制依赖的操作做重排序的起因);但在多线程程序中,对存在管制依赖的操作重排序,可能会改变程序的执行后果。

程序一致性

数据竞争与程序一致性保障

当程序未正确同步时,就会存在数据竞争。java 内存模型标准对数据竞争的定义如下:

  • 在一个线程中写一个变量,
  • 在另一个线程读同一个变量,
  • 而且写和读没有通过同步来排序。

当代码中蕴含数据竞争时,程序的执行往往产生违反直觉的后果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM 对正确同步的多线程程序的内存一致性做了如下保障:

如果程序是正确同步的,程序的执行将具备程序一致性(sequentially consistent)–即程序的执行后果与该程序在程序一致性内存模型中的执行后果雷同(马上咱们将会看到,这对于程序员来说是一个极强的保障)。这里的同步是指狭义上的同步,包含对罕用同步原语(lock,volatile 和 final)的正确应用。

程序一致性内存模型

程序一致性内存模型是一个被计算机科学家理想化了的实践参考模型,它为程序员提供了极强的内存可见性保障。程序一致性内存模型有两大个性:

  • 一个线程中的所有操作必须依照程序的程序来执行。
  • (不论程序是否同步)所有线程都只能看到一个繁多的操作执行程序。在程序一致性内存模型中,每个操作都必须原子执行且立即对所有线程可见。

程序一致性内存模型为程序员提供的视图如下:

在概念上,程序一致性模型有一个繁多的全局内存,这个内存通过一个左右摆动的开关能够连贯到任意一个线程。同时,每一个线程必须按程序的程序来执行内存读 / 写操作。从上图咱们能够看出,在任意工夫点最多只能有一个线程能够连贯到内存。当多个线程并发执行时,图中的开关安装能把所有线程的所有内存读 / 写操作串行化。

为了更好的了解,上面咱们通过两个示意图来对程序一致性模型的个性做进一步的阐明。

假如有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的程序是:A1->A2->A3。B 线程也有三个操作,它们在程序中的程序是:B1->B2->B3。

假如这两个线程应用监视器来正确同步:A 线程的三个操作执行后开释监视器,随后 B 线程获取同一个监视器。那么程序在程序一致性模型中的执行成果将如下图所示:

当初咱们再假如这两个线程没有做同步,上面是这个未同步程序在程序一致性模型中的执行示意图:

未同步程序在程序一致性模型中尽管整体执行程序是无序的,但所有线程都只能看到一个统一的整体执行程序。以上图为例,线程 A 和 B 看到的执行程序都是:B1->A1->A2->B2->A3->B3。之所以能失去这个保障是因为程序一致性内存模型中的每个操作必须立刻对任意线程可见。

然而,在 JMM 中就没有这个保障。未同步程序在 JMM 中岂但整体的执行程序是无序的,而且所有线程看到的操作执行程序也可能不统一。比方,在以后线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对以后线程可见;从其余线程的角度来察看,会认为这个写操作基本还没有被以后线程执行。只有以后线程把本地内存中写过的数据刷新到主内存之后,这个写操作能力对其余线程可见。在这种状况下,以后线程和其它线程看到的操作执行程序将不统一。

同步程序的程序一致性成果

上面咱们对后面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具备程序一致性。

请看上面的示例代码:

COPYclass SynchronizedExample {
    int a = 0;
    boolean flag = false;

    public synchronized void writer() {
        a = 1;
        flag = true;
    }

    public synchronized void reader() {if (flag) {
            int i = a;
            ……
        }
    }
}

下面示例代码中,假如 A 线程执行 writer()办法后,B 线程执行 reader()办法。这是一个正确同步的多线程程序。依据 JMM 标准,该程序的执行后果将与该程序在程序一致性模型中的执行后果雷同。上面是该程序在两个内存模型中的执行时序比照图:

在程序一致性模型中,所有操作齐全按程序的程序串行执行。而在 JMM 中,临界区内的代码能够重排序(但 JMM 不容许临界区内的代码“逸出”到临界区之外,那样会毁坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个要害工夫点做一些特地解决,使得线程在这两个工夫点具备与程序一致性模型雷同的内存视图(具体细节后文会阐明)。尽管线程 A 在临界区内做了重排序,但因为监视器的互斥执行的个性,这里的线程 B 根本无法“察看”到线程 A 在临界区内的重排序。这种重排序既进步了执行效率,又没有改变程序的执行后果。

从这里咱们能够看到 JMM 在具体实现上的基本方针:在不扭转(正确同步的)程序执行后果的前提下,尽可能的为编译器和处理器的优化关上方便之门。

未同步程序的执行个性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保障线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM 在堆上调配对象时,首先会清零内存空间,而后才会在下面调配对象(JVM 外部会同步这两个操作)。因而,在以清零的内存空间(pre-zeroed memory)调配对象时,域的默认初始化曾经实现了。

JMM 不保障未同步程序的执行后果与该程序在程序一致性模型中的执行后果统一。因为未同步程序在程序一致性模型中执行时,整体上是无序的,其执行后果无奈预知。保障未同步程序在两个模型中的执行后果统一毫无意义。

和程序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行后果也无奈预知。同时,未同步程序在这两个模型中的执行个性有上面几个差别:

  1. 程序一致性模型保障单线程内的操作会按程序的程序执行,而 JMM 不保障单线程内的操作会按程序的程序执行(比方下面正确同步的多线程程序在临界区内的重排序)。这一点后面曾经讲过了,这里就不再赘述。
  2. 程序一致性模型保障所有线程只能看到统一的操作执行程序,而 JMM 不保障所有线程能看到统一的操作执行程序。这一点后面也曾经讲过,这里就不再赘述。
  3. JMM 不保障对 64 位的 long 型和 double 型变量的读 / 写操作具备原子性,而程序一致性模型保障对所有的内存读 / 写操作都具备原子性。

    第 3 个差别与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来实现的,这一系列步骤称之为总线事务(bus transaction)。总线事务包含读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上间断的字。这里的要害是,总线会同步试图并发应用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I / O 设施执行内存的读 / 写。上面让咱们通过一个示意图来阐明总线的工作机制:

如上图所示,假如处理器 A,B 和 C 同时向总线发动总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里咱们假如总线在仲裁后断定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能偏心的拜访内存)。此时处理器 A 持续它的总线事务,而其它两个处理器则要期待处理器 A 的总线事务实现后能力开始再次执行内存拜访。假如在处理器 A 执行总线事务期间(不论这个总线事务是读事务还是写事务),处理器 D 向总线发动了总线事务,此时处理器 D 的这个申请会被总线禁止。

总线的这些工作机制能够把所有处理器对内存的拜访以串行化的形式来执行;在任意工夫点,最多只能有一个处理器能拜访内存。这个个性确保了单个总线事务之中的内存读 / 写操作具备原子性。

在一些 32 位的处理器上,如果要求对 64 位数据的写操作具备原子性,会有比拟大的开销。为了关照这种处理器,java 语言标准激励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的写具备原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的写操作拆分为两个 32 位的写操作来执行。这两个 32 位的写操作可能会被调配到不同的总线事务中执行,此时对这个 64 位变量的写将不具备原子性。

当单个内存操作不具备原子性,将可能会产生意想不到结果。请看上面示意图:

如上图所示,假如处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被调配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被调配到单个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A“写了一半“的有效值。

留神,在 JSR -133 之前的旧内存模型中,一个 64 位 long/ double 型变量的读 / 写操作能够被拆分为两个 32 位的读 / 写操作来执行。从 JSR -133 内存模型开始(即从 JDK5 开始),仅仅只容许把一个 64 位 long/ double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR -133 中都必须具备原子性(即任意读操作必须要在单个读事务中执行)。

本文由 传智教育博学谷狂野架构师 教研团队公布。

如果本文对您有帮忙,欢送 关注 点赞 ;如果您有任何倡议也可 留言评论 私信,您的反对是我保持创作的能源。

转载请注明出处!

退出移动版