若文章内容或图片失效,请留言反馈。
部分素材来自网络,若不小心影响到您的利益,请联系博主删除。
- 视频链接:https://www.bilibili.com/video/av81461839
- 配套资料:https://pan.baidu.com/s/1lSDty6-hzCWTXFYuqThRPw( 提取码:5xiu)
写这篇博客旨在制作笔记,方便个人在线阅览,巩固知识。无他用。
博客的内容主要来自视频内容和资料中提供的学习笔记。当然,在此基础之上也增删了一些内容。
参考书籍
- 《实战 JAVA 高并发程序设计》 葛一鸣 著
- 《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
参考博客
- 学习笔记【Java 虚拟机④】内存模型
- Java并发编程(中下篇)从入门到深入 超详细笔记
系列目录
- 学习笔记:Java 并发编程①_基础知识入门
- 学习笔记:Java 并发编程②_共享模型值管程
- 学习笔记:Java 并发编程③_共享模型之内存
在之前的 博客 里所讲解的 Monitor,其主要关注的是:访问共享变量时,保证临界区代码的原子性。
这一章我们进一步深入学习共享变量在多线程间的 可见性 问题与多条指令执行时的 有序性 问题。
1.Java 内存模型
JMM(Java Memory Model),它定义了主存、工作内存抽象概念。其底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
JMM 体现在以下几个方面:
- 原子性:保证指令不会受到线程上下文切换的影响
- 可见性:保证指令不会受 CPU 缓存的影响
- 有序性:保证指令不会受 CPU 指令并行优化的影响
以下内容参考自 《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 (周志明 著)
- 硬件的效率与一致性
- 主内存与工作内存
- 内存间的交互操作
- 三大特性
- 先行发生原则
1.1.硬件的效率与一致性
硬件的效率与一致性 |
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System),如下图所示。
当多个处理器的运算任务都涉及同一块主内存区域时,就将有可能导致各自的缓存数据不一致。如果真的发生了这种情况,那同步回到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI(Illinois Protocol)、MOSI、 Synapse、Firefly 及 Dragon Protocol 等。
“内存模型”,它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也有自己的内存模型,并且与这里介绍的内存访问操作及硬件的缓存访问操作具有高度的可类比性。
除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码 的先后顺序来保证。与处理器的乱序执行优化类似,Java 虚拟机的即时编译器中也有指令重排序 (Instruction Reorder)优化。
1.2.主内存与工作内存
主内存与工作内存 |
Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到 内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的(此处请读者注意区分概念:如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享,但是 reference 本身在 Java 栈的局部变量表中是线程私有的),不会被共享,自然就不会存在竞争问题。为了获得更好的执行效能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器 是否要进行调整代码执行顺序这类优化措施。
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本(有部分读者会对这段描述中的 “副本” 提出疑问,如 “假设线程中访问一个 10MB 大小的对象,也会把 这 10MB 的内存复制一份出来吗?”,事实上并不会如此,这个对象的引用、对象中某个在线程访问到的字段是有可能被复制的,但不会有虚拟机把整个对象复制一次),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据(根据《Java 虚拟机规范》的约定,volatile 变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般,因此这里的描述对于 volatile 也并不存在例外)。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图所示,注意与上面的图(处理器、高速缓存、主内存间的交互关系图)进行对比。
这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分(除了实例数据,Java 堆还保存了对象的其他信息。对于 HotSpot 虚拟机来讲,有 Mark Word,其存储对象哈希码、GC 标志、GC 年龄、同步锁等信息;Klass Point,其是指向存储类型元数据的指针;以及一些用于字节对齐补白的填充数据,如果实例数据刚好满足 8 字节对齐,则可以不存在补白),而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。
1.3.内存间交互操作
内存间交互操作 |
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,JMM 中定义了以下 8 种操作来完成。Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。(对于 double 和 long 类型的变量来说,load、store、read 和 write 操作在某些平台上允许有例外)
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
- write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read 和 load 操作;如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作。注意:Java 内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说 read 与 load 之间、store 与 write 之间是可插入其他指令的,如对主内存中的变量 a、b 进行访问时,一种可能出现的顺序是 read a
、read b
、load b
、load a
。除此之外,Java 内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现。
即不允许一个变量从主内存读取了,但工作内存不接受的情况出现;或者工作内存发起回写了,但主内存不接受的情况出现。 - 不允许一个线程丢弃它最近的 assign 操作。即变量在工作内存中改变了之后,必须把该变化同步回主内存。
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。
换句话说就是对一个变量实施 use、store 操作之前,必须先执行 assign 和 load 操作。 - 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
- 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作以初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)。
这 8 种内存访问操作以及上述规则限定,再加上专门针对 volatile 的一些特殊规定,就已经能准确地描述出 Java 程序中哪些内存访问操作在并发下才是安全的。这种定义相当严谨,但也是极为繁琐的,实践起来更是无比麻烦。可能部分读者阅读到这里已经对多线程开发产生恐惧感了。后来 Java 设计团队大概也意识到了这个问题,将 Java 内存模型的操作简化为 read、write、lock 和 unlock 四种。但这只是语言描述上的等价化简,Java 内存模型的基础设计并未改变。然而即使是这四操作种,对于普通用户来说阅读使用起来仍然并不方便。不过读者对此无须过分担忧。除了进行虚拟机开发的团队外,大概没有其他开发人员会以这种方式来思考并发问题,我们只需要理解 Java 内存模型的定义即可。这种定义的一个等效判断原则——先行发生原则,用来确定一个操作在并发环境下是否安全的。
按照我个人的理解的话,上面的六个指令的关系大概就是这样的(8 个指令中除去 lock、unlock 的剩下六个指令)
1.4.三大特性
1.4.1.原子性
由 Java 内存模型来直接保证的原子性变量操作包括 read
、load
、assign
、use
、store
和 write
这六个。
我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是 long 和 double 的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况)。 如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块:synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
1.4.2.可见性
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此。普通变量与 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。 除了 volatile 之外,Java 还有两个关键字能实现可见性,它们是 synchronized 和 final。同步块的可见性是由 “对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store
、write
操作)” 这条规则获得的。而 final 关键字的可见性是指:被final 修饰的字段在构造器中一旦被初始化完 成,并且构造器没有把 “this” 的引用传递出去(this 引用逃逸是一件很危险的事情,其他线程有可能通 过这个引用访问到 “初始化了一半” 的对象),那么在其他线程中就能看见 final 字段的值。
1.4.3.有序性
Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程, 所有的操作都是无序的。前半句是指 “线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指 “指令重排序” 现象和 “工作内存与主内存同步延迟” 现象。 Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。 介绍完并发中三种重要的特性,读者是否发现 synchronized 关键字在需要这三种特性的时候都可以 作为其中一种的解决方案?看起来很 “万能” 吧?的确,绝大部分并发控制操作都能使用 synchronized 来完成。synchronized 的 “万能” 也间接造就了它被程序员滥用的局面,越 “万能” 的并发控制,通常会伴随着越大的性能影响。
1.5.先行发生原则
如果 Java 内存模型中所有的有序性都仅靠 volatile 和 synchronized 来完成,那么有很多操作都将会变 得非常啰嗦,但是我们在编写 Java 并发代码的时候并没有察觉到这一点,这是因为 Java 语言中有一个 “先行发生”(Happens-Before)原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所有问题,而不需要陷入 Java 内存模型苦涩难懂的定义之中。
现在就来看看 “先行发生” 原则指的是什么。先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,比如说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响” 包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但 它意味着什么呢?我们可以举个例子来说明一下。如下面的代码清单中所示的这三条伪代码。
// 以下操作在线程 A 中执行
i = 1;
// 以下操作在线程 B 中执行
j = i;
// 以下操作在线程 C 中执行
i = 2;
假设线程 A 中的操作 i=1
先行发生于线程 B 的操作 j=i
,那我们就可以确定在线程 B 的操作执行 后,变量 j
的值一定是等于 1,得出这个结论的依据有两个:一是根据先行发生原则,i=1
的结果可以被观察到;二是线程 C 还没登场,线程 A 操作结束之后没有其他线程会修改变量 i
的值。现在再来考虑线程 C,我们依然保持线程 A 和 B 之间的先行发生关系,而 C 出现在线程 A 和 B 的操作之间,但是 C 与 B 没有先行发生关系,那 j
的值会是多少呢?答案是不确定!1 和 2 都有可能,因为线程 C 对变量 i
的影响可能会被线程 B 观察到,也可能不会,这时候线程 B 就存在读取到过期数据的风险,不具备多线程安全性。
下面是 Java 内存模型下一些 “天然的” 先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
注意:这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。 - 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。 - volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
这里的 “后面” 同样是指时间上的先后。 - 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测。
我们可以通过Thread::join()
方法是否结束、Thread::isAlive()
的返回值等手段检测线程是否已经终止执行。 - 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用 先行发生于 被中断线程的代码检测到中断事件的发生。
可以通过Thread::interrupted()
方法检测到是否有中断发生。 - 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
- 传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
Java 语言无须任何同步手段保障就能成立的先行发生规则有且只有上面这些。
下面演示一下如何使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全。
读者还可以从下面这个例子中感受一下 “时间上的先后顺序” 与 “先行发生” 之间有什么不同。
演示例子如下方的代码清单所示。
private int value = 0;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
上方的代码清单中显示的是一组再普通不过的 getter/setter 方法。假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue(),那么线程 B 收到的返回值是什么?
我们依次分析一下先行发生原则中的各项规则。由于两个方法分别由线程 A 和 B 调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生 lock 和 unlock 操作,所以管程锁定规则不适用;由于 value 变量没有被 volatile 关键字修饰,所以 volatile 变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规 则,所以最后一条传递性也无从谈起,因此我们可以判定,尽管线程 A 在操作时间上先于线程 B,但是无法确定线程 B 中 getValue() 方法的返回结果,换句话说,这里面的操作不是线程安全的。
那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把 getter/setter 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则;要么把 value 定义为 volatile 变量,由于 setter 方 法对 value 的修改不依赖 value 的原值,满足 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生关系。
通过上面的例子,我们可以得出结论:一个操作 “时间上的先发生” 不代表这个操作会是 “先行发生”。那如果一个操作 “先行发生”,是否就能推导出这个操作必定是 “时间上的先发生” 呢?很遗憾,这个推论也是不成立的。一个典型的例子就是多次提到的 “指令重排序”,演示例子如下方的代码清单所示。
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;
上方的代码清单所示的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1
” 的操作先行发生于 “int j=2
”,但是 “int j=2
” 的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性, 因为我们在这条线程之中没有办法感知到这一点。
上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有因果关系, 所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。
2.可见性
2.1.案例:退不出的循环
存在这样一种现象:main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
示例代码
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
sleep(1);
run = false; // 线程 t 不会如预想的停下来
}
控制台输出
16:29:25.971 [main] DEBUG Test5_01 - 停止 t 线程
// 之后控制台便卡住了。说明线程 t 并没有如预期般停下来
为什么会发生这种情况呢?分析一下:
- 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
- 因为 t 线程要频繁从主内存中读取 run 的值, JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
- 一秒之后,main 线程修改了 run 的值,并同步至主存。
而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
补充介绍:即时编译器 |
以下内容参考自 《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 (周志明 著)
在 Java 技术下谈 “编译期” 而没有具体上下文语境的话,其实是一句很含糊的表述,因为它可能是指:
- 一个前端编译器(叫 “编译器的前端” 更准确一些)把
*.java
文件转变成*.class
文件的过程; - Java 虚拟机的即时编译器(常称 JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;
- 使用静态的提前编译器(常称 AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。
我们可以这样认为,Java 中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;
而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高。
目前主流的两款商用 Java 虚拟机(HotSpot、OpenJ9)里,Java 程序最初都是通过解释器 (Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为 “热点代码”(HotSpot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为 即时编译器。
2.2.volatile
2.2.1.解决方案
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,它要求线程必须到主存中获取它的值。
线程操作 volatile 变量都是直接操作主存
volatile static boolean run = true;
当然,使用 synchronized 关键字也可以解决可见性的问题
static boolean run = true;
// 锁对象
final static Object lock = new Object();
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
//...
synchronized (lock) {
if (!run) {
break;
}
}
}
});
t.start();
sleep(1);
log.debug("停止 t 线程");
synchronized (lock) {
run = false; // 线程 t 并不会如我们预想的那样停下来
}
}
只是 synchronized 是需要创建 monitor 的,属于重量级的操作,而 volatile 的操作则比较轻量。
2.2.2.volatile 的基本介绍
以下内容参考自 《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 (周志明 著)
- 保证变量对所有线程的可见性
- 禁止指令重排序优化
- 对 volatile 变量的特殊规则
Java 内存模型为 volatile 专门定义了一些特殊的访问规则。
当一个变量被定义成 volatile 之后,它将具备两项特性:保证此变量对所有线程的可见性、禁止指令重排序优化。
保证变量对所有线程的可见性 |
这里的 “可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程 A 修改一个普通变量的值,然后向主内存进行回写;另外一条线程 B 在线程 A 回写完成了之后再对主内存进行读取操作,新变量值才会对线程 B 可见。
关于 volatile 变量的可见性,经常会被开发人员误解。
他们会误以为下面的描述是正确的:“volatile 变量对所有线程是立即可见的,对 volatile 变量所有的写操作都能立刻反映到其他线程之中。换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是线程安全的”。这句话的论据部分并没有错,但是由其论据并不能得出 “基于 volatile 变量的运算在并发下是线程安全的” 这样的结论。
volatile 变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中 volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是 Java 里面的运算操作符并非原子操作,这导致 volatile 变量的运算在并发下一样是不安全的。
由于 volatile 变量只能保证可见性,故在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized、java.util.concurrent 中的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
禁止指令重排序优化 |
普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是 Java 内存模型中描述的所谓 “线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。
现在我们来看看在众多保障并发安全的工具中选用 volatile 的意义——它能让我们的代码比使用其他的同步工具更快吗?
在某些情况下,volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁);但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说 volatile 就会比 synchronized 快上多少。
如果让 volatile 自己与自己比较,那可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
不过即便如此,大多数场景下 volatile 的总开销仍然要比锁来得更低。
我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。
对 volatile 变量的特殊规则 |
现在我们再回头看看 Java 内存模型中对 volatile 变量定义的特殊规则的定义。假定 T 表示一个线程,V 和 W 分别表示两个 volatile 型变量,那么在进行 read
、load
、use
、assign
、store
和 write
操作时需要满足如下规则:
- 只有当线程 T 对变量 V 执行的前一个动作是
load
的时候,线程 T 才能对变量 V 执行use
动作;并且,只有当线程 T 对变量 V 执行的后一个动作是use
的时候,线程 T 才能对变量 V 执行load
动作。线程 T 对变量 V 的use
动作可以认为是和线程 T 对变量 V 的load
、read
动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次使用 V 前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量 V 所做的修改。
- 只有当线程 T 对变量 V 执行的前一个动作是
assign
的时候,线程 T 才能对变量 V 执行store
动作;并且,只有当线程 T 对变量 V 执行的后一个动作是store
的时候,线程 T 才能对变量 V 执行assign
动作。线程 T 对变量 V 的assign
动作可以认为是和线程 T 对变量 V 的store
、write
动作相关联的,必须连续且一起出现。这条规则要求在工作内存中,每次修改 V 后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量 V 所做的修改。
- 假定动作 A 是线程 T 对变量 V 实施的
use
或assign
动作,假定动作 F 是和动作 A 相关联的load
或store
动作,假定动作 P 是和动作 F 相应的对变量 V 的read
或write
动作;与此类似,假定动作 B 是线程 T 对变量 W 实施的use
或assign
动作,假定动作 G 是 和动作 B 相关联的load
或store
动作,假定动作 Q 是和动作 G 相应的对变量 W 的read
或write
动作。如果 A 先于 B,那么 P 先于 Q。这条规则要求 volatile 修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。
2.3.比较可见性和原子性
前面例子体现的实际就是可见性。它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况。
上例从字节码理解是这样的
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
补充:访问类字段(static 字段,或者称为类变量)的指令:getstatic、putstatic
比较一下之前我们在线程安全时举的例子:两个线程一个 i++ 一个 i–,只能保证看到最新值,不能解决指令交错
// 假设 i 的初始值为 0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
注意
- synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。
- 但 synchronized 语句块的缺点是 synchronized 是属于重量级操作,性能相对更低。
思考:如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了,想一想为什么?
答:这是因为 println() 中有 synchronized 的部分,保证了可见性
java/io/PrintStream.java
的部分代码
public void println(boolean x) {
synchronized (this) {
print(x);
newLine();
}
}
2.4.终止阶段之两阶段终止
Two Phase Termination
在一个线程 T1 中如何 “优雅” 地终止线程 T2?这里的优雅指的是给 T2 一个料理后事的机会。
2.4.1.错误思路
错误思路 |
- 使用线程对象的 stop() 方法停止线程
- stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁
- 使用 System.exit(int) 方法停止线程
- 目的仅是停止一个线程,但这种做法会让整个程序都停止
2.4.2.利用 isInterrupted
利用 isInterrupted |
interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行
知识回顾
public void Thread.interrupt() // 中断线程
public boolean Thread.isInterrupted() // 判断线程是否被中断
public static boolean Thread.interrupted() // 判断线程是否被中断,且清除当前中断状态
- Thread.interrupt() 方法是一个实例方法。它通知目标线程中断,也就是设置中断标志位。中断标志位表示当前线程已经被中断了。
- Thread.isInterrupted() 方法也是实例方法,它判断当前线程是否有被中断(通过检查中断标志位)。
- 最后的静态方 Thread.interrupted() 也是用来判断当前线程的中断状态,但同时会清除当前线程的中断标志位状态。
方法名 | static | 功能说明 | 注意 |
---|---|---|---|
interrupt() | 打断线程 | 如果被打断的线程正在 sleep,wait,join, 会导致被打断的线程抛出 InterruptedException, 并清除 打断标记; 如果打断的正在运行的进程,则会设置 打断标记; park 的线程被打断,也会设置 打断标记 | |
interrupted() | static | 判断当前线程是否被打断 | 会清除 打断标记 |
isInterrupted() | 判断当前线程是否被打断 | 不会清除 打断标记 |
public static native void sleep(long millis) throws InterruptedException;
- Thread.sleep() 方法会让当前线程休眠若干时间,它会抛出一个 InterruptedException 中断异常。
- InterruptedException 不是运行时异常,也就是说程序必须捕获并且处理它,当线程在 sleep() 休眠时,如果被中断,这个异常就会产生。
- 注意:Thread.sleep() 方法由于中断而抛出异常。
此时,它会清除中断标记,如果不加处理,那么在下一次循环开始时,就无法捕获这个中断,故在异常处理中,再次设置中断标记位。
示例代码
@Slf4j(topic = "c.TwoPhaseTermination_01")
public class TwoPhaseTermination_01 {
// 监控线程
private Thread monitorThread;
// 启动监控线程
public void start() {
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
// 如果 interrupt 打断的是正在休眠中的监控线程,此处需要抛出异常
// 捕获异常后,标记值会被清空,默认标记值变为 false。
// 故此时需要再打断一下,将标记值转为了 ture
// 如果这里不重置这个标记值,那么这个线程是会一直运行的。
log.info("重置打断标记");
// 重置打断标记为 true,进入 if(current.isInterrupted()){} 代码块打断
current.interrupt();
e.printStackTrace();
}
}
}, "monitor");
monitorThread.start();
}
// 停止监控线程
public void stop() {
monitorThread.interrupt();
}
}
测试代码
@Slf4j(topic = "c.Test5_03")
public class Test5_03 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination_01 tpt = new TwoPhaseTermination_01();
tpt.start();
Thread.sleep(3500);
log.debug("停止监控");
tpt.stop();
}
}
控制台输出
19:51:13.925 [main] DEBUG c.Test5_03 - 停止监控
19:51:14.936 [monitor] DEBUG c.TwoPhaseTermination_01 - 执行监控记录
19:51:15.945 [monitor] DEBUG c.TwoPhaseTermination_01 - 执行监控记录
19:51:16.955 [monitor] DEBUG c.TwoPhaseTermination_01 - 执行监控记录
19:51:17.447 [monitor] INFO c.TwoPhaseTermination_01 - 重置打断标记
19:51:17.448 [monitor] DEBUG c.TwoPhaseTermination_01 - 料理后事
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at org.example.chapter05.others.TwoPhaseTermination_01.lambda$start$0(TwoPhaseTermination_01.java:21)
at java.lang.Thread.run(Thread.java:748)
2.4.3.利用停止标记
利用停止标记 |
停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
在下面的示例代码中,即主线程把停止标记修改为了 true,对 t1 线程可见
@Slf4j(topic = "c.TwoPhaseTermination")
public class TwoPhaseTermination {
// 监控线程
private Thread monitorThread;
// 停止标记
private volatile boolean stop = false;
// 启动监控线程
public void start() {
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}
// 停止监控线程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}
测试代码
@Slf4j(topic = "c.Test5_03")
public class Test5_03 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
Thread.sleep(3500);
log.debug("停止监控");
tpt.stop();
}
}
控制台输出
17:01:01.371 [monitor] DEBUG c.TwoPhaseTermination - 执行监控记录
17:01:02.393 [monitor] DEBUG c.TwoPhaseTermination - 执行监控记录
17:01:03.402 [monitor] DEBUG c.TwoPhaseTermination - 执行监控记录
17:01:03.869 [main] DEBUG c.Test5_03 - 停止监控
17:01:03.869 [monitor] DEBUG c.TwoPhaseTermination - 料理后事
2.5.犹豫模式(Balking)
同步模式之 Balking
Balking(犹豫)模式 定义:一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。
示例代码-1(保证监控线程只启动一次)
@Slf4j(topic = "c.TwoPhaseTermination_03")
public class TwoPhaseTermination_03 {
// 监控线程
private Thread monitorThread;
// 停止标记
private volatile boolean stop = false;
// 判断是否执行过 start 方法
private boolean starting = false;
// 启动监控线程
public void start() {
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}
// 停止监控线程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}
需要注意的一点是:尽量减少加锁的代码块,只保护需要保护的资源,以提升性能。
不过上面的代码块里只是在同一个主线程里调用了 start 方法,如此体现不出 synchronized 的作用。实际上监控程序一般是用在 Web 环境下的,该环境下,则肯定会涉及多个线程(Tomcat),此时就必须考虑到锁的问题了。相关的 SpringBoot 程序(case_monitor)在官方所给的资料中。
相关视频链接:https://www.bilibili.com/video/BV16J411h7Rd?p=140
示例代码-2(Balking 模式在单例模式中实现线程安全)
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
这里可以与保护性暂停模式作一下对比:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。
3.有序性
3.1.指令重排
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
可以看到,至于是先执行 i 还是 先执行 j,对最终的结果不会产生影响。
所以,上面代码真正执行时,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
这种特性称之为 指令重排,多线程下 指令重排 会影响正确性。
指令重排优化 |
为什么要有重排指令这项优化呢?这里我们可以从 CPU 执行指令的原理来理解一下。
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段。例如每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段。
术语参考:
- instruction fetch(IF)
- instruction decode(ID)
- execute(EX)
- memory access(MEM)
- register write back(WB)
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80 年代中叶到 90 年代中叶占据了计算架构的重要地位。(提示:分阶段,分工是提升效率的关键!)
指令重排的前提:重排指令不能影响结果
// 可以重排的例子
int a = 10; // 指令 1
int b = 20; // 指令 2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令 1
int b = a - 5; // 指令 2
支持流水线的处理器 |
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1
,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
奔腾四(Pentium 4)支持高达 35 级流水线,但由于功耗太高被废弃。
指令重排优化的问题 |
int num = 0;
boolean ready = false;
// 线程 1 执行此方法
public void actor1(I_Result r){
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程 2 执行此方法
public void actor2(I_Result r){
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果。
问:可能的结果有几种?
- 情况 1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 情况 2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
- 情况 3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
- 情况 4:线程 2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
情况 4 中发生的现象叫做指令重排,是 JIT 编译器在运行时的一些优化。
这个现象需要通过大量测试才能复现,这里可以借助 OpenJDK 提供的 java 并发压测工具:jcstress。(视频链接)
情况 4 虽然发生概率低,但毕竟是发生了。
解决该问题的方法:volatile 修饰的变量,可以禁用指令重排。
3.2.volatile 原理
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
3.2.1.如何保证可见性
写屏障(sfence)保证 在该屏障之前的 对共享变量的改动 都同步到主存当中。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
而读屏障(lfence)保证 在该屏障之后的 对共享变量的读取 加载的是主存中最新数据。
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
3.2.2.如何保证有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
但是 volatile 是不能解决指令交错的
- 写屏障仅仅是保证之后的读操作能够读到最新的结果,但不能保证读操作跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序。
两个线程间的代码执行是由 CPU 的时间片决定的。
这里依旧用之前的 i++ 和 i– 举例
参考博客:Java并发编程(中下篇)从入门到深入 超详细笔记
- synchronized 可以同时保证代码的可见性,有序性和原子性,但是它是不能禁止指令重排的。
- 那么为什么 synchronized 无法禁止指令重排,但是仍然可以保证有序性呢?
- 这是因为加了锁之后,只有一个线程可以获得锁,获得不到锁的线程就需要阻塞等待。所以同一时间只有一个线程在执行,相当于单线程执行。而在单线程的情况下,指令重排是没有问题的,它是不影响结果的。当然,这一切的前提是共享变量没有逃逸出加锁的同步代码块。
3.3.双检锁懒汉式
3.3.1.问题
此处以著名的 double-checked locking 为例
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 单例被创建出来后,其实只要第一次的创建需要线程保护,之后就无需再线程保护了
if (INSTANCE == null) { // 这里的 if 位于同步代码块之外,目的是避免第一次后还要进入加锁的代码块(线程保护)
// 首次访问会同步,而之后的使用没有 synchronized
synchronized (Singleton.class) {
if (INSTANCE == null) { // 这里加入 if 就是为了避免多线程情况下,对象被多次创建并覆盖(因为是单例模式嘛)
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
- 有隐含的、很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外
但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
在上述的指令中:
- 17 表示创建对象,将对象引用入栈
// new Singleton
- 20 表示复制一份对象引用
// 引用地址
- 21 表示利用一个对象引用,调用构造方法
// 根据引用地址调用构造方法
- 24 表示利用一个对象引用,赋值给静态的成员变量(
static INSTANCE
)
也许 JVM 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行
关键在于 0: getstatic
这行代码在 monitor 控制之外,它就像之不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值。
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。
3.3.2.解决办法
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) {
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
从字节码上是看不出来 volatile 指令的效果的
// -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
- 可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中 。
- 读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据 。
- 有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后 。
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。
- 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性。
3.4.happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读是可见的。
- 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x;
static Object m = new Object();
new Thread(() -> {
synchronized (m) {
x = 10;
}
}, "t1").start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
}, "t2").start();
- 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;
new Thread(() -> {
x = 10;
}, "t1").start();
new Thread(() -> {
System.out.println(x);
}, "t2").start();
- 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(() -> {
System.out.println(x);
}, "t2").start();
- 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用
t1.isAlive()
或t1.join()
等待它结束)
static int x;
Thread t1 = new Thread(() -> {
x = 10;
}, "t1");
t1.start();
t1.join();
System.out.println(x);
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过
t2.interrupted
或t2.isInterrupted
)
static int x;
public static void main (String[]args){
Thread t2 = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
}, "t2");
t2.start();
new Thread(() -> {
sleep(1);
x = 10;
t2.interrupt();
}, "t1").start();
while (!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
- 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见。
- 具有传递性,如果
x hb-> y
并且y hb-> z
那么有x hb-> z
,配合 volatile 的防指令重排。
volatile static int x;
static int y;
new Thread(() -> {
y = 10;
x = 20;
}, "t1").start();
new Thread(() -> {
// x=20 对 t2 可见, 同时 y=10 也对 t2 可见
System.out.println(x);
}, "t2").start();
3.5.习题
3.5.1.balking 模式习题
希望 doInit() 方法仅被调用一次,下面的实现是否有问题,为什么?
public class TestVolatile {
volatile boolean initialized = false;
void init() {
if (initialized) {
return;
}
doInit();
initialized = true;
}
private void doInit() { }
}
有问题,因为 volatile 不能保证原子性。
initialized 这个变量多个地方都被使用到了。线程 1 调用 doInit() 方法,执行完了 doInit() 方法,还未执行到 intialized = true
这行代码时;线程 2 调用 doInit() 方法,此时依旧判断 initialized 为 false,也调用了 doInit() 方法。这样就有 doInit() 方法就被调用多次的问题了。
解决办法就是加锁(如 synchronized),利用同步代码块来保护共享变量。
volatile 适用于一个写线程,多个读线程的情况。
volatile 也可以阻止同步代码块外的共享变量的指令重排序现象的发生。
3.5.2.线程安全单例习题
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类。
试分析每种实现下获取单例对象(即调用 getInstance)时的线程安全,并思考注释中的问题。
- 饿汉式:类加载就会导致该单实例对象被创建。
- 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建。
实现 1:饿汉式单例
// 问题 1:为什么加 final
// 问题 2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
public final class Singleton implements Serializable {
// 问题 3:为什么设置为私有? 是否能防止反射创建新的实例?
private Singleton() { }
// 问题 4:这样初始化是否能保证单例对象创建时的线程安全?
private static final Singleton INSTANCE = new Singleton();
// 问题 5:为什么提供公共的静态方法,而不是直接将 INSTANCE 设置为 public ?
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
-
问题 1:为什么加 final ?
答:防止子类覆盖父类,破坏了单例。
final 关键字修饰的类是不可以被继承的;final 关键字修饰的方法是不能被重写的;如果 final 关键字修饰的变量是基本数据类型的话,则值不能改变;final 关键字修饰的变量是引用类型时,那么其不能再指向其他对象。 -
问题 2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例?
答:加这样一行代码public Object readResolve() { return INSTANCE; }
即可。
这是因为反序列化的过程中一旦发现 readResolve() 方法返回了一个对象,就会返回这个方法中的对象;而不是返回反序列化生成的字节码对象。这样一来,返回的还是原对象,单例没有被破坏。 -
问题 3:为什么设置为私有? 是否能防止反射创建新的实例?
答:设置为私有是为了防止 Singleton 这个类被其他的类无限创建,这样一来就不是单例了。
设置为私有是不能防止反射创建新的实例。暴力反射依旧可以得到这个类的全部成分,比如调用这个类的构造器创建新的实例。 -
问题 4:这样初始化是否能保证单例对象创建时的线程安全?
答:静态的成员变量的初始化操作是在类加载的阶段完成的,类加载的阶段是由 JVM 来保证代码的线程安全性的。 -
问题 5:为什么提供公共的静态方法,而不是直接将 INSTANCE 设置为 public ?
使用方法,相当于提供了更好的封装性:可以在内部实现懒惰的初始化;也可以提供更多的泛型支持。如果是直接提供静态的成员变量,就办不到这些。
实现 2:枚举单例(饿汉式)
// 问题 1:枚举单例是如何限制实例个数的
// 问题 2:枚举单例在创建时是否有并发问题
// 问题 3:枚举单例能否被反射破坏单例
// 问题 4:枚举单例能否被反序列化破坏单例
// 问题 5:枚举单例属于懒汉式还是饿汉式
// 问题 6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
enum Singleton {
INSTANCE;
}
- 问题 1:枚举单例是如何限制实例个数的
枚举里面定义的枚举对象,定义时有几个,它将来就有几个对象。大致相当于枚举类的静态成员变量。
这里看一下它的反编译后的字节码就明白了。
-
问题 2:枚举单例在创建时是否有并发问题
答:没有。因为它也是静态成员变量,枚举单例的初始化也是在类加载阶段完成的,由 JVM 保证代码的线程安全。 -
问题 3:枚举单例能否被反射破坏单例
答:不能。反射的第一步是先得到类对象,然后从类对象中获取类的成分对象(Constructor、Field、MethodM)。Constructor 类中用于创建对象的方法有两个:T newInstance(Object... initargs)
(根据指定的构造器创建对象)、public void setAccessible(boolean flag)
(设置 flag 为 true,表示取消访问检查,进行暴力反射)。其中 newInstance 方法有一个判断,如果其为枚举类型,则会抛出异常。
-
问题 4:枚举单例能否被反序列化破坏单例
答:不能。枚举类默认都是继承了 Serializable 接口的。
public abstract class Enum<E extends Enum<E>> implements Comparable<E>, Serializable { }
-
问题 5:枚举单例属于懒汉式还是饿汉式
答:属于饿汉式(类加载就会导致该单实例对象被创建)。
枚举类中定义的对象大致相当于静态成员变量,类加载时就会把静态成员变量这个对象给创建了。 -
问题 6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
答:在枚举类写一个构造方法,把初始化的逻辑都加在构造方法中。
实现 3:懒汉式单例
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 问题:分析这里的线程安全, 并说明有什么缺点
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
- 问题:分析这里的线程安全, 并说明有什么缺点?
答:synchronized 直接作用于静态方法:相当于对当前类加锁,进入同步代码块前需要获得当前类的锁。
如此保证了多个线程访问 getInstance() 时的线程安全。且不能在 INSTANCE 上加 synchronized,这是因为 INSTANCE 后面还需要赋一次值,而且它的值还有可能是 null(null 上是不能加锁的)。
缺点是锁的范围过大了,影响了性能(每次调用 getInstance() 都需要加锁,但实际上在第一次创建完毕后就无需再加锁了)。
实现 4:DCL (双检锁懒汉式单例)
public final class Singleton {
private Singleton() { }
// 问题 1:解释为什么要加 volatile ?
private static volatile Singleton INSTANCE = null;
// 问题 2:对比实现 3, 说出这样做的意义
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 问题 3:为什么还要在这里加为空判断, 之前不是判断过了吗
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
-
问题 1:解释为什么要加 volatile
答:这个其实之前已经讲过了。因为在 synchronized 代码块里,指令依旧会重排序。
第一个线程运行到INSTANCE = new Singleton()
(从字节码上来看,这里有两步操作:调用构造方法构造对象、赋值给静态的成员变量)时,它是先执行了赋值的操作;恰好此时第二个线程如果是在进入同步代码块之前时获取的对象引用(INSTANCE),这时第一个线程尚未执行构造方法的操作,也就是实例对象并未创建完成,线程 2 实际拿到的是一个半成品,之后线程 2 进行非空判断,发现其不为空,故直接返回 INSTANCE,最终返回的对象是一个半成品。而加上 volatile 之后,就可以有效阻止同步代码块中的指令重排序,此时的顺序就一定是先构造、后赋值,就可以有效避免上述的问题了。 -
问题 2:对比实现 3,说出这样做的意义
答:缩小了 synchronized 的范围,提升性能(只有第一次创建单例时会进入同步代码块,之后的调用是无需再进入同步代码块的)。 -
问题 3:为什么还要在这里加为空判断, 之前不是判断过了吗
答:为了防止在第一次创建 INSTANCE 对象时,多个线程并发访问的所引起问题。即第二次的空判断是为了防止第一次并发访问时,单例对象被多次创建的情况。 t1 进入代码块,先锁住了类对象,执行到INSTANCE = new Singleton();
这行代码时(尚未完成),t2 线程也调用了 getInstance 方法,因为此时 INSTANCE 尚未创建完成,所以它也要进入同步代码块。在 t1 线程创建完单例后,释放锁,t2 线程得以进入同步代码块。因为 t2 线程之前已经判断了 INSTANCE 为 null,如果在同步代码块中没有这个判断的话,其必定会再次创建一个实例,从而覆盖掉了之前的实例,这既不单例,也不节约。
实现 5:静态内部类(懒汉式)
public final class Singleton {
private Singleton() { }
// 问题 1:属于懒汉式还是饿汉式
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题 2:在创建时是否有并发问题
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
-
问题 1:属于懒汉式还是饿汉式
答:静态内部类在使用的时候会完成类加载,它不会因为外部类的加载而加载,这是它与静态代码块和静态变量的区别。
类加载本身就是懒惰的,只有在第一次被用到的时候,才会触发类加载操作的。
只有 LazyHolder 被用到时,其才对该内部类进行类加载,对里面的静态变量进行 INSTANCE 初始化。
静态属性由于被 static 修饰,保证了其只被实例化一次,并且严格保证实例化顺序。 -
问题 2:在创建时是否有并发问题
答:无。只有 LazyHolder 被用到时,其才对该内部类进行类加载,对里面的静态变量进行 INSTANCE 初始化。
类加载的线程安全是由 JVM 保证的,故无需担心。
4.本章总结
本章重点讲解了 JMM 中的
- 可见性:由 JVM 缓存优化引起
- 有序性:由 JVM 指令重排序优化引起
- happens-before 规则
- 原理方面
- CPU 指令并行
- volatile
- 模式方面
- 两阶段终止模式的 volatile 改进
- 同步模式之 balking