Java内存模型

Java内存模型

第一节 内存模型

1.1 基本概念

Java的并发采用的是共享内存模型(什么是共享内存模型?),Java线程之间的通信总是隐式进行(怎么才叫隐式的通信?),整个通信过程对程序员完全透明。

并发编程要解决的两个关键问题:线程之间如何通信?线程之间如何同步?

(1)Java内存模型的抽象结构

可以配合Java内存区域和内存溢出异常一起学习。

首先我们应该知道,所有的实例域静态域数组元素都存储在堆内存中,堆内存在线程之间共享。可以将它们看作共享变量。而局部变量方法定义参数异常处理器参数这些则不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

Java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM(Java内存模型)的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

Java内存模型抽象结构

(2)线程之间如何通信

通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存消息传递

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

Java线程之间的通信由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程序员提供内存可见性保证。

(3)线程之间如何同步

同步是指程序中用于控制不同线程间操作发生相对顺序的机制。

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

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

(1)背景

在程序执行时,为了提高性能,编译器和处理器常常会对指令进行重排序

(2)类型

重排序分为3种类型:

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

软件从源码到最终执行的指令序列的排序过程:源代码->1.编译器优化重排序->2.指令级并行重排序->3.内存重排序->最终执行的指令序列

(3)隐患

重排序会导致一个问题:内存可见性问题

比如类型1,编译器具有重排序规则,会禁止特定类型的编译器重排序。对于类型2和3,处理器的重排序规则要求Java编译器在生成指令序列时,插入特定类型的内存屏障(一组处理器指令,用来实现对内存操作的顺序限制)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

Java内存模型属于语言级别的内存模型,所以不同的编译器和处理器平台上可以提供一致的内存可见性保证。

1.3 并发编程模型分类

(1)读写缓冲区

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

(2)示例

处理器操作内存的执行结果

处理器A和B同时把共享变量读入自己的写缓冲区(A1,B1),然后再从内存中读取另一个共享变量(A2,B2),最后才把缓存中的脏数据刷新到内存中(A3,B3),所以得到的x=y=0。即处理器执行顺序为A1->A2,而内存执行顺序则是A2->A1,即被内存系统重排序了。

处理器和内存的交互

由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。

处理器的重排序规则

“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

JMM把内存屏障指令分为4类:

内存屏障类型表

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

1.4 happens-before

从JDK 5开始,新的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与JMM的关系

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


第二节 重排序

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

2.1 数据依赖性

若两个操作访问同一个变量,且一个为写操作,则两者间就存在数据依赖性

  1. 写后读:a = 1; b = a; 写一个变量后再读此变量。
  2. 写后写:a = 1; a = 2; 写一个变量后再写此变量。
  3. 读后写:a = b; a = 1; 读一个变量后再写此变量。

上述情况只要重排序改变执行顺序,必然会导致执行结果的改变。所以会导致重排序的编译器和处理器都会遵照一个原则:存在数据依赖性的两个操作不会被改变执行顺序。(只针对单个处理器和单个线程中执行的操作,多处理器多线程不考虑)

2.2 as-if-serial语义

as-if-serial语义:表示无论如何重排序,单线程的执行结果都不能被改变。所以为了遵守as-if-serial语义,编译器和处理器要遵守数据依赖性

如以下情况:A,B,C三个操作,AC和BC分别存在数据依赖性,所以C不能被重排序到AB前面,执行的顺序就可能有ABC和BAC两种,但不会影响最终的结果。这类保护机制会让开发者有种错觉,程序是按照代码的顺序执行的,但实际上并不准确。

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

2.3 程序顺序规则

根据happens-before的程序顺序规则,ABC操作有三个happens-before关系:

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C

3由1和2传递可得,虽然A happens-before B,但操作A并不需要对B可见,且重排序后不会导致结果改变,所以Java内存模型允许此类重排序。主要目的就是尽量的提高并行度。

2.4 重排序对多线程的影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReorderExample {       
int a = 0;
boolean flag = false;//标记,标识变量a是否已被写入
public void writer() {
a = 1; // 1
flag = true; // 2
}

public void reader() {
if (flag) { // 3
int i = a * a; // 4
……
}
}
}

当由线程A和B同时工作,A执行writer(),B执行reader(),那么B是否可见A对a的写入呢?可能看到也可能看不到。

首先1和2以及3和4没有数据依赖性,所以可进行重排序。

当2->1时,有可能先标记写入执行操作2,然后B执行3读取变量,发现已写入就执行4,但其实操作1还未执行导致多线程程序的语义因为重排序而被破坏。

