Java并发编程 - 第三章 Java内存模型_数据依赖分为-程序员宅基地

技术标签: 面试  算法  Java并发编程  java  多线程  编程语言  

前言:

Java 线程之间的通信对程序员完全透明,内存可见性问题很容易困扰 Java 程序员,本章将揭开 Java 内存模型神秘的面纱。

一、Java 内存模型的基础

1.1 并发编程模型的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存消息传递

在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

1.2 Java 内存模型的抽象结构

在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本章用 “共享变量” 这个术语代指实例域,静态域和数组元素)。局部变量(Local Variables),方法定义参数(Java 语言规范称之为 Formal Method Parameters)和异常处理器参数(ExceptionHandler Parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意如图所示。

Java内存模型的抽象结构示意图
从图来看,如果线程 A 与线程 B 之间要通信的话,必须要经历下面两个步骤。

  1. 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
  2. 线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

下面通过示意图来说明这两个步骤。

线程之间的通信图
如图所示,本地内存 A 和本地内存 B 由主内存中共享变量 x 的副本。假设初始时,这 3 个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。

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

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句
    的执行顺序。
  • 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 Java 源代码到最终实际执行的指令序列,会分别经历下面 3 种重排序,如图所示。

从源码到最终执行的指令序列的示意图
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

1.4 并发编程模型的分类

现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了具体说明,请看下面的表。

处理器操作内存的执行结果
假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终可能得到 x = y = 0 的结果。具体的原因如图所示。

处理器和内存的交互
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是 A2→A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。

1.5 happens-before 简介

从 JDK 5 开始,Java 使用新的 JSR-133 内存模型(除非特别说明,本文针对的都是JSR-133内存模型)。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 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 为什么要这么定义。

happens-before 与 JMM 的关系如图所示。

happens-before与JMM的关系
如图所示,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。对于 Java 程序员来说,happens-before 规则简单易懂,它避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

二、重排序

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

2.1 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列 3 种类型,如表所示。

数据依赖类型表
上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

2.2 as-if-serial 语义

as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

上面 3 个操作的数据依赖关系如图所示。

3 个操作之间的依赖关系
如图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序。

程序的两种执行顺序

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。asif-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

2.3 程序顺序规则

根据 happens-before 的程序顺序规则,上面计算圆的面积的示例代码存在 3 个 happensbefore 关系。

  • A happens-before B。
  • B happens-before C。
  • A happens-before C。

这里的第 3 个 happens-before 关系,是根据 happens-before 的传递性推导出来的。

这里 A happens-before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果 A happens-before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作B可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens-before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵从这一目标,从 happens-before 的定义我们可以看出,JMM 同样遵从这一目标。

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
            ……
        }
    }
    
}

flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入呢?

答案是:不一定能看到。

由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果?请看下面的程序执行时序图,如图所示。

程序执行时序图
如图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!

注意:本文统一用虚箭线标识错误的读操作,用实箭线标识正确的读操作。

下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序执行的时序图,如图所示。

程序的执行时序图

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a * a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作 3 的条件判断为真时,就把该计算结果写入变量i中。

从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

三、顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。

3.1 数据竞争与顺序一致性

当程序未正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义如下。

  • 在一个线程中写一个变量;
  • 在另一个线程读同一个变量;
  • 而且写和读没有通过同步来排序。

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

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

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile和final)的正确使用。

3.2 顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。

  • 一个线程中的所有操作必须按照程序的顺序来执行。
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

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

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

为了更好进行理解,下面通过两个示意图来对顺序一致性模型的特性做进一步的说明。

假设有两个线程 A 和 B 并发执行。其中 A 线程有 3 个操作,它们在程序中的顺序是:A1→A2→A3。B 线程也有 3 个操作,它们在程序中的顺序是:B1→B2→B3。

假设这两个线程使用监视器锁来正确同步:A 线程的 3 个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如图所示。

顺序一致性模型的一种执行效果
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图,如图所示。

顺序一致性模型中的另一种执行效果
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:

B1→A1→A2→B2→A3→B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

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

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

下面,对前面的示例程序 ReorderExample 用锁来同步,看看正确同步的程序如何具有顺序一致性。

请看下面的示例代码。

public class SynchronizedExample {
    

    int a = 0;
    boolean flag = false;

