JUC-3(共享模型之内存)
JMM即Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。
JMM体现在以下几个方面:
- 原子性:保证指令不会受到线程上下文切换的影响
- 可见性:保证指令不会受CPU缓存的影响
- 有序性:保证指令不会受CPU指令并行优化的影响
3.1 JMM
3.1.1 JMM内存模型
Java线程的通信由JMM控制,JMM的主要目的是定义程序中各种变量的访问规则。变量包括实例字段、静态字段,但不包括局部变量与方法参数,因为它们是线程私有的,不存在多线程竞争。
JMM遵循一个基本原则:只要不改变程序执行结果,编译器和处理器怎么优化都可以。
JMM规定所有变量都存储在主内存,每条线程都有自己的工作内存,工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。不同线程间无法直接访问对方工作内存中的变量,线程通信必须经过主内存。
Java内存模型跟CPU缓存模型类似,是基于CPU缓存模型来建立的,Java线程内模型是标准化的,屏蔽了底层不同操作系统的区别。每个线程都拥有自己的工作内存,一般不会直接操作主内存,在读取的时候把主内存中的共享变量复制到自己的工作内存。
关于主内存与工作内存的交互,即变量如何从主内存拷贝到工作内存、从工作内存同步回主内存,JMM 定义了 8 种原子操作:
操作 | 作用变量范围 | 作用 |
---|---|---|
lock | 主内存 | 把变量标识为线程独占状态 |
unlock | 主内存 | 释放处于锁定状态的变量 |
read | 主内存 | 把变量值从主内存传到工作内存 |
load | 工作内存 | 把 read 得到的值放入工作内存的变量副本 |
user | 工作内存 | 把工作内存中的变量值传给执行引擎 |
assign | 工作内存 | 把从执行引擎接收的值赋给工作内存变量 |
store | 工作内存 | 把工作内存的变量值传到主内存 |
write | 主内存 | 把 store 取到的变量值放入主内存变量中 |
3.1.2 as-if-serial
不管怎么重排序,单线程程序的执行结果不能改变,编译器和处理器必须遵循 as-if-serial 语义。
为了遵循 as-if-serial,编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
as-if-serial 把单线程程序保护起来,给程序员一种幻觉:单线程程序是按程序的顺序执行的。
3.1.3 happens-before
先行发生原则,JMM 定义的两项操作间的偏序关系,是判断数据是否存在竞争的重要手段。
JMM 将 happens-before 要求禁止的重排序按是否会改变程序执行结果分为两类。对于会改变结果的重排序 JMM 要求编译器和处理器必须禁止,对于不会改变结果的重排序,JMM 不做要求。
JMM 存在一些天然的 happens-before 关系,无需任何同步器协助就已经存在。如果两个操作的关系不在此列,并且无法从这些规则推导出来,它们就没有顺序性保障,虚拟机可以对它们随意进行重排序。
程序次序规则:一个线程内写在前面的操作先行发生于后面的。
管程锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。
volatile 规则:对 volatile 变量的写操作先行发生于后面的读操作。
线程启动规则:线程的 start 方法先行发生于线程的每个动作。
线程终止规则:线程中所有操作先行发生于对线程的终止检测。
对象终结规则:对象的初始化先行发生于 finalize 方法。
传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C 。
3.2 可见性
3.2.1 退不出的循环
先看一个现象,main线程对run变量的修改对于t线程不可见,导致了t线程无法停止:
1 |
|
3.2.2 分析
- 初始状态,t线程刚开始从主内存读取了run的值到工作内存。
因为t线程要频繁地从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率。
1秒之后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
3.2.3 解决办法
(1)给变量run
加上修饰符volatile
。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volitale
变量都说直接操作主存。
(2)使用synchronized
1 |
|
(3)在while
循环中使用System.out.println()
也会终止循环,原因可以看println()
的源代码,其中执行的是PrintStream#newLine()
方法:
1 | private void newLine() { |
3.2.4 synchronized/volatile
synchronized
可以修饰变量和方法,volatile
只能修饰变量。synchronized
保证操作的原子性,volatile
保持变量的可见性。synchronized
通常适用于写多读少的场景,volatile
通常适用于写少读多的场景。
3.3 模式
3.3.1 终止模式之两阶段终止模式
使用volatile
实现两阶段终止:
1 | public class TwoStageTerminationDemo { |
3.3.2 同步模式之Balking
Balking(犹豫)模式用在一个线程发现另一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回,实际上是一种单例模式。
修改两阶段终止如下,监控线程执行一次之后就不再执行。
1 | public class BalkingTwoStageTerminationDemo { |
3.4 有序性
3.4.1 指令重排序
JIT即时编译器的优化,可能会导致指令重排。JVM会在不影响正确性的前提下,调整语句的执行顺序。
(1)例如以下代码:
1 | static int i, j; |
对于i
和j
的赋值操作顺序,对最终的结果都没有影响。因此在真正执行的时候,可能i
也可能是j
先被赋值。但是在多线程情况下指令重排序会影响正确性。
(2)比如new
一个对象: 一般顺序为(1)分配内存(2)对象初始化(3)建立指针对应关系,高并发情况下顺序可能乱成132,对象初始化时就被另一个线程读取造成问题。
3.4.2 诡异的结果
在如下代码中,I_Result
是一个对象,有一个属性r1
用来保存结果。
线程1执行actor1
方法,线程2执行actor2
方法,思考r1
的可能结果。
1 | int num = 0; |
- 线程1先执行,线程2后执行,此时结果为1
- 线程2先执行,线程1后执行,此时结果为4
- 线程2先执行,执行到
num = 2
,线程1开始执行,此时结果为1 - 线程2先执行,但是指令重排,先执行
ready = true
,线程1开始执行,进入if (ready)
,此时结果为2
解决方法:给ready
加上修复符volatile
。
3.4.3 重排序规则
- 指令重排序不会对存在数据依赖关系的操作进行重排序。比如:
a= 1; b = a;
。 - 重排序是为了优化性能,但是不管如何重排,单线程下程序的执行结果不能改变。
- 指令重排序保证单线程模式下的结果正确性,但是不保证多线程模式下的正确性。
- 解决方法:
volatile
修饰的变量,可以禁用指令重排。
3.5 volatile原理
3.5.1 double-checked locking
以著名的DCL(double-checked locking)单例模式为例:
1 | public class DCLSingleton { |
以上实现的特点:
- 延迟实例化
- 首次使用
getInstance()
才使用synchronbized
加锁,后续使用时无需加锁。 - 有隐含的,但很关键的一点:第一个
if
使用了INSTANCE
变量,是在同步块之外。
但是在多线程环境下,上面的代码是有问题的,getInstance()
方法对应的字节码为:
1 | 0 getstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE> # 获取静态变量INSTANCE |
重点关注17-24行:
- 17:创建
DCLSingleton
对象 - 20:复制
DCLSingleton
的class对象 - 21:调用class对象的默认构造方法
- 24:将新对象赋值给静态变量
INSTANCE
也许JVM会优化为:先执行24,再执行21,即先赋值,再调构造。
可能造成:一个线程正在造对象,对象还为空的时候就被另一线程拿去使用。
解决方法:
1 | public class DCLSingleton { |
从字节码上看不出volatile的效果,但是我们从屏障的角度分析:
1 | # ====================================================> 加入对INSTANCE的读屏障 |
3.5.2 缓存一致性协议
Intel处理器对应的缓存一致性协议为MESI。
CPU缓存行存在四种状态:
状态 | 描述 |
---|---|
M(modifed) | 缓存行有效,数据被修改,与内存中的数据不一致,数据只存在于该CPU缓存行中。 |
E(exclusive) | 缓存行有效,数据与内存中的数据一致,数据只存在于该CPU缓存行中。 |
S(shared) | 缓存行有效,数据与内存中的数据一致,数据存在于很多CPU缓存行中。 |
I(invalid): | 缓存行无效。 |
缓存一致性协议需要与多处理器的总线嗅探机制结合使用。
开启了缓存一致性协议后,处理器需要对总线进行嗅探,监听总线中这个处理器所感兴趣的数据。
如果主内存中所感兴趣的数据被其他处理器修改,那么线程工作内存中的对应数据会被立即失效。
3.5.3 实现可见性原理
JVM中volatile需要实现的内存屏障,在源代码bytecodeInterpreter中实现。
1 | OrderAccess:storeload(); |
这个方法根据操作系统和CPU的不同会有不同的实现(跨平台就是实现了不同平台的指令集的屏蔽),比如Linux_X86的实现:
1 | inline void OrderAccess::storeload() { fence(); } |
fence
的实现调用汇编指令:
1 | inline void OrderAccess::fence() { |
lock
前缀指令在多核处理器下会引发两件事情:
- 将当前CPU缓存行的数据写回到系统内存。
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高提高处理速度,CPU不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1、L2或者L3)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向CPU发送Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他CPU缓存的值还是旧的,再执行计算操作就会有问题。所以,在多核处理器下,为了保证各个CPU的缓存是一致的,就会实现缓存一致性协议,每个CPU通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当CPU发现自己缓存行对应的内存地址被修改,就会将当前CPU的缓存行设置成无效状态,当CPU对这个数据进行修改操作的时候,会重新从系统内存中把数据读到CPU缓存里。
1)Lock前缀指令会引起CPU缓存回写到内存。
Lock前缀指令导致在执行指令期间,声言CPU的LOCK#信号。在多核处理器环境中,LOCK#信号确保在声言该信号期间,CPU可以独占任何共享内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。
对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在CPU内部,则不会声音LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被成为“缓存锁定”,缓存一致性机制会阻止同时修改两个以上CPU缓存的内存区域数据。
2)一个CPU的缓存回写到内存会导致其他CPU的缓存失效。
IA-32处理器和Intel 64处理器使用MESI控制协议去维护内存缓存和其他CPU缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。CPU通过嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。
例如,在Pentium和P6 family处理器中,如果通过嗅探一个CPU来检测其他CPU打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。
3.5.4 实现有序性原理
为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。
内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。
插入内存屏障的策略:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 保证Load1数据的读取先于Load2及后续所有读取指令的执行 |
StoreStore Barriers | Store1;StoreStore;Store2 | 保证Store1数据刷新到主内存先于Store2及后续所有存储指令 |
LoadStore Barriers | Load1;LoadStore;Store2 | 保证Load1数据的读取先于Store2及后续的所有存储指令刷新到主内存 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 保证Store1数据刷新到主内存先于Load2及后续所有读取指令的执行 |
Java内存模型对编译器指定的volatile重排序规则为:
当第一个操作是volatile读时,无论第二个操作是什么都不能进行重排序;
当第二个操作是volatile写时,无论第一个操作是什么都不能进行重排序;
当第一个操作是volatile写时,第二个操作为volatile读时,不能进行重排序。
volatile读:在每个volatile读后面分别插入LoadLoad屏障及LoadStore屏障。
- LoadLoad屏障的作用:禁止上面所有的普通读操作和上面的volatile读操作进行重排序。
- LoadStore屏障的作用:禁止下面的普通写和上面的volatile读进行重排序。
volatile写:在每个volatile写前面插入一个StoreStore屏障,在每个volatile写后面插入一个StoreLoad屏障。
- StoreStore屏障的作用:禁止下面的普通写和下面的volatile写重排序。
- StoreLoad屏障的作用:防止上面的volatile写与下面可能出现的volatile读/写重排序。
3.6 习题
3.6.1 balking模式
希望doInit()
方法仅被调用一次,下面的实现是否有问题,为什么?
1 |
|
有问题,volatile
无法保证原子性,当多个线程同时调用init()
方法时,此时都进入到if
判断,都调用doInit()
方法,此时就调用了多次。
解决方法:对init()
方法的方法体,通过synchronized
加锁,防止多个线程共享initialized
。
1 |
|
3.6.2 线程安全单例
饿汉式
1 | // 问题1:为什么加final? |
DCL懒汉式
1 | public final class DCLSingleton { |
静态内部类
1 | public final class Singleton { |
枚举类
1 | // 问题1:枚举单例是如何限制实例个数的? |