程序执行时序图

当4->3时,首先操作3和4存在控制依赖关系,会影响指令序列执行的并行度,所以编译器和处理器会采用猜测执行来客服控制相关性对并行度的影响。所以4可能会先执行并将结果保存到一个名为重排序缓冲(Recorder Buffer,ROB)的硬件缓存中。当3执行为真时再把结果写回。所以这种情况明显会导致多线程程序的语义因为重排序而被破坏。

程序执行时序图


第三节 顺序一致性

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

3.1 数据竞争与顺序一致性

Java内存模型对数据竞争定义:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。

当代码中存在数据竞争时,程序的执行结果往往会与开发者所预估的结果相左,所以Java内存模型对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行会具有顺序一致性——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。此处的同步是广义上的同步,包括如常用同步语句synchronized、volatile和final的正确使用。

3.2 顺序一致性内存模型

两个特性:

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

顺序一致性内存有一个单一的全局内存,此内存通过一个左右摆动的开关可以联通任意的一个线程,每个线程必须按照程序的顺序来执行内存的读/写操作。任意时间点只有最多一个线程可以连接内存,所以并行执行的多个线程的读写操作会被此开关串行化。

假设有两个线程A和B,分别有3个操作,执行顺序分别为:A1->A2->A3,B1->B2->B3。当使用监视器锁来同步两个线程时,在顺序一致性模型中的执行效果如下:

顺序一致性模型的一种执行效果

如果没有对线程作同步,则在顺序一致性模型中的执行效果如下:

顺序一致性模型中的另一种执行效果

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序,是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

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

3.3 同步程序的顺序一致性内存模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SynchronizedExample {       
int a = 0;
boolean flag = false;//标记,标识变量a是否已被写入
public synchronized void writer() { //获取锁
a = 1; // 1
flag = true; // 2
} //释放锁

public synchronized void reader() { //获取锁
if (flag) { // 3
int i = a * a; // 4
……
}
} //释放锁
}

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

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

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

3.4 未同步程序的执行特性

对于未同步和不能正确同步的多线程程序,Java内存模型只能提供最小安全性:线程执行时读取到的值,要么是之前某个线程的值,要么是默认值(0, Null, False),Java内存模型保证线程读操作读到的值不会无中生有(Out Of Thin Air)的冒出来。

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

为什么Java内存模型不保证未同步程序的执行结果与顺序一致性模型执行结果一致?

因为若要保持一致,Java内存模型需要禁止大量的处理器和编译器优化,会对程序的执行性能造成很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,执行结果很难预知。最后,保证两者的一致性意义并不大。

未同步程序在两个模型的执行差异:

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而Java内存模型不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而Java内存模型不保证所有线程能看到一致的操作执行顺序。
  3. Java内存模型不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保 证对所有的内存读/写操作都具有原子性。

(1)为什么Java内存模型不保证对64位的long型和double型变量的写操作具有原子性?

处理器内存之间通过总线来传递数据,数据传递的过程被称为总线事务(Bus Transaction),包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务都会读/写内存中一个或多个物理上连续的字。关键的是:总线会同步试图并发使用总线的事务,在一个处理器执行总线事务的期间,总线会禁止其他处理器和I/O设备执行内存的读/写。

总线的工作机制

处理器A、B、C同时向总线发起总线事务,总线仲裁(Bus Arbitration)会对竞争做出裁决,确保所有处理器能公平的访问内存。此工作机制可以把所有处理器对内存的访问以串行化的方式来执行,确保了单个总线事务之中内存读/写操作的原子性。

在一些32位的处理器上保证对64位数据的写操作的原子性需要比较大的开销,所以为了照顾这些处理器,Java语言规范鼓励但并不强制要求JVM对64位的long型和double型变量的写操作具有原子性。当JVM在此类处理器上运行时,可能会把一个64的long型和double型变量拆分为两个32位的写操作来执行,那么这两个写操作可能会在不同的总线事务上执行,所以就不在具有原子性了。

当单个内存操作不具有原子性时,可能会有奇怪的现象发生:

总线事务执行的时序图

处理器A写一个long型变量,同时处理器B要读取这个变量,因为处理器A中的64位写操作被拆分为2个32位写操作,并被分配到不同的写事务中执行,而处理器B中的64位读操作被分配到单个读事务中执行。此时处理器B就只能看到被”写了一半”的无效值。(JSR-133之前的内存模型允许64位读操作也进行拆分,JSR-133后规定读操作都必须有原子性)

