内存模型之顺序一致性

前言

顺序一致性是程序执行过程中可见性和顺序的强有力保证。在顺序一致的执行过程中,所有动作(如读和写)间存在一个全序关系,与程序的顺序一致。每个动作都是原子的且立即对所有线程可见。如果一个程序没有数据争用,那么该程序的执行看起来将是顺序一致的。如前面所提到的,在一组操作要保持原子性而未得到保证时,即使有顺序一致性和/或未遭遇数据争用,仍然可能会出现错 误。

1、数据竞争和数据一致性保证

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

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

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

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

  • 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)--即
    程序的执行结果与该程序在顺序一致性内存模型中的执行结 果相同。马上我们将会看到,这对于程序员来说是一个极强的保证。这里的同 步是指广义上的同步,包括对常用同步原语(synchronized,volatile 和 final) 的正确使用。

2、顺序一致性内存模型

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

  •  一个线程中的所有操作必须按照程序的顺序来执行。 
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。

在顺序 一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:

                                               

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的 开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读 /写操作。从上面的示意图我们可以看出,在任意时间点最多只能有一个线程可以 连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 /写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。

为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的 说明。

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

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

               

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


         

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

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

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

下面我们对前面的示例程序 ReorderExample 用锁来同步,看看正确同步的程序如 何具有顺序一致性。

class 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 会在退出临界区和进入临界区这两个关键时间 点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视 按程 序顺 序执 行 按程 序顺 序执 行 临界 区内 可以 重排 序 临界区 内可以 重排序 在 JMM 中的执 行 在顺序一 致性模型 中的执行 时间 flag = true; if (flag) int i = a; a = 1; A 获取锁 A 释放锁 B 获取锁 B 释放锁 A 获取锁 flag = true; a = 1; A 释放锁 B 获取锁 if (flag) int i = a; B 释放锁 图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的 互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。 这种重排序既提高了执行效率,又没有改变程序的执行结果。 

从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程 序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

4、未同步程序的执行

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

 JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一 致。因为如果想要保证执行结果一致,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的这个请求会被总线处理器A处理器C总线内存处理器B处理器D内存访问A内存访问B内存访问C内存访问D内存访问A 禁止。

 总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任 意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中 的内存读/写操作具有原子性。 在一些 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 中都必须具有原子性(即 任意读操作必须要在单个读事务中执行)。


参考文献

【转】深入理解Java内存模型(三)


猜你喜欢

转载自juejin.im/post/5bf21dbde51d45130a186c29