    public synchronized void writer() {
    
        a = 1; // 1
        flag = true; // 2
    }

    public synchronized void reader() {
    
        if (flag) {
     // 3
            int i = a * a; // 4
            System.out.println(i);
        }
    }

}

在上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图,如图所示。

两个内存模型中的执行时序对比图

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

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

3.4 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM 保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。

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

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

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
  • 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 “写了一半” 的无效值。

四、volatile 的内存语义

4.1 volatile 的特性

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

class VolatileFeaturesExample {
    
    
    volatile long vl = 0L; // 使用volatile声明64位的long型变量

    public void set(long l) {
    
        vl = l; // 单个volatile变量的写
    }

    public void getAndIncrement() {
    
        vl++; // 复合(多个)volatile变量的读/写
    }

    public long get() {
    
        return vl; // 单个volatile变量的读
    }

}

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

class VolatileFeaturesExample {
    
    
    long vl = 0L; // 64位的long型普通变量

    public synchronized void set(long l) {
     // 对单个的普通变量的写用同一个锁同步
        vl = l;
    }

    public void getAndIncrement() {
     // 普通方法调用
        long temp = get(); // 调用已同步的读方法
        temp += 1L; // 普通写操作
        set(temp); // 调用已同步的写方法
    }

    public synchronized long get() {
     // 对单个的普通变量的读用同一个锁同步
        return vl;
    }
    
}

如上面示例程序所示,一个 volatile 变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。

volatile 变量自身具有下列特性。

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

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

从内存语义的角度来说,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() 方法。根据 happens-before 规则,这个过程建立的 happens-before 关系可以分为 3 类:

  • 根据程序次序规则,1 happens-before 2;3 happens-before 4。
  • 根据volatile规则,2 happens-before 3。
  • 根据happens-before的传递性规则,1 happens-before 4。

上述 happens-before 关系的图形化表现形式如下。

happens-before 关系
在上图中,每一个箭头链接的两个节点,代表了一个 happens-before 关系。黑色箭头表示程序顺序规则;橙色箭头表示 volatile 规则;蓝色箭头表示组合这些规则后提供的 happens-before 保证。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。

4.3 volatile 写-读的内存语义

当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。

以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer() 方法,随后线程 B 执行 reader() 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图。

共享变量的状态示意图
如图所示,线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值被刷新到主内存中。此时,本地内存 A 和主内存中的共享变量的值是一致的。

volatile 读的内存语义如下。

当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

下图为线程 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 发送消息。

4.4 volatile 内存语义的实现

前文提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。下表是 JMM 针对编译器制定的 volatile 重排序规则表。

volatile 重排序规则表
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图,如图所示。

指令序列示意图
图中的 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 读插入内存屏障后生成的指令序列示意图,如图所示。

指令序列示意图
上述 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() 方法,编译器在生成字节码时可以做如下的优化。

指令序列示意图
上面的优化针对任意处理器平台,由于不同的处理器有不同 “松紧度” 的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 X86 处理器为例,图中除最后的 StoreLoad 屏障外,其他的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 X86 处理器平台可以优化成如图所示。

指令序列示意图
前文提到过,X86 处理器仅会对写-读操作做重排序。X86 不会对读-读、读-写和写-写操作做重排序,因此在 X86 处理器中会省略掉这 3 种操作类型对应的内存屏障。在 X86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写-读的内存语义。这意味着在 X86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行 StoreLoad 屏障开销会比较大)。

五、锁的内存语义

5.1 锁的释放-获取建立的 happens-before 关系

锁是 Java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。

下面是锁释放-获取的示例代码。

class MonitorExample {
    
    
    int a = 0;

    public synchronized void writer() {
     // 1
        a++; // 2
    } // 3

    public synchronized void reader() {
     // 4
        int i = a; // 5
        ……
    } // 6
    
}

假设线程 A 执行 writer() 方法,随后线程 B 执行 reader() 方法。根据 happens-before 规则,这个过程包含的 happens-before 关系可以分为 3 类。

  • 根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happensbefore
    6。
  • 根据监视器锁规则,3 happens-before 4。
  • 根据 happens-before 的传递性,2 happens-before 5。

上述happens-before关系的图形化表现形式如图所示。

happens-before关系图
上图表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens-before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。

