《并发编程的艺术》第三章:Java内存模型

    技术2024-10-13  55

    3 Java内存模型(JMM)

    本章内容:

    JMM基础:基本概念;JMM中的顺序一致性:重排序和顺序一致性MM;同步原语:3个,分别是synchronized、volatile和final。介绍它们的内存语义及重排序规则在处理器中的实现;JMM的设计:JMM的设计原理,及其与处理器MM和顺序一致性MM的关系。

    3.1 JMM的基础

    3.1.1 JMM的两个关键问题

    关键问题:线程如何通信?如何同步? 命令式编程中,线程之间的通信机制:共享内存和消息传递。 通信:线程间程序公共状态。 同步:用于控制不同线程间发生相对顺序的机制。 共享内存:线程隐式通信、显式同步。 消息传递:线程显式通信、隐式同步。

    3.1.2 JMM的抽象结构

    在Java中,堆内存存储所有实例域、静态域和数组元素,且线程间共享。 “共享变量”:实例域、静态域和数组元素。 局部变量、方法定义参数和异常处理参数不会在线程间共享,它们不会有内存可见性问题,也不受MM影响。 Java线程通信由JMM控制。JMM决定线程对共享变量的写入何时对其他线程可见。 从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。 本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。 JMM的抽象示意如下图示。 上图两线程要通信,必须要经历下面两步骤。 1)线程A把本地内存A中更新的共享变量刷新到主内存中; 2)线程B到主内存中去读取线程A之前已更新过的共享变量。 这个步骤实质是是线程A向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存的交互,来为Java程序员提供内存可见性保证。

    3.1.3 从源代码到指令序列的重排序

    为了提高性能,编译器和处理器经常会对指令做重排序。重排序分三种类型: 1)编译器优化。不改变单线程语义。 2)指令集并行。如果不存在数据依赖性,改变语句对应机器指令的执行顺序。 3)内存系统。使用缓存和读/写缓冲区。 从Java源代码到最终实际执行的指令序列,会分别依次经历上面三种重排序。 1)属于编译器重排序,2)和3)属于处理器~。 对于编译器,会禁止特定类型。 对于处理器~,插入内存屏障。 Java属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

    3.1.4 并发编程模型的分类

    写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。 以批处理方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。 每个处理器上的写缓冲区仅对它所在的处理器可见。 假设两处理器按程序的顺序并执行内存访问,最终可能得到x=y=0的结果。 处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1, B1),然后从内存中读取另一个共享变量(A2, B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3, B3)。当一这种时序执行,就可以得到x=y=0的结果。 以内存操作实际发生的顺序看,直到刷新写缓存区,写操作才算真正执行。执行顺序是A1->A2,实际顺序则是A2->A1。操作顺序被重排序了。 这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。 上图是处理器的重排序规则。 常见的处理器都允许Store-Load重排序;常见的处理器都不允许对 存在数据依赖的操作做重排序。sparc-TSO和X86拥有相对较强的处理器内存模型,它们仅允 许对写-读操作做重排序(因为它们都使用了写缓冲区)。 图片注意: sparc-TSO是指TSO内存模型运行时sparc处理器的特性。 X86包括X64及AMD64。 ARM和PowerPC非常类似,忽略。 数据依赖性后文专门说明。 为了保证内存可见性, Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障分为四类。

    StoreLoad Barriers拥有其他三个屏障的效果。现代的多处理器大多支持该屏障。不过执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区的数据全部刷新到内存中。

    3.1.5 happens-before简介

    一个操作执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这两个操作可在同一线程,也可以在不同线程。 happens-before规则如下:

    程序顺序规则:一个县城中的每个操作,happens-before与该线程的任意后续操作。监视器锁顺序:对一个锁的解锁,happens-before与随后对这个锁的加锁。volatile变量规则:对一个volatile域的写,happens-before与任意后续对这个volatile域的读。传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。 注意: 两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。后面会说明happens-before为什么要这么定义。 一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

    3.2 重排序

    编译器和处理器为了优化性能而对指令序列进行重新排序的一种手段。

    3.2.1 数据依赖性

    如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。 数据依赖分下列三种类型: 上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。 编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。 这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

    3.2.2 as-if-serial语义

    as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。 为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作作重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

    double pi = 3.14; //1 double r = 1.0; //2 double area = pi * r * r; //3

    1和2可以重排,但是1和3,2和3不可以重排。 as-if-serial让程序员不必关注单线程的重排序,也不必关注内存可见性问题。

    3.2.3 程序顺序规则

    在不改变程序执行的结果下,尽可能提高并行度。

    3.2.4 重排序对多线程的影响

    class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2 } public void reader() { if (?flag) { // 3 int i = a * a; // 4 } } }

    假设两个线程分别执行writer()和reader()方法。 假设两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入? 答案是不一定能看到。 注:虚箭头为标识错误的操作。实箭头表示正确的读操作。 显然1和2没有数据依赖关系,3和4同样。 但是如果操作1和操作2做了重排序,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,这里多线程的语义被重排序破坏了。 如果操作3和操作4做了重排序,也是一样的道理。见下图。 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

    3.3 顺序一致性

    3.3.1 数据竞争与顺序一致性

    如果一个多线程程序能正确同步,这个程序将使一个没有数据竞争的程序。 注:这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

    3.3.2 顺序一致性内存模型

    两大特性:

    一个线程中的所有操作必须按照程序的顺序来执行。所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中, 每个操作都必须原子执行且立刻对所有线程可见。 在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关 可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上 面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发 执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中, 所有操作之间具有全序关系)。 为了更好进行理解,下面通过两个示意图来对顺序一致性模型的特性做进一步的说明。 假设有两个线程A和B并发执行。其中A线程有3个操作,它们在程序中的顺序是: A1→A2→A3。B线程也有3个操作,它们在程序中的顺序是:B1→B2→B3。 假设这两个线程使用监视器锁来正确同步:A线程的3个操作执行后释放监视器锁,随后B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如图3-11所示。 现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的 执行示意图,如图3-12所示。 未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一 个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是: B1→A1→A2→B2→A3→B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。 但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地 内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观 察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷 新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到 的操作执行顺序将不一致。

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

    class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2 } public void reader() { if (?flag) { // 3 int i = a * a; // 4 } } }

    假设A线程执行writer()方法后,B线程执行reader()方法。 根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。 顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。 从这里我们可以看到,JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为方便编译器和处理器的优化。

    3.3.4 未同步程序的执行特性

    对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有。 JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM需要禁止大量的处理器和编译器的优化,这对程序执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没什么意义。 未同步程序在JMM中的执行时,整体上无序,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异。 1)顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行。 2)顺序一致性保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。 3)JMM不保证对64位long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写都具有原子性。 第3个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。 这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和I/O设备执行内存的读/写。 由图可知,假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁会对竞争作出裁决,这里假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其他两个处理器则等待处理器A的总线事务完成后才能再次执行内存访问。假设在处理器A执行总线事务期间(不管读还是写事务),处理器D向总线发起了总线事务,此时处理器D的请求会被总线禁止。 总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。当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中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

    3.4 volatile的内存语义

    下面将介绍volatile的内存语义即volatile内存语义的实现。

    3.4.1 volatile的特性

    理解volatile特性的一个好方法是把volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。下面通过具体的示例来说明。

    class VolatileFeaturesExample { volatile long v1 = 0L; // 使用volatile声明64位的long型变量 public void set(long l) { v1 = 1; // 单个volatile变量的写 } public void getAndIncrement() { v1++; // 复合(多个)volatile变量的读/写 } public void get() { return v1; // 单个volatile变量的读 } }

    假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。

    class VolatileFeaturesExample { long v1 = 0L; // 64位的long型变量 public synchronized void set(long l) { //对单个的普通变量的写用同一个锁同步 v1 = 1; } public void getAndIncrement() { // 普通方法调用 long temp = get(); // 调用已同步的读方法 temp += 1L; // 普通写操作 set(temp); // 调用已同步的写方法 } public synchronized void get() { // 对单个的普通变量的读用同一个锁同步 return v1; } }

    如上面示例程序所示,一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。 锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。 锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。

    可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

    3.4.2 volatile写-读建立的happens-before关系

    上面讲的是volatile变量自身的特性,对程序员来说,volatile对线程的内存可见性的影响比volatile自身的特性更为重要。 从JSR-133(JDK5)开始,volatile变量的写-读可以实现线程之间的通信。 从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读和锁的获取有相同的内存语义。 示例代码如下:

    class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2 } public void reader() { if (flag) { // 3 int i = a; // 4 …… } } }

    假设线程A执行writer()方法后,线程B执行reader()方法。根据heppens-before规则,这个过程建立的happens-before关系可以分为三类: 1)根据程序次序规则,1 happens-before 2;3 happens-before 4。 2)根据volatile规则,2 happens-before 3。 3)根据happens-before的传递性规则,1 happens-before 4。 在上图中,每一个箭头链接的两个节点,代表了一个happens-before关系。黑色箭头(1->2和3->4)表示程序顺序规则;橙色箭头(2->3)表示volatile规则;蓝色箭头(1->4)表示组合这些规则后提供的heppens-before保证。 这里A线程写一个变量后,B线程读同一个volatile变量。A线程在写volatie变量之前所有课件的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

    3.4.3 volatile写-读内存语义

    volatile写的内存语义如下。 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。 以上面示例程序VolatileExample为例,假设线程A首先执行writer()方法,随后线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。图3-17是线程A执行volatile写后,共享变量的状态示意图。 如图示,线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。 volatile读的内存语义如下。 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。 图3-18为线程B读一个volatile变量后,共享变量的状态示意图。 如图所示,在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须冲主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一只。 如果我们把volatile写和volatile读两个步骤综合起来看,在读线程B读一个volatile变量后,写线程A在写这个volatile变量前所有可见的共享变量的值都将立即变得对读线程B可见。 下面对volatile写和volatile读的内存语义做个总结。

    线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(某对共享变量所做修改的)消息。线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

    3.4.4 volatile内存语义的实现

    下面来看JMM如何实现volatile写/读的内存语义。 前文提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。表3-5是JMM针对编译器指定的volatile重排序规则表。 举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。 从表3-5可以看出。

    当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。当一个操作是volatile写,第二个操作是volatile读时,不能重排序。 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障。 上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile语义。 下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图,如图3-19所示。 上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。 这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。 下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图,如图3-20所示。 图3-20中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。 class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; // 第一个volatile读 int j = v2; // 第二个volatile读 a = i + j; v1 = i + 1; // 第一个volatile写 v2 = j * 2; // 第二个volatile写 } ... // 其他方法 }

    针对readAndWrite()方法,编译器在生成字节码时可做如下优化。 注:最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插入一个StoreLoad屏障。 上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以X86处理器为例,图3-21中除最后的StoreLoad屏障外,其他的屏障都会被省略。 前面保守策略下的volatile读和写,在X86处理器平台可以优化成如图3-22所示。 前文提到过,X86处理器仅会对写-读操作做重排序。X86不会对读-读、读-写和写-写操作做重排序,因此在X86处理器中会省略掉这3种操作类型对应的内存屏障。在X86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在X86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。

    3.4.5 JSR-133为什么要增强volatile的内存语义?

    在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行,如图3-23所示。 在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:当线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。 因此,在旧的内存模型中,volatile的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。 由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替锁,请一定谨慎,具体详情请参阅Brian Goetz的文章《Java理论与实践:正确使用Volatile变量》。

    Processed: 0.013, SQL: 9