目录
1、Java内存模型基础
1.1、并发编程的两个关键问题
1.1.1、线程之间如何通信
- 两种通信机制,共享内存和消息传递,Java内存模型中使用共享内存
1.1.2、线程之间如何同步
- 共享内存通信机制中,线程同步是显式的,程序员要显式指定
- 消息传递通信机制中,线程同步是隐式的,消息的发送必须在消息的接受之前
1.2、Java内存模型抽象结构
- 线程A和B要通信的话,需要以下两个步骤
- 线程A将本地内存中更新过的共享变量刷新到主存中
- 线程B到主存中读取线程A刷新到主存中的变量
- 经过这两个步骤,实质上实现了线程A到线程B的通信
1.3、指令重排序
- 指令重排序分为以下三种:
- 编译器优化的重排序。在不改变单线程程序语义下,重新安排语句顺序(编译器级别)
- 指令级并行的重排序。如果数据不存在依赖,那么改变语句对应的一组机器指令的执行顺序(处理器级别)
- 内存系统的重排序。处理器内部使用缓存机制(缓存其实就是用内存来做的,有读写缓存),把数据都暂时存在缓存中,然后再操作(处理器级别)
- Java源码到最终执行,有三种重排序:
1.4、内存屏障类型
屏障类型 | 指令示例 | 说明 |
LoadLoad Barriers | Load1;LoadLoad;Load2 | 确保Load1数据的装载先于Load2及其后续装载指令的装载(就是先装载Load1的数据) |
StoreStore Barriers | Store1;StoreStore;Store2 | 确保Store1数据对其他处理器可见(刷新到主存)先于Store2及其所有后续存储指令的存储(就是Store的要存完了才到后面的) |
LoadStore Barriers | Load1;LoadStore ;Store2 | 确保Load1数据装载先于Store2及其所有后续的存储指令刷新到主存(就Load1先加载了,后面在储存) |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 确保Store1数据对其他处理器变得可见(指刷新到主存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers会是该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令 |
1.5、happens-before简介
- 在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么两个操作之间必须存在happens-before关系
- happens-before并不意味这前一个操作一定要在后一个操作之前执行。仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
- 与程序员相关的happens-before规则如下:(这些是规则,具体尊不遵守,看具体的模型)
- 程序顺序规则:一个线程中的每个操作,happens-before与该线程中的任意后续操作(就是一个线程中的每一个操作,对于其后续的操作来说都是可见的)
- 监视器锁规则:对一个锁的解锁,happens-before与随后对这个锁的加锁(就是解锁操作,对于其后续的加锁操作是可见的,这样才知道了已经解锁了,可以加锁啊)
- volatile变量规则:对一个volatile域的写,happens-before与任意后续对这个volatile域的读(就是写操作,对于其后续的读操作是可见的,这样volatile才能实现写可见性)
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
2、重排序
2.1、数据依赖性
- 两个操作访问同一个变量,只要其中一个为写操作,那么就存在数据依赖性,分为以下三种
- 写后读:a=1; b=a;
- 写后写:a=1; a=2;
- 读后写:a=b; b=1;
- 存在数据依赖性的操作,一旦重排,结果改变
2.2、as-if-serial
- as-if-serial的语义是:不管怎么重排序,单线程的执行结果不能被改变,其实就是保护单线程
- 编译器、runtime、处理器都必须遵守as-if-serial协议
- 比如计算圆的面积,pi=3.14; r=1.0; area=pi*r*r; 其中pi=3.14和r=1.0就可重排
2.3、重排序的影响
- 在单线程中,对存在控制依赖的操作重排序,不会改变执行结果(满足as-if-serial)
- 在多线程中,对存在控制依赖的的操作重排序,可能会改变执行结果
- 举例:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if(flag) { //3
int i = a*a; //4
}
}
}
- 执行效果1:线程A中1和2没有数据依赖,可以重排序,但是这样子操作之后,执行错误了,因为a还没有赋值就被线程B拿来使用了。这样破坏了多线程程序语义。
- 执行效果2:线程B中,操作3和4存在控制依赖关系,当存在依赖关系时,编译器和处理器会使用猜测机制克服依赖对并发的影响。在例子中,先猜测计算值,用temp临时变量存储,当条件为真时将temp值赋给i。这样其实也破坏了多线程程序语义。
3、顺序一致性模型
- 顺序一致性模型仅是一个理论参考模型,在设计时,处理器内存模型和编程语言的内存模型都会参考顺序一致性模型
3.1、顺序一致性模型两大特征
- 一个线程中的所有操作必须按照程序的顺序来进行(线程内部就不能指令重排序了)
- (不管是否同步)所有线程都只能看到一个单一的操作执行顺序
3.2、举例
- 线程A执行顺序:A1 -> A2 -> A3
- 线程B执行顺序:B1 -> B2 -> B3
- 执行效果1:操作的执行整体上有序,且两个线程都只能看到A1 -> A2 -> A3 -> B1 -> B2 -> B3这个执行顺序
- 执行效果2:操作的执行整体上无序,但两个线程都只能看到B1 -> A1 -> A2 -> B2 -> A3 -> B3这个执行顺序
3.3、同步程序的顺序一致性效果
- 顺序一致性模型中,所有的操作完全按照程序的顺序串行执行
- JMM中,临界区内的代码可以重排序,但是不允许临界区代码“逸出”到临界区之外
- JMM实现顺序一致性效果的原则是:在不改变程序执行结果的情况下,尽可能地为编译器和处理器的优化打开方便之门
3.4、未同步程序的执行特性
- 对于未同步的程序,JMM仅提供最小安全性:程序执行时读取到的值,要么是之前某个线程写入的值(主存),要么是默认值(0/null/false)
- JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行效果一致
- 未同步程序在JMM和顺序一致性模型中的三个差异:
- 顺序一致性模型保证单线程内操作按照程序顺序执行,JMM不能保证(因为要优化)
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,JMM不能保证
- 顺序一致性模型保证对64位的long和double类型变量的读/写操作具有原子性,JMM不保证64位写有原子性(但是读可以)(与总线工作机制有关)
4、volatile内存语义
4.1、volatile变量特性
- 可见性:对一个volatile变量的读,总能看到(任意线程)对这个volatile变量最后的写入(就是强迫写之后马上刷新到主存,每次读之前要到主存中获取最新值)
- 原子性:对任意单个volatile变量的读/写操作都有原子性,但是复合操作没有,比如i++
- 其实还有一个,就是指令不重排序
4.2、volatile与happens-before
- 举例说明
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if(flag) { //3
int i = a; //4
}
}
}
- 根据程序顺序规则,有:1 -> 2;3 -> 4;
- 1 -> 2,是因为volatile变量写操作前插入StoreStore,必须a写入后才到flag
- 3 -> 4,是因为volatile变量读操作后插入LoadLoad,必须flag读取到以后才能读a
- 根据volatile规则,有:2 -> 3;
- 根据传递性规则,有:1 -> 4;
4.3、volatile的读写内存语义
- 从内存语义的角度来看,volatile的读-写与锁的释放-获取的内存语义是一样的
- volatile的写和锁的释放语义一样
- volatile的读和锁的获取语义一样
- 写:写一个volatile变量时,JMM会将写线程对应的本地内存中的共享变量值刷新到主存中,就是写完同步到主存
- 读:当读一个volatile变量时,JMM会将该线程对应的本地内存置为无效,然后从主存中读取变量值到本地内存
- 总结:
- 线程A写一个volatile变量,实质上就是线程A向接下来要读这个volatile变量的线程发出了消息(发出已修改变量的消息)
- 线程B读一个volatile变量,实质上就是线程B接收之前写这个volatile变量的线程发送的消息
- 实际上就是线程A像线程B发送消息,写就是发,读就是收
4.4、volatile内存语义的实现
4.4.1、volatile重排序规则
第一个操作 | 第二个操作 | ||
| 普通读/写 | volatile读 | volatile写 |
普通读/写 |
|
| NO |
volatile读 | NO | NO | NO |
volatile写 |
| NO | NO |
- 第三行NO解释:当第一个操作是普通读写,第二个操作是volatile写,那么不能重排序这两个操作
- 表格解释:
- 当第二个操作是volatile写的时候,不管第一个操作是什么,都不能重排序(volatile写前无重排)
- 当第一个操作是volatile读的时候,无论第二个操作是什么,都不能重排序(volatile读后无重排)
- 当第一个操作是volatile写,第二个操作是volatile读的时候,不能重排序
4.4.2、内存屏障策略
- JMM采取保守内存屏障策略
- 在每个volatile写操作前插入StoreStore屏障(该写操作前的Store不能重排)
- 在每个volatile写操作后插入StoreLoad屏障(该写操作后的Load不能重排)
- 在每个volatile读操作后插入LoadLoad屏障(该读操作后的Load不能重排)
- 在每个volatile读操作后插入LoadStore屏障(该读操作后的Store不能重排)
- 写操作屏障示意图
- 虽说StoreStore是防止上面普通写,但是这样加进去之后,什么写都防止了,这就是基于保守策略,因为制定精准最优化策略的几乎不可能
- 读操作屏障示意图
4.5、JSR-133
- JSR-133之前,不允许volatile变量之间重排序,但是允许volatile和普通变量重排序
- JSR-133开始,增强volatile内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的读-写和锁的读-写有一样的内存语义
5、锁的内存语义
5.1、锁与happens-before
- 举例说明,线程A先写,线程B后读
class MonitorExample{
int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void read() { // 4
int i = a; // 5
//6, 余下操作
}
}
- 程序顺序规则:1 -> 2 -> 3;4 -> 5 -> 6;
- 监视器锁规则:3 -> 4;
- 传递性规则:2 -> 5;
5.2、锁的释放和获取的内存语义
- 锁释放获取的内存语义和volatile读写是一样的
- 释放锁:JMM会将线程本地内存中的共享变量刷新到主存中(和volatile的写是一样的)
- 获取锁:JMM会将线程本地内存置为无效,然后去主存中获取共享变量(和volatile的读是一样的)
5.3、锁的内存语义的实现
5.3.1、公平锁加锁lock
- 公平锁加锁lock轨迹:
- ReentrantLock: lock()
- FairSync: lock()
- AbstractQueuedSynchronizer: acquire(int arg)
- ReentrantLock: tryAcquire(int acquires)
- 第四步中的ReentrantLock: tryAcquire(int acquires)才是真正的加锁,首先读volatile变量state
5.3.2、公平锁解锁unlock
- 公平锁解锁unlock
- ReentrantLock: unlock()
- AbstractQueuedSyschronizer: release(int arg)
- Sync: tryRelease(int releases)
- 第三步中的Sync: tryRelease(int releases)真正开始释放锁,最后写volatile变量state
5.3.3、非公平锁加锁lock
- 非公平锁加锁lock轨迹
- ReentrantLock: lock()
- NonfairSync: lock()
- AbstractQueuedSynchronizer: compareAndSetState(int expect, int update)
- 第三步开始真正加锁,使用CAS机制修改state值,CAS底层由native方法保证,
5.3.4、总结
- 公平锁和非公平锁在释放时,最后都要写一个volatile变量state
- 公平锁获取时,首先会去读volatile变量
- 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和写的内存语义
6、final域的内存语义
6.1、final域的重排序规则
- 编译器和处理器都要遵守这两个规则
- 构造函数内对一个final域的写入,与随后将这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序(要先给对象的final属性赋值,才能给将将对象引用传出去)
- 初次读一个final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序(要先读到对象引用,才能读到对象中的final属性)
6.2、写final域的重排序规则
- JMM禁止编译器将final域的重写排序到构造函数之外(其实说的就是6.1中的1,要先给对象的final属性赋值,才能给将将对象引用传出去)
- 编译器会在final域写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器将final域的写重排序到构造函数之外
- 举例:
class FinalExample {
int i;//普通变量
final int j; //final变量
static FinalExample obj;
public FinalExample() { //构造函数
i = 1; //写普通域
j = 2; //写final域
}
public static void writer() { //写线程A执行
obj = new FinalExample();
}
public static void reader() { //读线程B执行
FinalExample object = obj; //读对象引用
int a = object.i; //读普通域
int b = object.j; //读final域
}
}
- 在以上执行时序图中,虚线部分表示是错误的。假设线程A将普通域的写操作放到构造器之外,那么线程B就有可能在普通域没有写入的时候去读取对象的普通域,这会造成错误
- 在以上执行时序图中,实线部分表示是正确的。写final域的操作,被限定在构造器中,那么线程B就一定能正确读到final域
- 所以写final域的重排序规则可以保证:在对象引用为任意线程可见之前,对象的final域已经正确初始化(就是final域一定初始化了,然后才能使用对象)
6.3、读final域的重排序规则
- 在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(其实说的就是6.1中的2,要先读到对象引用,才能读到对象中的final属性)
- 举例:代码同6.3
- 上图中,读对象的普通域的操作被处理器重排序到读对象引用之前,执行普通域读操作时,该域还没有被线程A写入,这是一个错误的读取操作
- 上图中,读对象的final域的重排序规则限定读final域操作要在读对象引用之后(加入LoadLoad屏障),这就确保了在读一个final域之前,一定会先读包含这个final域的对象的引用。
6.4、final域为引用类型
- 对于引用类型的final域,对编译器和处理器增加如下约束:在构造器函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造函数对象的引用赋值给一个引用变量,这两个操作之前不能重排序(就是final域为一个引用变量,比如数组,对这个数组的写入,与随后将构造的对象赋值,这两个操作不能重排序)
- 举例:
class FinalReferenceExample {
final int[] intArray; //final是引用类型
static FinalReferenceExample obj;
public FinalReferenceExample() { //构造函数
intArray = new int[1]; //1
intArray[0] = 1; //2
}
public static void writerOne() { //写线程A执行
obj = new FinalReferenceExample(); //3
}
public static void writerTwo() { //写线程B执行
obj.intArray[0] = 2; //4
}
public static void reader() { // 读线程C执行
if(obj != null) { //5
int temp1 = obj.intArray[0]; //6
}
}
}
- 以上代码中,假设线程A首先执行writerOne()方法,执行完毕后线程B执行writerTwo()方法,执行完毕后线程C执行reader()方法。执行时序图如下:
- 上图中,JMM至少能保证,读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入,但是不保证写线程B的写入。也就是说仅保证构造器中的赋值可见,如果要保证写线程B的写操作可见,那么需要使用同步原语
6.5、为什么final引用不能从构造函数中“溢出”
- 也是为什么在JSR-133中要增强final内存语义的原因
- 举例:
class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample() {
i = 1; //1. 写final域
obj = this; //2. this引用在此逸出
}
public static void writer() {
new FinalReferenceEscapeExample();
}
public static void reader() {
if(obj != null) { //3
int temp = obj.i; //4
}
}
}
- 上面的执行时序图中,线程A执行writer()方法,线程B执行reader()方法。
- 线程A中的操作2是构造器的最后一步,但是重排序在final域初始化之前,那么执行read方法时,就有可能出现错误。所以要保证final引用不能从构造函数内逸出
7、happens-before
7.1、JMM的设计
7.1.1、设计的两个关键性因素
- 程序员角度:希望内存模型易于理解、易于编程,希望使用强内存模型
- 编译器和处理器角度:希望模型对其限制束缚越少越好,这样它们可以做更多的优化,希望实现一个弱内存模型
7.1.2、重排序分类及其策略
- 分为两类:
- 会改变程序执行结果的重排序
- 不会改变程序执行结果的重排序
- JMM策略:
- 对于改变执行结果的重排序,JMM要求编译器和处理器禁止
- 对于不会改变执行结果的重排序,JMM不作要求,也就是允许重排序
7.2、happens-before定义及其规则
7.2.1、定义
- 定义为如下:
- 如果第一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前(对程序员的承诺)
- 两个操作之前存在happens-before关系,并不意味Java平台一定要按照happens-before规则制定的顺序的执行,如果重排序后的结果不改变,那么这种重排序也是可以的(对编译器和处理器重排序的约束原则)
- happens-before与as-if-serial
- 本质上,happens-before和as-if-serial是一回事
- as-if-serial保证单线程程序执行结果不改变,happens-before保证多线程程序执行结果不改变
- as-if-serial给程序员幻境:单线程程序按照程序的顺序执行;happens-before给程序员幻境:同步正确的多线程程序按照happens-before指定的顺序来执行
7.2.2、规则(JSR-133)
(这些是规则,具体尊不遵守,看具体的模型)
- 程序顺序规则:一个线程中的每个操作,happens-before与该线程中的任意后续操作(就是一个线程中的每一个操作,对于其后续的操作来说都是可见的)
- 监视器锁规则:对一个锁的解锁,happens-before与随后对这个锁的加锁(就是解锁操作,对于其后续的加锁操作是可见的,这样才知道了已经解锁了,可以加锁啊)
- volatile变量规则:对一个volatile域的写,happens-before与任意后续对这个volatile域的读(就是写操作,对于其后续的读操作是可见的,这样volatile才能实现写可见性)
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
- start()规则:线程A中操作ThreadB.start(),也就是在线程A中启动线程B,那么A线程的ThreadB.start()操作happens-before与线程B的任意操作(就是ThreadB.start()这个操作对于线程B中的操作是可见的,不然线程B怎么做操作呢,肯定是知道了线程开启了才进行操作呀)
- join()规则:线程A中操作ThreadB.join(),也就是线程A等待线程B执行完毕并成功返回后再往下执行,那么线程B中的任意操作happens-before与线程A从ThreadB.join()操作成功返回(就是线程B中的操作,对线程A后续的操作都是可见的,谁让B先执行了呢)
8、单例模式双重检查与延迟初始化
8.1、双重检查锁定问题
8.1.1、非安全单次检查
- 代码中有可能线程B在执行2,但是尚未执行完,此时线程A就可能进入执行2
class UnsafeLazyInitialization{
private static Instance instance;
public static Instance getInstance() {
if(instance == null) { //1.线程A执行
instance = new Instance(); //2.线程B执行
}
return instance;
}
}
8.1.2、安全单次检查
- 在getInstance()方法中加入synchronized关键字修饰,这样虽然安全了,但是存在性能下降了
class SafeLazyInitialization{
private static Instance instance;
public synchronized static Instance getInstance() {
if(instance == null) {
instance = new Instance();
}
return instance;
}
}
8.1.3、双重检查
- 在synchronized锁前后加上两次null检查,但是其实还是存在安全问题
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.问题的根源
}
}
}
return instance;
}
}
- 问题的根源在于,instance = new Instance();是通过三个步骤实现的,这三个步骤中的第二个和第三个可以重排序(颠倒)
- 重排序前:
1. memory = allocate(),分配对象内存空间
2. ctorInstance(memory),在内存空间中初始化对象
3. instance=memory,设置instance指向刚分配的内存空间
- 重排序后:
1. memory = allocate(),分配对象内存空间
3. instance=memory,设置instance指向刚分配的内存空间
2. ctorInstance(memory),在内存空间中初始化对象
- 并发执行情况(出现问题)
- 问题分析:线程A重排序2和3后,先执行3,于是在没有初始化对象前,instance已经指向内存空间了。当线程B第一次去判断instance时,不为空,那么直接返回instance,后续线程B引用instance实例,但是instance实例极有可能未完成初始化,于是就出了问题
8.2、volatile解决单例双重检查问题
- volatile关键字,会禁止相关属性的指令重排序
- 使用volatile关键字修饰instance属性即可
class DoubleCheckedLocking { //1
private static volatile Instance instance; //2
public static Instance getInstance() { //3
if(instance == null) { //4.第一次检查
synchronized (DoubleCheckedLocking.class) { //5.加锁
if(instance == null) { //6.第二次检查
instance = new Instance(); //7.问题的根源
}
}
}
return instance;
}
}
8.3、基于类初始化解决单例双重检查问题
- 在执行类的初始化期间(不是new,而是new三个步骤中的第二个),JVM会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化(这是由JVM实现的)
- 基于以上特性,可以实现线程安全的延迟初始化方案(Initalization On Demand Holder idiom)
class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance;
}
}
- 代码中,首次执行Instance()方法时,会导致初始化静态InstanceHolder类,初始化InstanceHolder类时由JVM实现同步
- 更多类初始化细节TODO
9、Java内存模型总结
- 顺序一致性模型是一个理论参考模型,JMM和处理器内存模型在设计时都会参考
9.1、处理器内存模型
- Total Store Ordering内存模型:放松程序中写-读操作的顺序
- Partial Store Order内存模型:在Total Store Ordering的基础上,继续放松程序中写-写操作的顺序
- Relaxed Memory Order内存模型:在Partial Store Order的基础上,继续放松程序中读-写和读-读操作的顺序
9.2、JMM的内存可见性保证
- 单线程程序中,编译器、runtime和处理器会共同保证与顺序一致性模型执行结果一致
- 正确同步的多线程程序中,JMM通过限制编译器和处理器重排序来为程序员提供内存可见性保证,保证与顺序一致性模型执行结果一致
- 未同步/未正确同步的多线程程序中,JMM提供最小安全性保障,线程读到的值,要么是之前某个线程写入的值,要么是默认值(0/null/false)
9.3、JSR-133对旧内存的修补
- 增强volatile的内存语义,旧内存模型中允许volatile变量与普通变量重排序;JSR-133中严格限制,使得volatile的读-写与锁的释放-获取具有一样的内存语义
- 增强final的内存语义,就内存模型中多次读取同一个final变量可能会不同(赋值前后);JSR-133中为final增加两个重排序规则,保证final引用不会从构造函数逸出,使得final具有初始化安全性