JMM与顺序一致模型和happens-before模型的关系和区别
详细介绍了Java内存模型、顺序一致性内存模型、原始的happens-before内存模型三者之间的区别和联系。
Java内存模型的设计,参考了顺序一致性内存模型和原始的happens-before内存模型,吸收了他们的优点,改进了他们的缺点。
1 顺序一致的内存模型
1.1 数据竞争与顺序一致性保证
多线程下,当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:在一个线程中写一个变量;在另一个线程读同一个变量;而且写和读没有通过同步来排序!
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)-- 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。下面来看看顺序一致性内存模型。
1.2 顺序一致性内存模型
顺序一致性内存模型(Sequential Consistency Memory Model)是一个被计算机科学家理想化了的理论参考模型,是程序执行过程中可见性和顺序的强有力保证。在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
顺序一致性内存模型有两大特性:
一个线程中的所有操作必须按照程序的顺序来执行。
(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
顺序一致性内存模型为程序员提供的视图如下:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,开关装置能把所有线程的所有内存读/写操作串行化。
假设这两个线程使用监视器锁来正确同步:A 线程的三个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果如下:
现在再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序:以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在还没有刷新到主内存之前,这个写操作仅对当前线程可见。
从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。
1.3 同步程序的顺序一致性效果
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在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
2.4 未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。
和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:
顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。
2 原始的happens-before内存模型
2.1 因果关系问题
原始的happens-before模型太弱了,所有Java内存模型允许的行为,happens-before内存模型也允许,但是有些行为是Java内存模型不允许的,比如以不可捉摸的方法违反因果关系——允许某些值凭空出现。如下图一:
如上代码,在正确同步的原始的happens-before内存模型中,存在执行结果是r1 = r2 = 1的情况。因为在原始的happens-before内存模型中,代码可能被优化为:
这就导致了不可能出现的结果r1 = r2 = 1出现。
当一个写操作发生在了一个其依赖的读操作之前,我们将这样的问题称为因果关系,因为它涉及写操作是否会触发自身发生的问题。读操作促使写操作发生,然后写操作使得读操作能看到它们都看到的值。
2.2 Java内存模型的改进
Java内存模型将一个特定的执行过程和一个程序作为输入,然后确定该执行过程是否是该程序的一次合法执行。它是通过逐步地建立一组“提交的”动作来实现的,这些动作反映出了我们知道的哪些动作能够被程序执行而不需要一个“因果循环”。
通常,下一个将要提交的动作表示的是能被顺序一致的执行过程执行的下一个动作。然而,为了表明读操作能看到程序顺序里后面其它线程写的值,我们允许一些动作比更早发生的其它动作先提交。
Java内存模型允许下图二中的行为,即使该例子看似也存在循环因果关系。必须允许这样的行为,因为编译器能够:消除多余的读取a的操作,将r2 = a替换为r2 = r1,然后,确定了表达式r1 == r2总是为true,消除条件分支3,然后最终将4: b = 2移到前面。这对编译器的性能提升是很有帮助的。
有些动作可以提前提交,有些则不能。Java内存模型不允许最开始图一中r1 = r2 = 1的情况发生,但是允许图二的替换情况发生。Java内存模型会判断某个动作的发生不会产生数据争用,就允许该动作提前提交。
所谓数据争用(data race),简单的解释就是:一个线程里有个写操作,另一个线程读取了这个写入的变量值,且读写操作没有被同步排序。当上述情况发生时,称之为存在数据争用。
对于图一由于不知道争用数据x的值,因此Java内存模型不允许写操作发生在读之前;对于图二,在Jvav内存模型看来。虽然不知道a的值,但是r1==r2始终为true,因此允许优化!
3 总结
顺序一致性内存模型对于Java来说,它太严格了,不适合做Java内存模型,因为它禁止了标准的编译器和处理器优化,影响性能。然后是原始happens-before内存模型,这个模型已经非常接近Java内存模型的需求,但是,它太弱了,其允许违反因果关系这种不可接受的事情发生,这对于Java内存模型和程序员来说都是不可接受的。因此Java使用了改良后的 happens-before内存模型,形成了自己跌内存模型,即JMM。
关于JMM和改良后的happens-before内存模型,详情可见这篇文章:Java内存模型与happens-before原则详解。
作者:刘Java
链接:https://juejin.cn/post/7018102207870402591