5.2 锁的释放和获取的内存语义

当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。以上面的 MonitorExample 程序为例,A 线程释放锁后,共享数据的状态示意图如图所示。

共享数据的状态示意图
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。下图是锁获取的状态示意图。

锁获取的状态示意图
对比锁释放-获取的内存语义与 volatile 写-读的内存语义可以看出:锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义。

下面对锁释放和锁获取的内存语义做个总结。

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

六、final 域的内存语义

6.1 final 域的重排序规则

对于 final 域,编译器和处理器要遵守两个重排序规则。

  • 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

下面通过一些示例性的代码来分别说明这两个规则。

public class FinalExample {
    
    
    int i; // 普通变量
    final int j; // final变量
    static FinalExample obj;

    public FinalExample() {
     // 构造函数
        i = 1; // 写普通域
        j = 2; // 写final域
    }

    public static void writer() {
     // 写线程A执行
        obj = new FinalExample();
    }

    public static void reader() {
     // 读线程B执行
        FinalExample object = obj; // 读对象引用
        int a = object.i; // 读普通域
        int b = object.j; // 读final域
    }

}

这里假设一个线程 A 执行 writer() 方法,随后另一个线程 B 执行 reader() 方法。下面我们通过这两个线程的交互来说明这两个规则。

6.2 写 final 域的重排序规则

写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面。

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外。
  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

现在让我们分析 writer() 方法。writer() 方法只包含一行代码:obj = newFinalExample()。这行代码包含两个步骤,如下。

  1. 构造一个 FinalExample 类型的对象。
  2. 把这个对象的引用赋值给引用变量 obj。

假设线程 B 读对象引用与读对象的成员域之间没有重排序(马上会说明为什么需要这个假设),下图是一种可能的执行时序。

线程执行时序图
在图中,写普通域的操作被编译器重排序到了构造函数之外,读线程 B 错误地读取了普通变量i初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则 “限定” 在了构造函数之内,读线程 B 正确地读取了 final 变量初始化之后的值。

写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程 B “看到” 对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值 1 还没有写入普通域 i )。

6.3 读 final 域的重排序规则

读 final 域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器的。

reader() 方法包含 3 个操作。

  • 初次读引用变量 obj。
  • 初次读引用变量 obj 指向对象的普通域 j 。
  • 初次读引用变量 obj 指向对象的 final 域 i 。

现在假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,下图所示是一种可能的执行时序。

线程执行时序图
在图中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程 A 写入,这是一个错误的读取操作。而读 final 域的重排序规则会把读对象 final 域的操作 “限定” 在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。

读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了。

6.4 final 域为引用类型

上面我们看到的 final 域是基础数据类型,如果 final 域是引用类型,将会有什么效果?请看下列示例代码。

public class FinalReferenceExample {
    

    final int[] intArray; // final是引用类型
    static FinalReferenceExample obj;

    public FinalReferenceExample() {
     // 构造函数
        intArray = new int[1]; // 1
        intArray[0] = 1; // 2
    }

    public static void writerOne() {
     // 写线程A执行
        obj = new FinalReferenceExample(); // 3
    }

    public static void writerTwo() {
     // 写线程B执行
        obj.intArray[0] = 2; // 4
    }

    public static void reader() {
     // 读线程C执行
        if (obj != null) {
     // 5
            int temp1 = obj.intArray[0]; // 6
        }
    }
    
}

本例 final 域为一个引用类型,它引用一个 int 型的数组对象。对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

对上面的示例程序,假设首先线程 A 执行 writerOne() 方法,执行完后线程 B 执行 writerTwo() 方法,执行完后线程 C 执行 reader() 方法。下图是一种可能的线程执行时序。

引用型final的执行时序图
在图中,1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。

JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看得到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。

如果想要确保读线程 C 看到写线程 B 对数组元素的写入,写线程 B 和读线程 C 之间需要使用同步原语(lock或volatile)来确保内存可见性。

6.5 为什么 final 引用不能从构造函数内“溢出”

前面我们提到过,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面的示例代码。

public class FinalReferenceEscapeExample {
    
    
    final int i;
    static FinalReferenceEscapeExample obj;

    public FinalReferenceEscapeExample() {
    
        i = 1; // 1写final域
        obj = this; // 2 this引用在此"逸出"
    }

