JUP JMM (二) Reorder And Sequential Consistency


一、重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

1、数据依赖性

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

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

上述三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

前面提到过,编译器和处理器可能会对操作进行重排序。编译器和处理器再重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

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

2、as-if-serial 语义

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

为了遵守 as-if-serial,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

比如下面这段代码用于计算圆的面积:

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

A 和 C 之间存在数据依赖关系(写后读),同时 B 和 C 之间也存在数据依赖关系(写后读)。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、runtime 和处理器共同为编写单线程程序的程序员创建了一种幻觉:单线程程序是按程序的顺序来执行的。

as-if-serial 语义使单线程程序员无需担心重排序会影响到他们,也无需担心内存可见性问题。

3、程序顺序规则

根据 happens-before 的程序顺序规则,上面计算圆的面积的代码存在 3 个 happens-before 关系:

(1)A happens-before B

(2)B happens-before C

(3)A happens-before C

这里的第三个 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 同一遵从这一目标。

4、重排序对多线程的影响

下面来看一看,重排序是否会对多线程造成影响,代码如下:

class 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 存在 控制依赖关系。当代码中存在控制依赖关系时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。

以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a * a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。

从上图可以看出,猜测执行实质上就是对操作 3 和操作 4 做了重排序(虽然它们不存在数据依赖关系,但是存在控制依赖关系)。重排序在这里破环了多线程程序的语义!

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

二、顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。

1、数据竞争与顺序一致性

当程序未正确同步时,就可能会存在数据竞争。Java 内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量
  • 在另一个线程中读同一个变量
  • 而且写和读没有通过同步来排序
  • 当代码中包含数据竞争时,程序的执行往往会产生违反直觉的结果。如果一个多线程程序能够正确同步,这个程序将是一个没有数据竞争的程序。

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

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)—— 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile 和 final)的正确使用。

2、顺序一致性内存模型

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

(1)一个线程中的所有操作必须按照程序的顺序来执行;

(2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。

从上面的图中可以看到,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

再举个例子:

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

B 线程也有三个操作,它们在程序中的顺序是:B1 -> B2 -> B3

假设这两个线程使用监视器锁来正确同步:A 线程的 3 个操作执行后立即释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果如下图:

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 都看到的执行顺序是:

B1 -> A1 -> A2 -> B2 -> A3 -> B3

之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。

比如,在当前线程把写过的数据缓存到本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。

3、同步程序的顺序一致性效果

下面对前面的示例程序用锁来进行同步,看看正确同步的程序如何具有顺序一致性:

class ReorderExample {
    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 在具体实现上的基本方针为:在不改变(正确同步)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。

4、未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的的值,要么是默认值(0,Null,False),JMM 保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来。

为了实现最小安全性,JMM 在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM 需要禁止大量的处理器和编译器优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没什么意义。

未同步程序在 JMM 中执行时,整体是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有以下几个差异:

(1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如前面正确同步的多线程程序在临界区内的重排序);

(2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序;

(3)JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性,而顺序一致性模型保证了对所有的内存读/写都具有原子性。

第三个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为 总线事务(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 内存模型开始(即从 JDK 5 开始),仅仅只允许把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。


Author: NaiveKyo
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source NaiveKyo !
  TOC