0%

Java-顺序一致性

阅读更多

1 前言

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

2 数据竞争与顺序一致性

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

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

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

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

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

3 顺序一致性内存模型

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

  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中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致

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

4 同步程序的顺序一致性效果

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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;
...
}
} //释放锁
}
  • 在上面的实例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同,下面是该程序在两个内存模型中的执行时序对比图

执行时序对比图

  • 在顺序一致性模型中,所有操作完全按程序的顺序串行执行
  • 在JMM中,临界区内的代码可以重排序(JMM不允许临界区内的代码"逸出"到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键点做一些特殊处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存模型
    • 虽然线程A在临界区内做了重排,但是由于监视器的特性,这里的线程B根本无法"观察"到线程A在临界区内的重排序。这种重排序既提高了效率,有没有改变程序执行结果

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

5 未同步程序的执行特性

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

  • 为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JMM内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed 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的请求会被总线禁止
  • 总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时刻,最多只能有一个处理器可以访问内存,这个特性确保了单个总线事务之中的内存读/写操作具有原子性

在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性(但事实上,大多数商用的JVM都保证long和double变量的写操作原子性,例如Hot-spot)。当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中都必须具有原子性(即将任意读操作必须要在单个读事务中执行)

6 参考

  • 《Java并发编程的艺术》