    public static void writer() {
    
        new FinalReferenceEscapeExample();
    }

    public static void reader() {
    
        if (obj != null) {
     // 3
            int temp = obj.i; // 4
        }
    }
    
}

假设一个线程 A 执行 writer() 方法,另一个线程 B 执行 reader() 方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且在程序中操作 2 排在操作 1 后面,执行 read() 方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如图所示。

多线程执行时序图
从图可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。

七、happens-before

7.1 JMM 的设计

首先,让我们来看 JMM 的设计意图。从 JMM 设计者的角度,在设计 JMM 时,需要考虑两个关键因素。

  • 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。下面让我们来看 JSR-133 是如何实现这一目标的。

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

上面计算圆的面积的示例代码存在 3 个 happens-before 关系,如下。

  • A happens-before B。
  • B happens-before C。
  • A happens-before C。

在 3 个 happens-before 关系中,2 和 3 是必需的,但 1 是不必要的。因此,JMM 把 happens-before 要求禁止的重排序分为了下面两类。

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略,如下。

  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求(JMM 允许这种重排序)。

下图是 JMM 的设计示意图。

JMM的设计示意图

7.2 happens-before 的定义

《JSR-133:Java Memory Model and Thread Specification》对 happens-before 关系的定义如下。

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。

7.3 happens-before 规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下 happens-before 规则。

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  • start() 规则:如果线程 A 执行操作 ThreadB.start()(启动线程B),那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作。
  • join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回。

下图是 volatile 变量规则对应的 happens-before 关系图。

happens-before 关系的示意图
下图是 start() 规则对应的 happens-before 关系图。

happens-before关系的示意图
下图是 join() 规则对应的 happens-before 关系图。

happens-before关系的示意图

八、双重检查锁定与延迟初始化

8.1 双重检查锁定的由来

在 Java 程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码。

public class UnsafeLazyInitialization {
    
    
    private static Instance instance;

    public static Instance getInstance() {
    
        if (instance == null) // 1:A线程执行
            instance = new Instance(); // 2:B线程执行
        return instance;
    }
    
}

UnsafeLazyInitialization类中,假设 A 线程执行代码 1 的同时,B 线程执行代码 2。此时,线程 A 可能会看到 instance 引用的对象还没有完成初始化。

对于UnsafeLazyInitialization类,我们可以对 getInstance() 方法做同步处理来实现线程安全的延迟初始化。示例代码如下。

public class SafeLazyInitialization {
    
    
    private static Instance instance;

    public synchronized static Instance getInstance() {
    
        if (instance == null)
            instance = new Instance();
        return instance;
    }
    
}

由于对 getInstance() 方法做了同步处理,synchronized 将导致性能开销。如果 getInstance() 方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果 getInstance() 方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。