3.5 volatile的内存语义

参考volatile相关章节。

3.6 锁的内存语义

参考锁和final域的内存语义

3.7 final域的内存语义

参考锁和final域的内存语义

3.8 happens-before

参考happens-before规则

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

背景/作用:在Java多线程程序中,有时候需要采用延迟初始化(懒加载)来降低初始化类和创建对象的开销。

双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。本章节将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。

(1)双重检查锁定的由来

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

1
2
3
4
5
6
7
8
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()方法做同步处理来实现线程安全的延迟初始化。示例代码如下。

1
2
3
4
5
6
7
8
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)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
public class DoubleCheckedLocking { // 1
private static Instance instance; // 2
public static Instance getInstance() { // 3
if (instance == null) { // 4:第一次检查
synchronized (DoubleCheckedLocking.class) { // 5:加锁
if (instance == null) // 6:第二次检查
instance = new Instance(); // 7:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}

如上面代码所示,双重检查锁定的逻辑是:如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美:

  • 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
  • 在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。

双重检查锁定看起来似乎很完美,但这是一个错误的优化!因为在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。虽然提前一次检查避免了加锁,但又把未加锁时的脏读带回了多线程环境。

(2)问题的根源

前面的双重检查锁定示例代码的第7行(instance = new Singleton();)创建了一个对象。这一行代码可以分解为如下的3行伪代码。

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

上面3行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的,详情见参考文献1的“Out-of-order writes”部分)。2和3之间重排序之后的执行时序如下。

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

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

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

如下图所示,只要保证2排在4的前面,即使2和3之间重排序了,也不会违反线程内语义。

线程执行时序图

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

多线程执行时序图

由于单线程内要遵守线程内语义,从而能保证A线程的执行结果不会被改变。但是,当线程A和B按图的时序执行时,B线程将看到一个还没有被初始化的对象。回到本文的主题,DoubleCheckedLocking示例代码的第7行(instance = new Singleton();)如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化!下表是这个场景的具体执行时序。

多线程执行时序表

这里A2和A3虽然重排序了,但Java内存模型的线程内语义将确保A2一定会排在A4前面执行。因此,线程A的线程内语义没有改变,但A2和A3的重排序,将导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。

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

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

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

(3)基于volatile的解决方案

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

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}

注意 这个解决方案需要JDK 5或更高版本(因为从JDK 5开始使用新的JSR-133内存模型规范,这个规范增强了volatile的语义)。当声明对象的引用为volatile后,3.8.2节中的3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行,如下图所示。

多线程执行时序图

这个方案本质上是通过禁止图中的2和3之间的重排序,来保证线程安全的延迟初始化。

(4)基于类初始化的解决方案

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为Initialization On Demand Holder idiom)。

1
2
3
4
5
6
7
8
9
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}

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

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

两个线程并发执行的示意图

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

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化。

  1. T是一个类,而且一个T类型的实例被创建。
  2. T是一个类,且T中声明的一个静态方法被调用。
  3. T中声明的一个静态字段被赋值。
  4. T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
  5. T是一个顶级类(Top Level Class,见Java语言规范的§7.6),而且一个断言语句嵌套在T内部被执行。

在InstanceFactory示例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化(符合情况4)。

由于Java语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用getInstance()方法来初始化InstanceHolder类)。因此,在Java中初始化一个类或者接口时,需要做细致的同步处理。Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,Java语言规范允许JVM的具体实现在这里做一些优化,见后文的说明)。

对于类或接口的初始化,Java语言规范制定了精巧而复杂的类初始化处理过程。Java初始化一个类或接口的处理过程如下(这里对类初始化处理过程的说明,省略了与本文无关的部分;同时为了更好的说明类初始化过程中的同步处理机制,笔者人为的把类初始化的处理过程分为了5个阶段)。

第1阶段

通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。假设Class对象当前还没有被初始化(初始化状态state,此时被标记为state=noInitialization),且有两个线程A和B试图同时初始化这个Class对象。下图是对应的示意图。

类初始化——第1阶段

下表是这个示意图的说明。

类初始化——第1阶段的执行时序表

第2阶段

线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。

下表是这个示意图的说明。

类初始化——第2阶段的执行时序表

类初始化——第2阶段

第3阶段

线程A设置state=initialized,然后唤醒在condition中等待的所有线程。

类初始化——第3阶段

下表是这个示意图的说明。

类初始化——第3阶段的执行时序表

第4阶段

线程B结束类的初始化处理。

类初始化——第4阶段

下表是这个示意图的说明。

