- 《Java并发编程的艺术》第3章的标题《Java内存模型》,初一看自己还以为讲解的JVM的内存模型(堆、栈、方法区等)
- 真正学习时,发现这一章的内容组织对自己来说比较难理解,学得迷迷糊糊的
- 查看了一些资料,起码比不看的效果更好:
- 自己之前的博客:Java高并发之JMM(java内存模型、volatile变量、JMM的三大特性)
- 短小精悍的Java学习笔记:Java并发 —— 十、Java 内存模型
- 发现《Java并发编程的艺术》第3章,就是在详细介绍Java并发编程的三大特性中的两个特性:可见性、有序性
- 可见性
- 可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。
- Java 内存模型(简写JMM)是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的
- 可见性的三种主要实现方式:volatile、 synchronized、final
- 主要有三种实现可见性的方式:
- 有序性
- JMM中,为了提高程序的执行性能,允许编译器和处理器对指令序列重新进行排序
- 这使得,本线程内观察,所有操作都是有序的;在其他线程观察当前线程,所有操作是无序的
- 不太理解 😂,重点在指令重排序: 指令重排序不影响单线程程序的执行结果,但可能会影响多线程程序的执行结果
- 重排序的前提:不会影响单线程程序的执行结果
1. 内存可见性
1.1 并发编程的2个关键问题:通信与同步
通信
- 所谓通信,是指线程间以何种机制来传递信息
- 线程的间的两种通信机制:共享内存、消息传递
- 共享内存:通过读写共享内存,进行隐式通信
- 消息传递:线程之间通过消息的发送与接收,进行显式通信
同步
- 所谓同步,是指程序中用于控制不同线程间操作发生相对顺序的机制
- 共享内存的通信机制中,程序员需要显式控制内存的互斥访问,是一种显式同步
- 消息传递的通信机制中,消息的发送必定早于消息的接收,是一种隐式同步
1.2 JMM的抽象结构
缓存一致性问题
- 操作系统中,为了寄存器和内存之间读写速度几个数量级的差异,引入了高速缓存
- 每个处理器都有自己的高速缓存,如果缓存同一块内存区域,这些高速缓存中的数据可能不一致
JMM的抽象结构
- 线程间的共享变量存储在主内存中,每个线程都有一个自己的工作内存,又叫本地内存
- 工作内存中存储了线程需要读/写的共享变量的副本
- 线程只能操作工作内存中共享变量的副本,不同线程间共享变量的同步依靠主内存完成
- 一些说明:
- 共享变量是指存储在堆内存中的变量,包括实例变量、静态变量、数组等
- 堆是线程共享的,而存储局部变量、操作数栈等的虚拟机栈是线程私有的
1.3 内存可见性的重要性
理想的线程通信:
- 线程A将本地内存中更新过的共享变量x的值写回主内存
- 线程B从主内存读取线程A已经更新过的共享变量的值
- 也就是说,线程读到的总是共享变量的最新值,而非本地内存中缓存的旧值
实际的线程通信
-
考虑这样的场景:初始时,共享变量
a = b = 0
时间 线程A 线程B 备注 t1 a = 1; // A1
b = 2; // B1
线程A将主内存中 a
的值更新为1,线程B将主内存中b
的值更新为2t2 x = b; // A2
y = a; // B2
线程A从主内存获取 b
的最新值,x
的值更新为2;
线程B从主内存获取a
的最新值,y
的值更新为1 -
上面的操作结果是处理器视角,它认为 A1 → \rightarrow → A2(A1先于A2执行)
-
由于本地内存的存在,其实际执行顺序:A2 → \rightarrow → A1
- A1操作的是本地内存,只有最后执行A3后,主内存中
a
的值才被更新为1 - 因此,对内存来说,是先读取主内存中尚未更新的
b
,然后再更新a
的值
- A1操作的是本地内存,只有最后执行A3后,主内存中
总结:本地内存影响了共享变量的可见性
- 由于本地内存的存在,导致共享变量的值在本地内存发生更新后,其他线程无法感知这个修改。
- 只有将修改后的共享变量的新值立即写回主内存,并且其他线程访问共享变量时,都从主内存而非本地内存获取最值,才能保证共享变量的
"可见性"
:一旦修改,立马可感知
1.4 理想的内存模型 —— 顺序一致性内存模型
- 顺序一致性内存模型是一个理论参考模型,JMM和处理器内存模型的设计都以该模型作为参考
- 顺序一致性内存模型具有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行(不允许重排序)
- 不管程序是否正确同步,所有线程都只能看到一个单一的执行顺序。每个操作都必须原子执行,且执行结果立即对其他线程可见
- 对特性二的理解:
- 可以看做一个单开关多灯模型,开关可以随意switch到某个灯,使其点亮
- 也就是说,任意时刻只能有一个线程连接到内存,从而对内存进行读/写操作
- 这样,多线程的并发执行被这个唯一的开关变成了串行,使得多线程中所有的操作具有全序
注意:64 bit变量的写操作不具有原子性
- 32 bit的处理器,单个写操作原本是具有原子性的
- 64 bit的处理器,对long或double类型变量的写操作将由两个写操作实现。
- 此时,64 bit变量的写操作将不再具有原子性
2. 重排序
- 为了提高程序的执行性能,编译器和处理器往往会对指令序列进行重排序
2.1 重排序的分类
3种类型的重排序
-
编译器优化重排序:
- 编译器在不改变单线程程序语义的情况下,可以重新安排语句的执行顺序
- 例如,针对不同变量的写操作:
// 原始顺序 a = 10; flag = true; // 重排序后的顺序 flag = true; a = 10;
-
指令级并行重排序:
- 现代处理器支持指令级并行(ILP)技术,可以将多条指令重叠执行
- 若不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序
-
内存系统重排序 (并未真正的重排序?)
- 处理器使用缓存和读/写缓冲区,使得load和store操作看起来可能是乱序执行的
- 处理器使用缓存和读/写缓冲区,使得load和store操作看起来可能是乱序执行的
-
其中,指令级并行重排序和内存系统重排序统称为处理器重排序
-
常见处理器所允许的重排序规则
- 常见的处理器都不允许对存在数据依赖的操作进行重排序
- 常见的处理器都允许对
Store - Load
操作进行重排序 - 从上到下,这些处理器允许的重排序规则逐渐增加,这是为了降低内存模型的束缚以追求更高的性能
禁止重排序
- 重排序可能会导致多线程之间出现内存可见性问题,有时需要禁止重排序
- 对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序
- 对于处理器重排序,JMM的处理器重排序规则会在编译器生成指令序列时,插入特定类型的内存屏障,从而禁止特定类型的处理器重排序
2.2 内存屏障
-
内存屏障,是一组机器指令,用于实现对内存操作的顺序限制
-
自己的理解:可以限制内存读/写操作先后顺序的一组机器指令
-
共有4种内存屏障:对内存的操作只有两种 (读/写),内存屏障是针对这两种操作的组合
屏障类型 指令示例 说明 LoadLoad load1; LoadLoad; load2;
load2及其后续的load操作执行前,load1中的load操作已全部完成 StoreStore store1; Store; store2;
store2及其后续的store操作执行前,store1中的store操作已全部执行完成
也就是store1中的操作的数据要先对其他处理器可见LoadStore load1; LoadStore; store2;
store2及其后续store操作执行前,load1中的load操作已全部执行完 StoreLoad store1; StoreLoad; load2;
load2及其后续load操作执行前,store1中的store操作已全部执行完 -
StoreLoad是开销最昂贵的内存屏障,也是全能型的内存屏障,同时具有其他3种内存屏障的效果
- StoreLoad屏障要求该屏障之前的所有内存访问指令(包括store和load)完成之后,才会执行该屏障之后的内存访问指令
- 开销昂贵的原因:需要将写缓冲区中的数据全部刷新到内存中
絮絮叨叨
- 其实,自己对内存屏障的理解也不是很透彻
- 只知道:
- ① 内存屏障可以对内存访问的顺序进行限制,进而可以禁止指令重排序
- ② 根据读写操作的组合,有四种内存屏障;
- ③ StoreLoad的开销最为昂贵且兼备其他三种内存屏障的效果
补充知识:
- 后续的学习中,可以了解到:
- 内存屏障可以进行优化,去除不必要的内存屏障,提高程序执行效率
- 甚至在某些处理器中(x86),可以不使用某些内存屏障
2.3 数据依赖 & as-if-serial语义 (重排序对单线程的影响)
数据依赖
- 两个操作访问同一个变量,且其中一个操作为写操作。此时,这两个操作之间存在数据依赖
- 单个处理器或单个线程中,上面的三种情况,两个操作的执行顺序一旦改变,程序执行结果将会改变。
- 编译器和处理器(大多数)不会对存在数据依赖的操作重排序
as-if-serial语义
as-if-serial
语义的规定:不管怎么重排序,单线程程序的执行结果不能被改变- 编译器、runtime和处理器,都需要遵守 as-if-serial 语义
- as-if-serial 语义为单线程执行制造了一种假象:单线程程序是按照代码顺序依次执行的,但实际可能发生了重排序,只是因为执行结果未改变,程序员难以感知
- 例如,计算圆面积的代码如下:数据依赖关系为 A
→
\rightarrow
→ C,B
→
\rightarrow
→ C,即操作A和操作B之间不存在数据依赖
- 因此,可以重排序为 B → \rightarrow → A → \rightarrow → C,重排序后的执行结果不变
2.4 重排序对多线程的影响
- 从上面的讲解可知:单线程下,不存在数据依赖的操作可以重排序,而程序的执行结果不会被改变
- 但在多线程下,即使不存在数据依赖,重排序也可能会影响程序的执行结果
- 示例代码如下:
- 操作1和操作2不存在数据依赖,可以重排序
- 重排序后,多线程访问将存在问题:(带箭头的虚线标识错误的读操作,带箭头的实线标识正确的读操作)
- 线程B访问flag时,获取到的值为
true
,条件判断为真。 - 随后,线程B读取变量a的值,为初始化时的
0
并非预期的1
- 线程B访问flag时,获取到的值为
3. happens-before规则
- happens-before规则,又叫先行发生原则,是JMM的的核心概念
- 从JDK 5开始,Java使用JSR-133内存模型,该内存模型使用happens-before规则来阐述操作之间的内存可见性
3.1 happens-before的定义
设计者的考虑
- JMM的设计者,在设计JMM时,应该考虑下面两个因素:
- 一方面,要为程序员提供足够强的内存可见性保证
- 另一方面,对编译器和处理器的限制要尽可能的放松(束缚过多,没有足够提高性能的优化空间)
- JMM实质上遵循一个原则:在单线程和正确同步的多线程程序中,只要不改变程序的执行结果,编译器和处理器怎么优化都行
- 程序执行过程中,锁只会被一个线程访问,则锁可以被消除
- volatile变量只会被一个线程访问,则可以将其当做一个普通变量处理
happens-before的定义
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二操作可见,且第一个操作的执行顺序排在第二个操作之前
- 两个操作之间存在happens-before关系,并不意味着Java必须按照happens-before关系指定的顺序来执行。因为若重排序后的执行结果,与按照happens-before的执行结果一致,那允许进行重排序
说明
- 这两个定义看起来相互矛盾,定义1已经规定了两个操作执行的先后顺序,定义2又说允许重排序
- 定义1是JMM对程序员的承诺:A happens-before B,从程序员视角来看,A的执行结果对B可见,且A的执行顺序在B之前
- 定义2是JMM对编译器和处理器重排序的约束:
- 重排序的前提是,执行结果不能被改变。
- 对程序员来说,他更关心的是执行结果而非执行过程
- happens-before语义和as-if-serial语义本质相同:
- 前者保证,正确同步的多线程程序执行结果不被改变;后者保证,单线程程序的执行结果不被改变
- 前者让程序员误认为:正确同步的多线程程序是按照happens-before指定的顺序执行的
- 后者让程序员误认为:单线程程序是按照程序的顺序来执行的
3.2 happens-before规则
程序顺序规则
- 又叫单一线程原则:在一个线程内,程序前面的操作,happens-before程序后面的操作
监视器锁规则
- 对一个锁的解锁操作,happens before 对这个锁的加锁操作
volatile变量规则
- 对一个volatile变量的写操作,happens before对这个volatile变量的读
start()规则
- 线程A执行threadB.start()操作启动线程B,则线程A的threadB.start()操作happens before 线程B中的任意操作
- 很好理解:人家不启动你,你怎么可能执行自身操作
join()规则
- 线程A执行threadB.join()并成功返回,则线程B中任意操作 happens before 线程A从threadB.join()操作成功返回
线程中断规则
- 执行thread. interrupt() 操作,happens before被中断线程检测到中断事件的发生
- 可以通过 interrupted() 或 isInterrupted() 方法,检测线程是否被中断
对象终结规则
- 对象的初始化的完成,happens before 该对象的 finalize() 方法的开始
传递性规则
- 如果A happens-before B,B happens-before C,则 A happens-before C
- 这对理解happens-before原则如何提供跨线程的内存可见性非常重要
4. 基于内存语义,了解可见性保证
- happens-before,是JMM在内存可见性问题上对程序员的承诺
- 如何实现这个可见性承诺,需要依靠具体的内存语义来实现
4.1 volatile
4.1.1 volatile的内存语义
- volatile变量的代码示例如下
volatile内存语义
- 线程A写volatile变量时,JMM会把线程A对应的本地内存中的共享变量刷新到主内存
- 从通信的角度看,是线程A向后续读volatile变量的线程发出了消息,告知它们共享变量已被修改
- 线程B读volatile变量时,JMM会把线程B对应的本地内存置为无效;线程B需要从主内存读取共享变量,这时读取到的是共享变量的最新值
- 从通信的角度看,是线程B接收到了向线程A发出的消息,知道了共享变量已被修改的事情
- 整体来看,线程A通过主内存向线程B发送消息
4.1.2 volatile内存语义的实现
- volatile重排序规则如下
- 第三行最后一个单元格为No,表示当第一个操作为普通读/写操作,第二个操作为volatile写操作时,编译器不能重排序这两个操作
- 第三行最后一个单元格为No,表示当第一个操作为普通读/写操作,第二个操作为volatile写操作时,编译器不能重排序这两个操作
volatile写内存语义
- 写操作之前插入一个
StoreStore
内存屏障,禁止前面的普通写与后面的volatile写重排序- 这样可以保证volatile写被刷新回主内存之前,前面的普通写已经刷新到内存
- 这也是为什么,上面的写内存语义中说:将线程对应的本地内存中的共享变量刷新到主内存,而非:将线程对应的本地内存中的volatile变量刷新到主内存
- 写操作之后插入一个
StoreLoad
内存屏障(全能型的),禁止前面的volatile写重排序与后面可能的volatile读/写重排序- 编译器无法判断是否需要在volatile写后面插入内存屏障,JMM采取保守策略:在volatile变量写之后或读之前插入一个
StoreLoad
内存屏障 - 考虑到多读少写的场景更多,最终在volatile变量写之后插入一个
StoreLoad
内存屏障,以提升程序的执行效率
- 编译器无法判断是否需要在volatile写后面插入内存屏障,JMM采取保守策略:在volatile变量写之后或读之前插入一个
volatile读内存语义
- 不是很理解读内存语义,欢迎交流
- 读操作之后,先插入一个
LoadLoad
内存屏障,禁止前面的volatile读操作与后面的普通读操作重排序 - 读操作之后,再插入一个
LoadStore
内存屏障,禁止前面的volatile读操作与后面的普通写操作重排序
内存语义的优化
-
上面的读写内存语义是保守的,具体的代码中还可以对生成的指令序列进行优化,减少不必要的内存屏障
-
优化后的指令序列如下
-
如果是x86处理器,volatile读写操作的指令序列还可以简化如下
- x86处理器只允许写 - 读操作重排序,可以直接省略读- 写、读 - 读, 写 - 写操作对应的内存屏障
- x86处理器只允许写 - 读操作重排序,可以直接省略读- 写、读 - 读, 写 - 写操作对应的内存屏障
4.1.3 JSR-133对volatile内存语义的增强
-
JSR-133之前的旧JMM中,不允许volatile变量之间的重排序,但允许volatile变量与普通变量的重排序
-
结合volatile的示例代码,这样的内存模型将存在一定问题:
- 由于不存在数据依赖,volatile写操作前的普通变量写操作可能被置后
-
线程A执行writer()方法后,线程B执行reader()方法
- 线程B在执行操作4时,无法看到线程A对普通变量的修改
- 但按照程序员的期望,既然都能看到volatile变量的修改,那之前的普通变量的修改也应该可见 😂
-
增强之后的volatile内存语义:
- 根据volatile写操作前的
StoreStore
内存屏障,我们可知:writer()方法中,对共享变量i
的写操作不会被重排序 - 根据volatile读操作后的
LoadLoad
内存屏障,我们可知:reader()方法中,对共享变量i
的读操作不会被重排序
- 根据volatile写操作前的
-
线程A执行writer()方法后,线程B执行reader()方法,执行顺序如下
- 通过程序顺序规则,1 happens before 2, 3 happens before 4
- 根据volatile规则,2 happens before 3
- 根据传递性规则, 1 happens before 4
4.2 锁
4.2.1 锁的内存语义
-
基于synchronized锁的代码如下:假设线程A执行writer()方法,更新共享变量;随后,线程B执行reader()方法
-
线程A释放锁时,JMM会把线程A对应的本地内存中的共享变量刷新到主内存
- 从通信的角度看:线程A向后续的线程发出消息:共享变量已经被修改
-
线程B获取锁时,JMM会把线程B对应的本地内存置为无效;从而,执行reader()方法时,需要从主内存获取共享变量的值
- 从通信的角度看:线程B接收到了线程A修改共享变量的消息
-
线程A释放锁,随后线程B获取锁,实际是线程A通过主内存向线程B发送消息
-
总结:
- 获取锁和释放锁,还隐藏了更新主内存中共享变量的值、获取主内存中共享变量的值
- 对比volatile的内存语义,可知:锁的释放对应volatile变量的写操作,锁的获取对应volatile变量的读操作
示例代码中,锁的happens-before关系
- 根据程序顺序规则, 1 happens before 2, 2 happens before 3, 4 happens before 5,5 happens before 6
- 根据监视器锁规则, 3 happens before 4
- 根据传递性,2 happens before 5
4.2.2 锁内存语义的实现
- 本小节将基于可重入锁ReentrantLock了解锁内存语义的实现
- ReentrantLock基于AQS(AbstractQueuedSynchronizer,同步器)实现,AQS中有一个int类型的volatile变量来维护同步状态
- volatile变量是ReentrantLock内存语义实现的关键
公平锁的获取
-
使用公平锁时,加锁方法lock()的调用轨迹如下
1. ReentrantLock: lock() 2. FairSync: lock() 3. AbstractQueuedSynchronizer: acquire(1) 4. FairSync: tryAcquire(1)
-
第4步,
tryAcquire(int acquires)
是真正实现加锁的方法- 获取锁前,首先读取volatile变量的值
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); // 获取锁时,先读取volatile变量state if (c == 0) { // 成功获取锁,将当前线程设置为锁的拥有者 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { // 通过查收将state更新为1 setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { // 当前线程重复获取锁 int nextc = c + acquires; // state加1 if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); // 直接更新state的值 return true; } return false; } }
公平锁的释放
-
使用公平锁时,解锁方法unlock()的调用轨迹如下
1. ReentrantLock: unlock() 2. AbstractQueuedSynchronizer: release(1) 3. Sync: tryRelease(1)
-
第3步,
tryRelease(int releases)
是实现释放锁的实际方法protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); // 释放锁的最后,写volatile变量state return free; }
-
公平锁,释放锁的最写volatile变量state,获取锁时首先度volatile变量state的值
-
根据volatile变量的happens-before规则,一个线程释放锁后,后续线程获取同一个锁,锁计数器的值立即可见
-
非公平锁的释放,也是一样的调用轨迹
非公平锁的获取
-
非公平锁的释放轨迹如下:
1. ReentrantLock: lock() 2. NonfairSync: lock() 3. AbstractQueuedSynchronizer: compareAndSetState(int expect, int update) 4. 或AbstractQueuedSynchronizer: acquire(int arg) ---> NoFairSync: tryAcquire(int acquires)
-
关键代码如下
- 不管是否是首次获取,最终都将调用AbstractQueuedSynchronizer的compareAndSetState(int expect, int update)方法,原子更新state变量的值
final void lock() { if (compareAndSetState(0, 1)) // 首次获取锁 setExclusiveOwnerThread(Thread.currentThread()); else // 重复获取锁 acquire(1); } protected final boolean compareAndSetState(int expect, int update) { // See below for intrinsics setup to support this return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }
-
CAS操作需要先读volatile变量的值,如果符合预期,再写volatile变量的值
-
CAS操作将同时具有volatile读和volatile写的内存语义
- volatile读:编译器不会将volatile读与volatile读之后的任意内存操作重排序
- volatile写:编译器不会将volatile写与volatile写之前的任意内存操作重排序
- 综合起来,编译器不会将CAS与CAS前后的任意内存操作重排序
- 从而保证非公平锁释放后、再获取,volatile变量state是内存可见的
总结
- 公平锁或非公平锁的释放,最后都需要写volatile变量state
- 公平锁获取,首先需要读volatile变量state;非公平锁的获取,使用CAS更新volatile变量state的值。
- 由于CAS同时具有volatile读和写的内存语义,获取锁时volatile变量state是内存可见的
4.2.3 concurrent包的实现
-
Java concurrent包的通用实现方式;
- 将状态变量定义为volatile共享变量
- 借助volatile的读写内存语义、CAS同时具有volatile读写内存语义的特性,实现线程间的通信:对状态变量的修改是内存可见的
- 同时,借助CAS解决多线程竞争锁的问题,从而实现线程间的同步
-
AQS、非阻塞数据结构、原子变量类,这些concurrent包中的基础类,几乎都使用这种模式实现的
-
同时,concurrent包的高层类又是基于这些基础类实现的。
-
可以说,上述实现方式在concurrent包中是通用的
4.3 final
4.3.1 final域的重排序规则
- 写final域的重排序规则:
- 在构造函数内对一个final域的写入,与随后把被构造对象赋值给一个引用变量,这两个操作之间不能重排序
- 也就是说,对final域的写入不能被重排序到构造函数之外
- 读final域的重排序规则:
- 初次读包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
- 也就是说,初次读final域不能被重排序到初次读对象的引用之前
写final域的重排序规则
- JMM禁止编译器把final域的写重排序到构造函数之外
- 编译器会在写final域、构造函数返回之前,插入一个StoreStore内存屏障,从而禁止处理器把final域的写重排序到构造函数之外
- 代码示例如下
- 线程A执行writer()方法,随后线程B执行reader()
- 写普通域操作被编译器重排序到了构造函数之外,线程B读到是普通域未初始化的值
- 写final域的重排序规则将写final域操作限定在构造函数中,线程B可以正确读取final域初始化的值
- 总结:
- 写final域的重排序规则确保,在对象引用为任意线程可见之前,对象的final域已经正确初始化
读final域的重排序规则
- 由于读包含final域对象的引用、读final域之间存在间接依赖关系,编译器是不会对这两个操作进行重排序的
- 大多数处理器也不会重排序,但少数处理器会对存在间接依赖关系的操作重排序
- 因此,读final域重排序规则针对的是处理器,编译器会在读final域操作前插入
LoadLoad
内存屏障以禁止重排序 - 仍然以上面的代码为例:
- 读普通域操作被重排序到读对象引用之前,读到的普通域未正确初始化
- 读final域被限定在读对象引用之后,读到的final域已经完成了初始化
- 总结:
- 读final域的重排序规则确保,读对象的final域之前,一定会先读该对象的引用
- 只要对象引用不为
null
(成功构造),则对象中的final域一定已经完成了初始化
自己的理解
- 写final域的重排序规则,使得构造函数返回前,对象中的final域已经成功初始化 —— 这是前提
- 读final的重排序规则,使得只要对象引用不为null,读到的对象final域一定成功初始化 —— 这是在该前提下的必然结果
x86处理器对final域重排序规则的支持
- x86处理器不会对写 - 写操作重排序,因此写final域无需添加StoreStore屏障
- x86处理器不会对存在间接依赖的操作重排序,因此读final域无需添加LoadLoad屏障
- 也就是说,x86处理器针对final与的重排序规则天生就支持,无需借助额外的内存屏障指令来实现
4.3.2 final引用不能从构造函数溢出
- 示例代码中,在构造函数返回前,被构造对象的引用可以被其他线程看见
- 被构造对象的引用发生逸出,对象中的final与可能尚未初始化
- 也就是说,final引用从构造函数逸出了,并未按照达到预期(构造函数返回前,final域已经初始化)
- 这使得线程B在访问对象引用时,对象引用虽然不为null,但是对象中的final域尚未初始化
4.3.3 JSR-133对final语义的增强
- 在旧的JMM中,一个线程看到的final域可能是尚未初始化,一段时间内再读final域发现值发生了变化
- 这样的现象根本不符合final域的定义:不可变量
- 增强后的final语义可以保证:只要final引用没有从构造函数中逸出,不需要使用同步就能保证任意线程看到的是final域初始化后的值
5. JMM在单例模式中的应用
5.1 不安全的DCL
-
单例模式的相关知识,可以参考之前的博客:层层递进,实现单例模式
-
针对最简单的饿汉模式,我们提出可以使用懒汉模式:在获取唯一实例时才进行创建
-
原始的懒汉模式是非线程安全的,于是使用
synchronized
修饰全局访问点 -
synchronized实现同步,却导致了性能下降,于是提出使用DCL实现懒汉模式
public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { // 第一重检验,避免不必要的同步 synchronized (Singleton.class) { // 同步锁 if (instance == null) { // 第二重检验,避免重复创建 instance = new Singleton(); } } } return instance; } }
-
仔细分析发现,这样的实现也存在问题,主要是
instance = new Singleton()
对应多个操作
-
这些操作可能被JVM重排序如下
-
其他线程发现instance不为
null
并直接返回后,将会访问到一个尚未初始化的对象
-
这样的重排序没有违反
intra-thread semantics
,在没有改变单线程的执行结果的前提下,可以提高程序的执行性能 -
解决办法:
- 保证2和3不会被重排序,使得其他线程发现对象不为
null
时,对象已经初始化 - 允许2和3重排序,但不允许其他线程看到这个重排序。也就是说,初次访问对象一定是在整个
instance = new Singleton()
执行完之后
- 保证2和3不会被重排序,使得其他线程发现对象不为
lock前缀指令
-
在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。
-
Java代码
instance = new Singleton();
,对应的汇编代码如下:关键在lock前缀指令0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);
-
lock前缀指令相当于一个内存屏障,主要提供3个功能:
- 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面
- 强制将处理器缓存回写到内存
- 如果是缓存回写操作,它会导致其他处理器的缓存行无效
-
这样的话,volatile不仅能禁止指令重排序,还能保证变量的内存可见性
-
参考链接:java内存屏障的原理与应用
5.2 volatile:禁止2和3重排序
- 将instance定义为volatile类型,就可以禁止2和3重排序
- 3是在写volatile变量,根据写volatile的内存语义,会在写操作前添加StoreStore屏障,从而禁止2重排序到3之后
- 这样就可以保证其他线程在判断对象不为
null
时,对象已经初始化
5.3 静态内部类
- 允许2和3重排序,但要保证执行初次访问对象时,2和3都已执行,即对象已成功构建
- 静态内部类刚好可以满足需求
- 静态内部类在第一次有线程访问
getInstance()
方法,执行return LazyHolder.instance;
时才会被加载 —— 满足延迟加载的需求 - instance作为静态内部类的静态成员,在内部类被加载时就已经完成初始化
- 一旦能访问到instance对象,instance对象已就绪
public class Singleton { private Singleton() {} private static class LazyHolder { private static Singleton instance = new Singleton(); } public static Singleton getInstance() { return LazyHolder.instance; } }
- 静态内部类在第一次有线程访问
- 为什么instance对象的构建是线程安全的?
- Java语言规定,每个类或接口,都有一个唯一的初始化锁与之对应,从而类加载的整个过程是线程安全的
- 因此,在类加载中完成初始化的instance也是线程安全的
两种方案的对比
- 静态内部类的实现方案更加简洁,但volatile不仅支持静态变量的初始化,还支持实例变量的初始化
- 需要对实例字段实现线程安全的延迟初始化,可以使用volatile方案
- 需要对静态字段实现线程安全的延迟初始化,可以使用静态内部类方案
6. JMM总结
- JMM这一部分学习了好久,总结起来也挺费劲的
内存可见性
- 所谓内存可见性:一个线程对共享变量的修改,其他线程立即可感知
- 处理器中缓存的引入,带来的缓存一致性问题;JMM的抽象结构,以及本地内存带来的内存可见性问题(处理器和内存看到的执行顺序不一致)
- 理想化的内存模型:顺序一致性内存模型,保证单线程内按程序顺序执行,多线程能看到一个统一的全序、且执行结果立即可见(单开关多灯模型)
- Java程序的内存可见性保证:
- 单线程程序:不会出现内存可见性问题,在执行结果与在顺序一致性内存模型中的执行结果一致的前提下, 可以进行重排序
- 正确同步的多线程程序:执行结果与在顺序一致性内存模型中的执行结果一致,通过限制编译器和处理器的重排序来为程序员提供内存可见性
- 为同步/正确同步的多线程程序:JMM提供最小安全性保障,线程读取到的值,要么是之前线程写入的值,要么是默认值
重排序
- 重排序的分类:编译器重排序、处理器重排序(指令级并行重排序、内存系统重排序)
- 常见处理器允许的重排序:禁止对存在数据依赖的操作重排序,都允许写 - 读操作重排序,x86处理器只允许写 - 读操作重排序
- 如何禁止重排序:编译器重排序规则,编译器插入内存屏障禁止特定类型的处理器重排序
- 四种内存屏障及其含义,开销最昂贵、全能型的内存屏障
StoreLoad
- 操作同一个变量时,数据依赖的三种情况;
as-if-serial
语义,不管怎么重排序单线程程序的执行结果不能被改变
happens-before语义
- 定义:
- 对程序员的承诺:1 happens before 2,则1的操作结果对2内存可见,且1在2之前执行
- 放松对编译器和处理器的约束:在保证执行结果不变的前提下,允许不按照
happens-before
指定的顺序执行
- 与
as-if-serial
语义的对比理解:一个针对正确同步的多线程程序,一个针对单线程程序 - happens-before的几大规则:程序顺序规则、volatile规则、监视器锁规则、线程start规则、线程join规则、线程interrupt规则、对象finalize规则、传递性
volatile、锁、final的内存语义
- volatile
- 内存语义:写volatile,本地内存中的共享变量刷新回主内存;读volatile,本地内存失效,需要从主内存读最新值
- 内存语义的实现:写volatile操作,之前插入StoreStore屏障,之后插入StoreLoad屏障;读volatile操作,之后先插入LoadLoad屏障,再插入LoadStore屏障
- JSR-133对volatile内存语义的增强:禁止volatile变量与普通变量的重排序
- 锁
- 内存语义:释放锁,本地内存中的共享变量刷新回主内存;获取锁,本地内存失败,需要从主内存读最新值;与volatile内存语义的对比(二者具有对应关系)
- 内存语义的实现:
- 基于可重入锁ReentrantLock,关键在AQS中的volatile变量state
- 公平锁和非公平锁释放时,最后都需要写volatile变量state
- 公平锁获取时,首先读volatile变量state
- 非公平锁通过CAS获取锁,同时具有volatile变量读写操作的内存语义
- concurrent包的通用实现模式:
- 将状态变量定义为volatile
- 通过volatile变量读写内存语义、CAS同时具有volatile变量读写内存语义,实现线程通信
- CAS更新状态变量,保证竞争锁时的线程同步
- final
- final域重排序规则:
- 构造函数中,写final域操作不能重排序到构造函数之外。(写操作后,插入StoreStore屏障)
- 读final域之前,需要先读final域所在的对象引用。(读操作前,插入LoadLoad屏障)
- 前者保证对象引用被其他线程可见前,final域已经正确初始化(前提);后者保证,只要对象引用不为
null
,对象中的final域一定已初始化(前提的必然结果)
- x86处理器的特殊性,无需插入任何屏障就能保证final域重排序规则
- final引用从构造函数溢出,将无法保证对象引用不为
null
时,final域已初始化 - JSR-133对final内存语义的增强,之前没有相关的重排序规则
- final域重排序规则:
JMM的在单例模式的应用
- 不安全的DCL存在的问题,如何解决这个问题:禁止对象初始化和引用赋值的重排序 ;允许重排序,但初次访问对象引用,不能提前
- 前者,使用volatile解决:lock前缀指令及其作用
- 后者,使用静态内部类解决:内部类的延迟加载、线程安全的初始化(初始化锁保证)
参考文档:
- 图片参考:谁给解释下java内存模型读volatile域时的语义?
- 重要内容总结(volatile、锁、单例):面试:为了进阿里,重新翻阅了Volatile与Synchronized
- happens-before和内存屏障的关系:漫画:volatile对指令重排的影响
- 从汇编看Volatile的内存屏障
- 短小精悍的Java学习笔记:Java并发 —— 十、Java 内存模型