在早期的 JVM 中,synchronized(甚至是无竞争的synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。

public class DoubleCheckedLocking {
    
    
    private static Instance instance;

    public static Instance getInstance() {
    
        if (instance == null) {
     // 第一次检查
            synchronized (DoubleCheckedLocking.class) {
     // 加锁
                if (instance == null) // 第二次检查
                    instance = new Instance(); // 问题的根源出在这里
            }
        }
        return instance;
    }
    
}

如上面代码所示,如果第一次检查 instance 不为 null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低 synchronized 带来的性能开销。

双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第 9 行,代码读取到 instance 不为 null 时,instance 引用的对象有可能还没有完成初始化。

8.2 问题的根源

前面的双重检查锁定示例代码的第 9 行创建了一个对象。这一行代码可以分解为如下的 3 行伪代码。

memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址

上面 3 行伪代码中的 2 和 3 之间,可能会被重排序。2 和 3 之间重排序之后的执行时序如下。

memory = allocate(); // 1:分配对象的内存空间
instance = memory; // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象

根据《The Java Language Specification,Java SE 7 Edition》(后文简称为Java语言规范),所有线程在执行 Java 程序时必须要遵守 intra-thread semantics。intra-thread semantics 保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面 3 行伪代码的 2 和 3 之间虽然被重排序了,但这个重排序并不会违反 intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。

为了更好地理解 intra-thread semantics,请看如图所示的示意图(假设一个线程 A 在构造对象后,立即访问这个对象)。

线程执行时序图
如图所示,只要保证 2 排在 4 的前面,即使 2 和 3 之间重排序了,也不会违反 intra-thread semantics。

下面,再让我们查看多线程并发执行的情况。如图所示。

多线程执行时序图
由于单线程内要遵守 intra-thread semantics,从而能保证 A 线程的执行结果不会被改变。但是,当线程 A 和 B 按上图的时序执行时,B 线程将看到一个还没有被初始化的对象。

下表是这个场景的具体执行时序。

多线程执行时序表
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。

  • 不允许 2 和 3 重排序。
  • 允许 2 和 3 重排序,但不允许其他线程 “看到” 这个重排序。

后文介绍的两个解决方案,分别对应于上面这两点。

8.3 基于 volatile 的解决方案

对于前面的基于双重检查锁定来实现延迟初始化的方案(指 DoubleCheckedLocking 示例代码),只需要做一点小的修改(把 instance 声明为 volatile 型),就可以实现线程安全的延迟初始化。请看下面的示例代码。

public class SafeDoubleCheckedLocking {
    
    
    private volatile static Instance instance;

    public static Instance getInstance() {
    
        if (instance == null) {
    
            synchronized (SafeDoubleCheckedLocking.class) {
    
                if (instance == null)
                    instance = new Instance(); // instance为volatile,现在没问题了
            }
        }
        return instance;
    }
    
}

当声明对象的引用为 volatile 后,8.2 节中的 3 行伪代码中的 2 和 3 之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行,如图所示。

多线程执行时序图

8.4 基于类初始化的解决方案

JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为 Initialization On Demand Holder idiom)。

public class InstanceFactory {
    
    
    private static class InstanceHolder {
    
        public static Instance instance = new Instance();
    }

    public static Instance getInstance() {
    
        return InstanceHolder.instance; // 这里将导致InstanceHolder类被初始化
    }
    
}

假设两个线程并发执行 getInstance() 方法,下面是执行的示意图,如图所示。

两个线程并发执行的示意图
这个方案的实质是:允许 8.2 节中的 3 行伪代码中的 2 和 3 重排序,但不允许非构造线程(这里指线程 B)“看到” 这个重排序。

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/weixin_41105242/article/details/108149569

智能推荐

oracle 12c 集群安装后的检查_12c查看crs状态-程序员宅基地

文章浏览阅读1.6k次。安装配置gi、安装数据库软件、dbca建库见下:http://blog.csdn.net/kadwf123/article/details/784299611、检查集群节点及状态:[root@rac2 ~]# olsnodes -srac1 Activerac2 Activerac3 Activerac4 Active[root@rac2 ~]_12c查看crs状态

解决jupyter notebook无法找到虚拟环境的问题_jupyter没有pytorch环境-程序员宅基地

文章浏览阅读1.3w次,点赞45次,收藏99次。我个人用的是anaconda3的一个python集成环境,自带jupyter notebook,但在我打开jupyter notebook界面后,却找不到对应的虚拟环境,原来是jupyter notebook只是通用于下载anaconda时自带的环境,其他环境要想使用必须手动下载一些库:1.首先进入到自己创建的虚拟环境(pytorch是虚拟环境的名字)activate pytorch2.在该环境下下载这个库conda install ipykernelconda install nb__jupyter没有pytorch环境

国内安装scoop的保姆教程_scoop-cn-程序员宅基地

文章浏览阅读5.2k次,点赞19次,收藏28次。选择scoop纯属意外,也是无奈,因为电脑用户被锁了管理员权限,所有exe安装程序都无法安装,只可以用绿色软件,最后被我发现scoop,省去了到处下载XXX绿色版的烦恼,当然scoop里需要管理员权限的软件也跟我无缘了(譬如everything)。推荐添加dorado这个bucket镜像,里面很多中文软件,但是部分国外的软件下载地址在github,可能无法下载。以上两个是官方bucket的国内镜像,所有软件建议优先从这里下载。上面可以看到很多bucket以及软件数。如果官网登陆不了可以试一下以下方式。_scoop-cn

Element ui colorpicker在Vue中的使用_vue el-color-picker-程序员宅基地