类初始化——第4阶段的执行时序表

多线程执行时序图

线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放初始化锁;线程B在第4阶段的B1获取同一个初始化锁,并在第4阶段的B4之后才开始访问这个类。根据Java内存模型规范的锁规则,这里将存在如下的happens-before关系。这个happens-before关系将保证:线程A执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程B一定能看到。

第5阶段

线程C执行类的初始化的处理。

类初始化——第5阶段

下表是这个示意图的说明。

类初始化——第5阶段的执行时序表

在第3阶段之后,类已经完成了初始化。因此线程C在第5阶段的类初始化处理过程相对简单一些(前面的线程A和B的类初始化处理过程都经历了两次锁获取-锁释放,而线程C的类初始化处理只需要经历一次锁获取-锁释放)。线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放锁;线程C在第5阶段的C1获取同一个锁,并在在第5阶段的C4之后才开始访问这个类。根据Java内存模型规范的锁规则,将存在如下的happens-before关系。

这个happens-before关系将保证:线程A执行类的初始化时的写入操作,线程C一定能看到。

注意 这里的condition和state标记是本文虚构出来的。Java语言规范并没有硬性规定一定要使用condition和state标记。JVM的具体实现只要实现类似功能即可。
注意 Java语言规范允许Java的具体实现,优化类的初始化处理过程(对这里的第5阶段做优化),具体细节参见Java语言规范的12.4.2节。

多线程执行时序图

通过对比基于volatile的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。

  1. 如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案。

  2. 如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。

3.10 Java内存模型综述

前面对Java内存模型的基础知识和内存模型的具体实现进行了说明。下面对Java内存模型的相关知识做一个总结。

(1)处理器的内存模型

顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺序一致性内存模型为参照。在设计时,JMM和处理器内存模型会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型的读/写操作组合的执行顺序的放松(放松监管),可以把常见处理器的内存模型划分为如下几种类型:

  • 放松程序中写-读操作的顺序,由此产生了Total Store Ordering全存储内存模型(简称为TSO)。
  • 在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了Partial Store Order部分存储内存模型(简称为PSO)。
  • 在前面两条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了RelaxedMemory Order内存模型(简称为RMO)和PowerPC内存模型。

注意,这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守as-if-serial语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

下表展示了常见处理器内存模型的细节特征如下。

处理器内存模型的特征表

从表中可以看到,所有处理器内存模型都允许写-读重排序,原因在第1章已经说明过:它们都使用了写缓存区。写缓存区可能导致写-读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区。由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓存区中的写。

表中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计得会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。

由于常见的处理器内存模型比JMM要弱,Java编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱不同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了JMM在不同处理器内存模型中需要插入的内存屏障的示意图。JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为Java程序员呈现了一个一致的内存模型。

JMM插入内存屏障的示意图

(2)各种内存模型之间的关系

JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型、处理器内存模型和顺序一致性内存模型的强弱对比示意图,如下图所示。

各种CPU内存模型的强弱对比示意图

从图中可以看出:常见的4种处理器内存模型比常用的3中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计得会越弱。

(3)JMM的内存可见性保证

按程序类型,Java程序的内存可见性保证可以分为下列3类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
  • 未同步/未正确同步的多线程程序。JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。

注意,最小安全性保障与64位数据的非原子性写并不矛盾。它们是两个不同的概念,它们“发生”的时间点也不同。最小安全性保证对象默认初始化之后(设置成员域为0、null或false),才会被任意线程使用。最小安全性“发生”在对象被任意线程使用之前。64位数据的非原子性写“发生”在对象被多个线程使用的过程中(写共享变量)。当发生问题时(处理器B看到仅仅被处理器A“写了一半”的无效值),这里虽然处理器B读取到一个被写了一半的无效值,但这个值仍然是处理器A写入的,只不过是处理器A还没有写完而已。最小安全性保证线程读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。但最小安全性并不保证线程读取到的值,一定是某个线程写完后的值。最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。

下图展示了这3类程序在JMM中与在顺序一致性内存模型中的执行结果的异同。只要多线程程序是正确同步的,JMM保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

3类程序的执行结果的对比图

(4)JSR-133对旧内存模型的修补

JSR-133对JDK 5之前的旧内存模型的修补主要有两个:

  • 增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。
  • 增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。在保证final引用不会从构造函数内逸出的情况下,final具有了初始化安全性。

参考博客和文章书籍等:

《Java并发编程的艺术》

因博客主等未标明不可引用,若部分内容涉及侵权请及时告知,我会尽快修改和删除相关内容