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

当程序未正确同步时,就能会存在数据竞争。Java 内存模型规范对数据竞争的定义是,在一个线程中写一个变量,在另一个线程中读同一个变量,而且写和读没有通过同步来排序。

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

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

如果程序正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序的顺序一致性内存模型中的执行结果相同。这里的同步是指广义上的同步,包括对常用关键字 volatile、final 和 volatile 的正确使用。

2、顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参考。它为程序提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. 所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型为程序员提供的视图如图:

五、顺序一致性 - 图1

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

举个例子,假设线程 A 和 B 并发执行,其中线程 A 和线程 B 分别都有三个操作:A1->A2->A3 和 B1->B2->B3。

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

五、顺序一致性 - 图2

假设这两个线程没有做同步,执行顺序又是如何呢?如图所示:

五、顺序一致性 - 图3

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

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

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

  1. package com.yj.order;
  2. /**
  3. * @description: 同步锁
  4. * @author: erlang
  5. * @since: 2021-01-13 21:40
  6. */
  7. public class TestSynchronized {
  8. int x = 0;
  9. boolean v = false;
  10. public synchronized void writer() {
  11. x = 1;
  12. v = true;
  13. }
  14. public synchronized void reader() {
  15. if (v) {
  16. int y = x + 1;
  17. }
  18. }
  19. }

在上面的实例代码中,假设线程 A 执行 writer 方法后,线程 B 执行 reader 方法。这是一个同步的多线程程序,根据 JMM 规范,该程序的执行结果将与线程在顺序一致性模型中的执行结果相同。

在顺序一致性模型中,所有操作安全按程序的顺序串行执行。而在 JMM 中,临界内的代码可以重排序(但 JMM 不允许临界区的代码溢出到临界区外,那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这个两个关键时间点做一些特别的处理,使得线程在这两个时间点具有顺序一致性模型的内存视图。虽然线程 A 在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程 B 根本无法观察到线程 A 在临界区的重排序。

五、顺序一致性 - 图4

从这里可以看到,JMM 在具体实现上的基本方针为:在不改变程序执行结果的前提下,按需禁用缓存以及编译优化。

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

对于未同步或正确同步的多线程程序,JMM 指提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0|null|false),不可能是其他值。为了实现最小安全性,JVM 在对上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认值初始化已经完成了。

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

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

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证在单线程内的操作会按程序的顺序执行
  2. 顺序一致性模型保障证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序
  3. JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性

第三个差异预处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。读事务从内存传送数据到处理器,写事务处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字节。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 I/O 设备执行内存的读/写。如下图

五、顺序一致性 - 图5

假设,处理器 A、B 和 C 同时向中线发起总线事务,这时总线仲裁(Bus Arbitration)会对竞争做出裁决,这里假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其他两个处理器则要等待处理器 A 的总线事务完成后才能再次执行内存访问。假设处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的请求会被总线禁止。

总线的这些工作机制可以把所有的处理器对内存的访问一串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作原子性。

5、long 和 double 类型变量的特殊规则

Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都具有原子性。在一些 32 位处理器上,如果要求对 64 位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,对于 64 位的数据类型(long 和 double),在模型中特别定义了一条宽松的规定:运行虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性,这点就是所谓的 long 和 double 的非原子性协定(Nonatomic Treatment of double and long Variables)。

当 JVM 在这种处理器上运行时,可能会把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作执行。这两个 32 位的写操作可能会被分配到不同的中心事务中执行,此时对这个 64 位变量的写操作将不具有原子性。如图所示:

五、顺序一致性 - 图6

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被分配到单个的事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A 写了一半的无效值,是一个半个变量的数值。

注意:

  • JSR-133 之前的旧内存模型中,一个 64 位 long/double 类型的变量读/写操作可以被拆分为两个 32 位的读/写操作来执行。同时弹出两个槽的数据,和同时压入两个槽的数据,保证原子操作。
  • JSR-133 之后的内存模型开始(JDK1.5 开始),仅仅只允许把一个 64 位 long/double 类型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性,即任意读操作必须要在单个读事务中执行。
  • 现在商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到 long 和 double 变量专门声明为 volatile 变量。