文章浏览阅读4.5k次,点赞2次,收藏3次。首先要有一个color-picker组件 <el-color-picker v-model="headcolor"></el-color-picker>在data里面data() { return {headcolor: ’ #278add ’ //这里可以选择一个默认的颜色} }然后在你想要改变颜色的地方用v-bind绑定就好了,例如:这里的:sty..._vue el-color-picker

迅为iTOP-4412精英版之烧写内核移植后的镜像_exynos 4412 刷机-程序员宅基地

文章浏览阅读640次。基于芯片日益增长的问题,所以内核开发者们引入了新的方法,就是在内核中只保留函数,而数据则不包含,由用户(应用程序员)自己把数据按照规定的格式编写,并放在约定的地方,为了不占用过多的内存,还要求数据以根精简的方式编写。boot启动时,传参给内核,告诉内核设备树文件和kernel的位置,内核启动时根据地址去找到设备树文件,再利用专用的编译器去反编译dtb文件,将dtb还原成数据结构,以供驱动的函数去调用。firmware是三星的一个固件的设备信息,因为找不到固件,所以内核启动不成功。_exynos 4412 刷机

Linux系统配置jdk_linux配置jdk-程序员宅基地

文章浏览阅读2w次,点赞24次,收藏42次。Linux系统配置jdkLinux学习教程,Linux入门教程(超详细)_linux配置jdk

随便推点

matlab(4):特殊符号的输入_matlab微米怎么输入-程序员宅基地

文章浏览阅读3.3k次,点赞5次,收藏19次。xlabel('\delta');ylabel('AUC');具体符号的对照表参照下图:_matlab微米怎么输入

C语言程序设计-文件(打开与关闭、顺序、二进制读写)-程序员宅基地

文章浏览阅读119次。顺序读写指的是按照文件中数据的顺序进行读取或写入。对于文本文件,可以使用fgets、fputs、fscanf、fprintf等函数进行顺序读写。在C语言中,对文件的操作通常涉及文件的打开、读写以及关闭。文件的打开使用fopen函数,而关闭则使用fclose函数。在C语言中,可以使用fread和fwrite函数进行二进制读写。‍ Biaoge 于2024-03-09 23:51发布 阅读量:7 ️文章类型:【 C语言程序设计 】在C语言中,用于打开文件的函数是____,用于关闭文件的函数是____。

Touchdesigner自学笔记之三_touchdesigner怎么让一个模型跟着鼠标移动-程序员宅基地

文章浏览阅读3.4k次,点赞2次,收藏13次。跟随鼠标移动的粒子以grid(SOP)为partical(SOP)的资源模板,调整后连接【Geo组合+point spirit(MAT)】,在连接【feedback组合】适当调整。影响粒子动态的节点【metaball(SOP)+force(SOP)】添加mouse in(CHOP)鼠标位置到metaball的坐标,实现鼠标影响。..._touchdesigner怎么让一个模型跟着鼠标移动

【附源码】基于java的校园停车场管理系统的设计与实现61m0e9计算机毕设SSM_基于java技术的停车场管理系统实现与设计-程序员宅基地

文章浏览阅读178次。项目运行环境配置:Jdk1.8 + Tomcat7.0 + Mysql + HBuilderX(Webstorm也行)+ Eclispe(IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持)。项目技术:Springboot + mybatis + Maven +mysql5.7或8.0+html+css+js等等组成,B/S模式 + Maven管理等等。环境需要1.运行环境:最好是java jdk 1.8,我们在这个平台上运行的。其他版本理论上也可以。_基于java技术的停车场管理系统实现与设计

Android系统播放器MediaPlayer源码分析_android多媒体播放源码分析 时序图-程序员宅基地

文章浏览阅读3.5k次。前言对于MediaPlayer播放器的源码分析内容相对来说比较多,会从Java-&amp;amp;gt;Jni-&amp;amp;gt;C/C++慢慢分析,后面会慢慢更新。另外,博客只作为自己学习记录的一种方式,对于其他的不过多的评论。MediaPlayerDemopublic class MainActivity extends AppCompatActivity implements SurfaceHolder.Cal..._android多媒体播放源码分析 时序图

java 数据结构与算法 ——快速排序法-程序员宅基地

文章浏览阅读2.4k次,点赞41次,收藏13次。java 数据结构与算法 ——快速排序法_快速排序法