第3章 Java内存模型
本章大致分4部分:
- Java内存模型的基础,主要介绍内存模型相关的基本概念;
- Java内存模型中的顺序一致性,主要介绍重排序与顺序一致性内存模型;
- 同步原语,主要介绍3个同步原语(synchronized、volatile和final)的内存语义及重排序规则在处理器中的实现;
- Java内存模型的设计,主要介绍Java内存模型的设计原理,及其与处理器内存模型和顺序一致性内存模型的关系。
3.1 Java内存模型的基础
3.1.1 并发编程模型的两个关键问题
在并发编程中,需要处理两个关键问题
- 线程之间如何通信
- 线程之间如何同步
在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写读内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
Java的并发采用的是共享内存模型
3.1.2 Java内存模型的抽象结构
在Java中,每个线程都有自己的栈,但是同一个进程要共享堆。Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了共享变量在该线程中的副本。(本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。)Java内存模型的抽象结构如下图所示。
从图3-1来看,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已更新过的共享变量。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
3.1.3 从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果指令间不存在依赖性(比如数据依赖性),处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
指令级重排序详见(https://blog.csdn.net/u014030117/article/details/46576043),主要是为了实现指令级并发不得不对一些操作的执行顺序进行重排。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
- 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序
- 对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。
3.1.4 内存屏障
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为4类,如表3-3所示。
3.1.5 happens-before简介
定义: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与JMM的关系
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
也就是应用程序员了解happens-before即可,一般不会涉及到复杂的重排序规则的学习
3.2 重排序
定义:重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
3.2.1 数据依赖性
定义:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
数据依赖分为3种
上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。不同处理器之间和不同线程之间的数据依赖性 不被编译器和处理器考虑。
3.2.2 as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。
3.2.3 重排序对多线程的影响
现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码。
1 | class ReorderExample { |
这里有两个线程A和B,我们假设A线程 先执行writer()方法,B线程 再执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?
不一定
由于操作1和操作2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。
如图3-8所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了!
3.3 顺序一致性
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
3.3.1 数据竞争与顺序一致性
当程序未正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义如下
- 在一个线程中写一个变量,
- 在另一个线程读同一个变量,
- 而且写和读 没有通过同步来排序。
JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
3.3.2 顺序一致性内存模型
顺序一致性内存模型有两大特性
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
未同步程序在顺序一致性模型中也可以保证上述两条,例如下面的例子
- 未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。
- 之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。
JMM只保证正确同步的程序执行具有顺序一致性。(在结果上与顺序一致性模型相同,但是在某些语句上会进行重排序来提升效率)
3.3.3 同步程序的顺序一致性效果
下面,对前面的示例程序ReorderExample用锁来同步,看看正确同步的程序 如何具有顺序一致性。
1 | class SynchronizedExample { |
在上面示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果 相同。
顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区(被锁包裹的代码区)内的代码可以重排序。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
3.3.4 未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值 不会无中生有(Out Of Thin Air)的冒出来。
JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致
3.4 volatile的内存语义
3.4.1 volatile的特性
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
以下面的程序为例
1 | class VolatileFeaturesExample{ |
假设有多个线程分别调用上面程序的3个方法,它在语义上与下面程序等价
1 | class VolatileFeaturesExample{ |
简而言之,volatile变量自身具有下列特性。
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
3.4.2 volatile写-读建立的happens-before关系
从JSR-133开始(即从JDK5开始),volatile变量的写读可以实现 线程之间的通信。
从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。
1 | class VolatileExample{ |
假设 线程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。
这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
3.4.3 volatile写-读的内存语义
volatile写的内存语义如下:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
例如下图
线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。
volatile读的内存语义如下:
当读一个volatile变量时,JMM会把该线程对应的所有本地共享变量副本置为无效。线程接下来将从主内存中读取共享变量。
在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。
下面对volatile写和volatile读的内存语义做个总结。
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
3.4.4 volatile内存语义的实现
重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。
3.4.4.1 JMM对编译器重排序的限制
下表是JMM针对编译器制定的volatile重排序规则表
3.4.4.2 JMM对处理器重排序的限制
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图
下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
3.4.5 JSR-133为什么要增强volatile的内存语义
为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。
3.5 锁的内存语义
众所周知,锁可以让临界区互斥执行。这里将介绍锁的另一个同样重要,但常常被忽视的功能:锁的内存语义。
3.5.1 锁的释放-获取建立的happens-before关系
锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
下面是锁释放-获取的示例代码
1 | class MonitorExample { |
假设线程A执行writer()方法,随后线程B执行reader()方法。上述happens-before关系的图形化表现形式如图3-24所示。
线程A在释放锁之前所有可见的共享变量,在线程B获取同一个锁之后,将立刻变得对B线程可见。
3.5.2 锁的释放和获取的内存语义
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
下面对锁释放和锁获取的内存语义做个总结。
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
3.5.3 锁内存语义的实现
锁的内存语义的实现也是通过读写一个volatile变量来实现的
- 公平锁和非公平锁释放时,最后都要写一个volatile变量state。
- 公平锁获取时,首先会去读volatile变量。
- 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。
下面解释一下上文提到的CAS
- 本文把Java的compareAndSet()方法调用简称为CAS
- JDK文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有volatile读和写的内存语义。
- 为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序。
3.5.4 concurrent包的实现
Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写(例如i++就是典型的读改写操作)操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。
如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。
- 首先,声明共享变量为volatile。
- 然后,使用CAS的原子条件更新来实现线程之间的同步。
- 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
AQS、非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如3-28所示。
3.6 final域的内存语义
3.6.1 final域的重排序规则
写final域的重排序规则会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。
3.6.2 写final域的重排序规则
3.6.3 读final域的重排序规则
3.6.4 final域的引用类型
3.6.5 为什么final引用不能从构造函数内“溢出”
在旧的Java内存模型中,一个最严重的缺陷就是线程可能看到final域的值会改变。比如,一个线程当前看到一个整型final域的值为0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个final域的值时,却发现值变为1(被某个线程初始化之后的值)。最常见的例子就是在旧的Java内存模型中,String的值可能会改变。
为了修补这个漏洞,JSR-133专家组增强了final的语义。通过为final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指lock和volatile的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值。
3.7 happens-before
3.7.1 JMM的设计
从JMM设计者的角度,在设计JMM时,需要考虑两个关键因素
- 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点
JMM把happens-before要求禁止的重排序分为了下面两类。
- 会改变程序执行结果的重排序,对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 不会改变程序执行结果的重排序,对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)
JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。
- 例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。
- 再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。
- 这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
3.7.2 happens-before的定义
Leslie Lamport使用happens-before来定义分布式系统中事件之间的偏序关系(partial ordering)
JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happensbefore关系,尽管a操作和b操作在不同的线程中执行,JMM向程序员保证a操作将对b操作可见)。
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
上面的1是JMM对程序员的承诺
上面的2是JMM对编译器和处理器重排序的约束原则。JMM这么做的原因是:程序员对于这两个操作是否真的被重
排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。
3.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()操作成功返回。
3.7.3.1 start()规则
假设线程A在执行的过程中,通过执行ThreadB.start()来启动线程B;同时,假设线程A在执行ThreadB.start()之前修改了一些共享变量,线程B在开始执行后会读这些共享变量。图3-35是该程序对应的happens-before关系图。
在图3-35中,1 happens-before 2由程序顺序规则产生。2 happens-before 4由start()规则产生。根据传递性,将有1 happens-before 4。这意味着,线程A在执行ThreadB.start()之前对共享变量所做的修改,接下来在线程B开始执行后都将确保对线程B可见。
3.7.3.2 join()规则
假设线程A在执行的过程中,通过执行ThreadB.join()来等待线程B终止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后会读这些共享变量。图3-36是该程序对应的happens-before关系图。
在图3-36中,2 happens-before 4由join()规则产生;4 happens-before 5由程序顺序规则产生。根据传递性规则,将有2 happens-before 5。这意味着,线程A执行操作ThreadB.join()并成功返回后,线程B中的任意操作都将对线程A可见。
3.8 双重检查锁定与延迟初始化
在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。(用到这个对象的时候再初始化,而不是一开始就初始化)双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。本文将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。
3.8.1 双重检查锁定的由来
在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。
下面是非线程安全的延迟初始化对象的示例代码
1 | public class UnsafeLazyInitialization { |
在UnsafeLazyInitialization类中,假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化,然后也去对对象进行初始化,最终导致冲突和错误。
我们可以对getInstance()方法做synchronized同步处理来实现线程安全的延迟初始化。示例代码如下
1 | public class SafeLazyInitialization { |
由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。
因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。
1 | public class DoubleCheckedLocking { // 1 |
如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美。
- 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
- 在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。
双重检查锁定看起来似乎很完美,但这是一个错误的优化!**在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。**下一小节将解释这个问题。
3.8.2 问题的根源
前面的双重检查锁定示例代码的第7行(instance=new Singleton();
)创建了一个对象。这一行代码可以分解为如下的3行伪代码。
1 | memory = allocate(); // 1:分配对象的内存空间 |
上面3行伪代码中的2和3之间,可能会被重排序。这个重排序可能在没有改变单线程程序执行结果的前提下,可以
提高程序的执行性能。
由于单线程内要遵守happens-before准则,从而能保证A线程的执行结果不会被改变。但是,当线程A和B按图3-38的时序执行时,B线程将看到一个还没有被初始化的对象。
回到本文的主题,DoubleCheckedLocking示例代码的第7行(instance=new Singleton();)如果发生重排序,另一个并发执行的线程B就有可能在第4行判断instance不为null。线程B接下来将访问instance所引用的对象,但此时这个对象可能还没有被A线程初始化!
3.8.3 基于volatile的解决方案
对于前面的基于双重检查锁定来实现延迟初始化的方案(指DoubleCheckedLocking示例代码),只需要做一点小的修改(把instance声明为volatile型),就可以实现线程安全的延迟初始化。
1 | public class SafeDoubleCheckedLocking { |
当声明对象的引用为volatile后,3.8.2节中的3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。
因为instance = memory
是volatile写操作,前面有storestore内存屏障,因此不会发生2和3之间的重排序。
上面示例代码将按如下的时序执行,如图3-39所示。
3.9 Java内存模型综述
3.9.1 处理器内存模型
顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型在设计时通常会以顺序一致性内存模型为参照。在设计时,JMM和处理器内存模型会对顺序一致性模型做一些放松。
处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的。根据对不同类型的读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为如下几种类型。
- 放松程序中写-读操作的顺序,由此产生了Total Store Ordering内存模型(简称为TSO)。
- 在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了Partial Store Order内存模型(简称为PSO)。
- 在前面两条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了RelaxedMemory Order内存模型(简称为RMO)和PowerPC内存模型。
由于常见的处理器内存模型比JMM要弱 ,Java编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱不同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM在不同的处理器中需要插入的内存屏障的数量和种类也不相同。
JMM屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为Java程序员呈现了一个一致的内存模型。
3.9.2 各个内存模型之间的关系
下面是语言内存模型、处理器内存模型和顺序一致性内存模型的强弱对比示意图,如图3-49所示
从图中可以看出:
- 常见的4种处理器内存模型比常用的3中语言内存模型要弱,
- 处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。
- 越是追求执行性能的编程语言,内存模型设计得会越弱。
3.9.3 JMM的内存可见性保证
按程序类型,Java程序的内存可见性保证可以分为下列3类
- 单线程程序:单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序:正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步/未正确同步的多线程程序:JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。