写点什么

【并发编程的艺术】Java 内存模型的顺序一致性

发布于: 2021 年 01 月 26 日
【并发编程的艺术】Java内存模型的顺序一致性

系列文章:

【并发编程的艺术】JVM 体系与内存模型

【并发编程的艺术】JAVA 并发机制的底层原理

【并发编程的艺术】JAVA 原子操作实现原理

【并发编程的艺术】JVM 内存模型

【并发编程的艺术】详解指令重排序与数据依赖

一 概念

首先明确一点,顺序一致性内存模型是一个被理想化了的理论参考模型,提供了很强的内存可见性保证。其两大特性如下:

1)一个线程中的所有操作,必须按照程序的顺序来执行(代码编写顺序)

2)无论程序是否同步,所有线程都只能看到一个单一的操作执行顺序。每个操作都必须原子执行且立即对所有线程可见。

对开发者来说,视图如下:

从图中可以看出,该模型有一个单一的全局内存,这个内存通过一个开关连接到任意一个线程,同时每个线程都必须按照程序的顺序执行内存的读/写操作。任意时刻,最多只能有一个线程可以连接到内存。当多线程并发时,这个开关会把所有线程的所有内存读/写操作串行化执行。

二 案例示意

有 A、B 两个线程并发执行,且各自都有 3 个操作。A: A1->A2->A3;B:B1->B2->B3。当这两个线程使用监视器锁来保证同步执行:A 线程先获取监视器锁;A 的 3 个步骤执行完成后释放监视器锁;B 获取同一个监视器锁,B 按顺序执行 3 个操作完成。那么整个程序在顺序一致性模型中的执行顺序应该如下图所示:

但如果没有做同步,那么执行流程可能如下图所示:

在这种情况下,看起来是乱序的,虽然只看 A 线程或只看 B 线程依然保持顺序不变。且所有线程都只能看到一个一致的整体执行顺序,即:B1->A1->A2->B2->A3->B3。这点的保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见

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

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

在回顾一下前面章节中提到过的示例代码,这里会加上同步控制:

public 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;            //other action ...        }    }}
复制代码

writer() 和 reader()两个方法是同步方法(通过 synchronized 关键字标记),两个线程 A、B,A 执行 writer()方法后,B 线程执行 reader(),这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果与该程序在顺序一致性模型中的执行结果相同。

顺序一致性模型中,所有操作完全按照程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逃逸”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键位置做特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程 A 在临界区内做了重排徐,但由于监视器互斥执行的特性,线程 B 根本无法感知到线程 A 在临界区内的重排序。通过这样的方式,既提高了执行效率,又没有改变程序的执行结果。

在顺序一致性模型,和 JMM 的执行效果如下图所示:

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

四 未同步程序的执行特性

JMM 对未同步或未正确同步的多线程程序,只提供最小安全性,即:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0, Null, False),JMM 保证线程读操作读取到的值不会凭空(Out of thin Air)冒出来

为了实现最小安全性,JVM 在堆上分配对象时,会对内存空间进行清零,然后才会在上面分配对象(JVM 内部会对这两个操作做同步)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。

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

结合前面几篇文章中的描述,总结未同步程序在顺序一致性模型,和 JMM 这两种模型中的执行特性差异包括:

1)顺序一致性模型保证单线程内的操作会按照代码编写的顺序执行,而 JMM 不保证单线程内的操作会按照代码编写的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序);

2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证

3)JMM 不保证对 64 位的 long 型和 double 型变量的写操作的原子性,而顺序一致性模型保证(对所有的内存读/写操作都具有原子性)

差异 3 与处理器总线的工作机制有关,示意图如下:

上图描述了总线的工作机制:数据通过总线在处理器和(主)内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称为总线事务(Bus Transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步视图并并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 I/O 设备执行内存的读/写。

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

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

当单个内存操作不具有原子性时,可能会产生意想不到的后果。例如:

前面提到过,long 和 double 是 64 位,处理器 A 对 long 变量的操作会拆成高 32 位和低 32 位的两个写操作,且这两个 32 位的写操作可能被分配到不同的写事务中执行。同时,B 中的 64 位读操作被分配到单个的都市无中执行,当两个处理器中的操作按照上图的时序执行时,处理器 B 会看到被 A”写了一半“的无效值。

注:

JSR-133 之前的旧内存模型中,一个 64 位的 long/double 类型变量的读/写操作可以被拆分为两个 32 位读/写操作来执行。

JSR-133 内存模型开始(JDK5),只允许报一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR-133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

五 总结

通过本章内容,我们终于深入到了多线程场景,并发执行问题的根源。总线的工作机制,顺序一致性模型的理想情况,以及 JMM 在性能与一致性上的折衷。通过这些,我们了解到了问题产生的原因。在下一篇文章中,我们将介绍 volatile、synchronized、final 域的内存语义,来看它们是怎样解决这些问题的,以及各自的适用场景。

发布于: 2021 年 01 月 26 日阅读数: 15
用户头像

磨炼中成长,痛苦中前行 2017.10.22 加入

微信公众号【程序员架构进阶】。多年项目实践,架构设计经验。曲折中向前,分享经验和教训

评论

发布
暂无评论
【并发编程的艺术】Java内存模型的顺序一致性