深入理解Java内存模型(三)——顺序一致性

本文属于作者原创,原文发表于InfoQ:http://www.infoq.com/cn/articles/java-memory-model-3

数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。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用监视器来同步,看看正确同步的程序如何具有顺序一致性。

请看下面的示例代码:

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会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程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中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

参考文献

  1. JSR-133: Java Memory Model and Thread Specification
  2. Shared memory consistency models: A tutorial
  3. The JSR-133 Cookbook for Compiler Writers
  4. 深入理解计算机系统(原书第2版)
  5. UNIX Systems for Modern Architectures: Symmetric Multiprocessing and Caching for Kernel Programmers

作者简介

程晓明,Java软件工程师,国家认证的系统分析师、信息项目管理师。专注于并发编程。个人邮箱:asst2003@163.com


原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 深入理解Java内存模型(三)——顺序一致性

  • Trackback 关闭
  • 评论 (12)
    • nicky
    • 2013/02/01 9:45上午

    博主,你好呀。请教些问题,你在例子中有写到一个reader()和writer()这2个同步方法,现在假设线程A和线程B同时向JVM发出申请(其中A是写,B是读),请求获得锁,那么JVM会把锁给哪个线程,给不同的线程,就会有不同的结果,如果说我们想要先写后读,那怎么该如何去处理?

      • 程晓明
      • 2013/02/03 2:18上午

      你好
      你太客气了。大家在这里互相学习,互相交流,谈不上请教的。
      现在假设线程A和线程B同时向JVM发出申请(其中A是写,B是读),请求获得锁,那么JVM会把锁给哪个线程
      –在这种情况下,会使用处理器的同步指令。
      处理器的同步指令分为两类:
      1:intel和AMD使用CAS(compare-and-swap)
      2:IBM PowerPC和ARM使用LL/SI(load-linked和store-conditional)
      这两种同步指令都能以原子方式对内存执行读-改-写操作。
      假设A线程在CPU1上执行,线程B在CPU2上执行。这两个线程同时请求获取锁。这时,这两个处理器同时执行上面的同步指令。由于同步指令保证原子性,因此最多只能有一个处理器在竞争中获胜,在这个竞争中获胜的处理器上执行的那个线程将获得锁。

      给不同的线程,就会有不同的结果,如果说我们想要先写后读,那怎么该如何去处理?
      –想要先写-后读,可以参考JDK文档中的一个例子:
      http://docs.oracle.com/javase/6/docs/api/java/util/concurrent/locks/Condition.html
      这个例子讲的是写线程与读线程之间如何互相协调工作,希望能对你有用。
      可以直接看JDK文档的中文版,JDK文档的中文版翻译的很好。

  1. 您好,请问能否分享一下java 中的dcl和volatile,为什么1.5之后,volatile会使得DCL的写法合法。1.4对volatile以及1.5对volatile的语义有什么不同。谢谢。

      • 程晓明
      • 2013/03/13 9:54下午

      好的。

      这几天在疯狂加班,人基本处于崩溃的边缘:)

      缓两天,我会抽空写出来。

        • tiger.zhou
        • 2013/03/18 12:15下午

        好的哈哈。3Q

    • 信言
    • 2013/11/10 12:05上午

    请教博主,在JLS里(http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7)似乎只提到写long和double可能是非原子的,“任意的读操作在JSR -133中都必须具有原子性”的出处在哪儿?非常感谢!

      • 程晓明
      • 2013/11/10 9:22下午

      您好
      “任意的读操作在JSR133中都必须具有原子性”
      –这句话是我对单个变量的读/写特性的总结。
      注意,这里的操作指的是“对单个变量的读/写”

      在《The JavaTM Virtual Machine Specification Second Edition》的“8.4 Nonatomic Treatment of double and long Variables”中指出:对非volatile的double/long型变量的读/写,可以被拆分为两个32位的读/写操作:
      http://docs.oracle.com/javase/specs/jvms/se5.0/html/Threads.doc.html#22244
      请注意看这个章节的最后一段话:
      It would have been simpler for the Java virtual machine to define all memory transactions on single variables as atomic; this more complex definition is a pragmatic concession to current hardware practice. In the future this concession may be eliminated. Meanwhile, programmers are cautioned to explicitly synchronize access to shared double and long variables.
      规范在当年(大约是1999年?)已经指出,这种让步在将来的规范中可以会被删除。

      我们再来看2004年推出的JSR133规范(java语言规范第三版和java语言规范SE7版的第17章,其实都是引用jsr133规范),在“12 Non-atomic Treatment of double and long”中指出:非volatile的double/long型变量的写可以被拆分为两个32位的写操作。
      注意与上面的JVM规范第二版对比后我们会发现:在JSR133中已经不允许64位的非volatile的double/long型变量的读操作被拆分两个32位的读操作了!也就是说:任意的读操作在JSR133中都必须具有原子性。

    • TryLoveCatch
    • 2014/02/25 4:59下午

    博主写的非常好,一直在看 感觉这个网站很棒,特地注册 支持个

    • zwm512327
    • 2014/04/12 6:47上午

    写的很用心,受教。感谢作者

    • okone
    • 2014/08/28 5:15下午

    博主您好,文章结尾说道:
    从JSR -133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/ double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR -133中都必须具有原子性

    那么拆分的两个操作之间是可能插入其他操作,对么?这样的话是否也会产生一些奇怪的情况?

    • facedamon
    • 2018/09/11 4:53下午

    博主您好,这个系列的文章,我怎么都看不到文中的图片了?

return top