https://www.cnblogs.com/jinshuai86/p/9226164.html
Java编程的逻辑
Java并发编程的艺术
极客时间:Java并发编程实战
3 Java内存模型
本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。
具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则
Happens-Before为语义增强
3.1 基础
3.1.1 并发编程模型的两个模型和两个关键问题
线程之间如何通信及线程之间如何同步:
1.通信是指线程之间以何种机制来交换信息
在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递(两个模型)。
2.同步是指程序中用于控制不同线程间操作发生相对顺序的机制
在共享内存并发模型里,同步是显式进行的,程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java采用共享内存模型
3.1.2 Java内存模型(JMM)的抽象结构
线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本;
本地内存是JMM的一个抽象概念,并不真实存在。
它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
如果线程A与线程B之间要通信的话,必须要经历下面2个步骤来保证内存可见性:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去
2)线程B到主内存中去读取线程A之前已更新过的共享变量
线程结束后,会将本地内存写到主内存
锁也是:锁释放与volatile写有相同的内存语义(JMM会把该线程对应的本地内存中的共享变量刷新到主内存中);锁获取与volatile读有相同的内存语义(JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
3.1.3 重排序
含义:编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
1属于编译器重排序,2和3属于处理器重排序
这些重排序可能会导致多线程程序出现内存可见性问题
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止);
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM在此处的作用:确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
通过volatile标记,可以解决编译器层面的可见性与重排序问题。而内存屏障则解决了硬件层面的可见性与重排序问题
内存屏障有三种类型和一种伪类型:
a、lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
b、sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见。
c、mfence,即全能屏障,具备ifence和sfence的能力。
d、Lock前缀:Lock不是一种内存屏障,但是它能完成类似全能型内存屏障的功能。
https://blog.csdn.net/lsunwing/article/details/83154208
https://www.jianshu.com/p/64240319ed60
3.1.3.1 数据依赖性
含义:
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
仅针对单个处理器中执行的指令序列和单个线程中执行的操作;
不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑(可能出错,请看3.1.3.4);
类型:
问题:
只要重排序两个操作的执行顺序,程序的执行结果就会被改变
解决:
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
3.1.3.2 as-if-serial语义
含义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
编译器、runtime和处理器都必须遵守as-if-serial语义;
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序
3.1.3.3 程序顺序规则
如有两个操作A、B:
int a = 0;//A
int b = 1;//B
操作A的执行结果不需要对操作B可见,且重排序操作A和操作B后的执行结果,与操作A和操作B按happens-before顺序执行的结果一致。
在这种情况下,JMM会认为这种重排序并不非法(notillegal),JMM允许这种重排序。
3.1.3.4 重排序对多线程的影响
如下案例,操作1和4之间有数据依赖性:
假设线程A执行:
a = 1;//1
flag = true;//2
线程B执行:
if(flag){//3
int i = a * a;//4
}
-
当操作1、2重排序时可能出现的情况
程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入;
在这里多线程程序的语义被重排序破坏了(int i = 0 * 0,而不是1 * 1) -
当操作3、4重排序时可能出现的情况
操作3和操作4存在控制依赖关系。
当代码中存在控制依赖性时,会影响指令序列执行的并行度。
为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。
以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。
可以看出,猜测执行实质上对操作3和4做了重排序。
重排序在这里破坏了多线程程序的语义(temp = 0 * 0,而不是1 * 1)
3.1.4 写缓冲区和内存屏障
写缓冲区特性:每个处理器上的写缓冲区,仅仅对它所在的处理器可见
这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致
例子:
如果得到的结果为x=y=0,则原因如下:
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。
虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。
此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样)
不同的处理器允许的重排序类型不同;
常见的处理器都允许Store-Load重排序、都不允许对存在数据依赖的操作做重排序
解决方案:使用内存屏障
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序
四种类型和StoreLoad Barriers:
3.1.5 happens-before
- JMM的关系
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关
系。
两个操作既可以是在一个线程之内,也可以是在不同线程之间
一个happens-before规则对应于一个或多个编译器和处理器重排序规则
它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法
符合规则的话,底层使用禁止重排序,即内存屏障,保证本地内存与主内存相同
- 规则
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。
7)线程中断规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
8)对象终结规则:一个对象的初始化完成(构造函数执行结束)happens-before于它的finalize()方法的开始。
- 注意
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行;
它仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second,第二点不一定要求,不改变结果的话,可以重排序来进行优化);
例子见3.1.3.3
happens-before可以理解为保证正确同步的多线程程序的的as-if-serial;
两者目的:都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
3.2 顺序一致性
- 顺序一致性内存模型是一个理论参考模型
在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照;
其有两大特点:
1)一个线程中的所有操作必须按照程序的顺序来执行
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序,且每个操作都必须原子执行且立刻对所有线程可见
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作;
从上图可以看出,在任意时间点最多只能有一个线程可以连接到内存。
当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,
所有操作之间具有全序关系)。
例子:
有两个线程A、B,A执行顺序为A1→A2→A3,B执行顺序为B1→B2→B3;
使用synchronized进行同步,先A后B:
不进行同步:
- 在JMM中
没有保证每个操作必须立即对任意线程可见。
未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。
例子:
比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;
从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。
只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。
在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。
JMM对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同
JMM在具体实现上的基本方针为:
在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:
线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False);
JMM保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来。
为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了
JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性,此差异与处理器总线的工作机制密切相关:
3.3 volatile的内存语义实现
编译器不会对volatile读与volatile读后面的任意内存操作重排序;
编译器不会对volatile写与volatile写前面的任意内存操作重排序;
组合这两个条件,意味着为了同时实现volatile读和volatile写的内存语义,编译器不能对CAS与CAS前面和后面的任意内存操作重排序:
程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀;
如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg);
反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致
性,不需要lock前缀提供的内存屏障效果)
lock前缀作用参考“并发基础”的lock#
例子:
写:
读:
在不影响内存语义的情况下,可以省略部分屏障,如:
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
...
}
… // 其他方法
}
有些处理器不支持某些重排序,则可以省略相应的屏障
3.4 锁的内存语义
参考3.3的cas描述
锁释放-获取的内存语义的实现至少有下面两种方式:
1)利用volatile变量的写-读所具有的内存语义。
2)利用CAS所附带的volatile读和volatile写的内存语义。
Java线程通信的四种方式:
1)A线程写volatile变量,随后B线程读这个volatile变量。
2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。
concurrent通用化实现模式:
首先,声明共享变量为volatile;
然后,使用CAS的原子条件更新来实现线程之间的同步(volatile还是可能会出错,因为保证了内存可见性并不保证原子性:如果有线程A、B,业务是A先B后,但是两个线程却同时执行了写操作,当其中A在拿到主内存中的原值进行修改时,B也已经开始拿到主内存中的原值去修改,这种情况下会出错,所以使用CAS,则B发现内存是原值,而不是期望的A修改过后的值,会自旋);
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信;
3.5 final域的内存定义
不可变类三要素:类final、属性final、方法皆为只读
对于final域,编译器和处理器要遵守两个重排序规则:
1)写:在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(先写入final变量,后调用该对象引用)
2)读:初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。(先读对象的引用,后读final变量)
案例代码:线程A执行writer()方法,随后另一个线程B执行reader()方法
public class FinalExample {
int i; // 普通变量
final int j; // final变量
static FinalExample obj;
public FinalExample () { // 构造函数
i = 1; // 写普通域
j = 2; // 写final域(1)的1)
}
public static void writer () { // 写线程A执行
obj = new FinalExample ();//(1)的2)
}
public static void reader () { // 读线程B执行
FinalExample object = obj; // 读对象引用(2)的1)
int a = object.i; // 读普通域
int b = object.j; // 读final域(2)的2)
}
}
3.5.1 写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外
这个规则的实现包含下面2个方面:
1)JMM禁止编译器把final域的写重排序到构造函数之外。
2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。
这个屏障禁止处理器把final域的写重排序到构造函数之外。
此时,final的写会正确(即),而普通域的写则不一定,如:
假设线程B读对象引用与读对象的成员域之间没有重排序,如下图是一种可能的执行时序,即普通域被重排序,导致线程B的i读取错误:
3.5.2 读final域的重排序规则
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)
编译器会在读final域操作的前面插入一个LoadLoad屏障
这两个操作存在间接依赖关系
此时,final的第会正确,而普通域的读则不一定
假设写线程A没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行;如下图是一种可能的执行时序,即普通域被重排序,导致线程B的i读取错误:
3.5.3 final域为引用类型
对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
案例代码:
public 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()方法
如图是一种可能的线程执行时序:
1、3和2、3不能重排序
JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。
即C至少能看到数组下标0的值为1。
而写线程B对数组元素的写入,读线程C可能看得到,也可能看不到。
JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知。
如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性。
3.5.4 final引用不能从构造函数内溢出的原因
为了确保在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了:
1.需要写final域的重排序规则
2.在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”
3.6 双重检查锁定与延迟初始化
延迟初始化目的:降低初始化类和创建对象的开销
手段:双重检查锁定,但是这是一个错误的用法
3.6.1 错误的根源
public 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:问题的根源出在这里
} // 8
} // 9
return instance; // 10
} // 11
}
第七行可以分解如下:
memory = allocate(); // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory; // 3:设置instance指向刚分配的内存地址
2和3之间可能会被重排序
单线程下,不会发生错误:
多线程下,可能会发生错误:B线程将看到一个其实还没初始化的对象
3.6.2 正确的线程安全的方案
1.方法上加sync
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}
2.解决3.6.1的方式1:不允许2、3重排序
基于volatile的解决方案:把instance声明为volatile型即可(只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止)
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;
public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance(); // instance为volatile,现在没问题了
}
}
return instance;
}
}
禁止了重排序后,时序图如下:
3.解决3.6.2的方式2:允许2、3重排序,不允许其他线程“看到”这个重排序
基于类初始化的解决方案:JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化(执行这个类的静态初始化和初始化在这个类中声明的静态字段)。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化
在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化:
1)T是一个类,而且一个T类型的实例被创建。
2)T是一个类,且T中声明的一个静态方法被调用。
3)T中声明的一个静态字段被赋值。
4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
5)T是一个顶级类,而且一个断言语句嵌套在T内部被执行。
public class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ; // 符合情况4),将导致InstanceHolder类被初始化
}
}
假设两个线程并发执行getInstance()方法,其时序图为:
总结:
基于volatile的双重检查锁定的方案除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化;
基于类初始化的方案的实现代码更简洁;
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销;
在大多数时候,正常的初始化要优于延迟初始化;
如果需要对实例字段使用线程安全的延迟初始化,使用基于volatile的的方案;
如果需要对静态字段使用线程安全的延迟初始化,使用基于类初始化的方案;
3.7 总结
按程序类型,Java程序的内存可见性保证可以分为下列3类:
·单线程程序:单线程程序不会出现内存可见性问题。编译器、runtime和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
·正确同步的多线程程序:正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。
这是JMM关注的重点,JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
·未同步/未正确同步的多线程程序:JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
JSR-133对JDK 5之前的旧内存模型的修补主要有两个:
·增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。
·增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。在保证final引用不会从构造函数内逸出的情况下,final具有了初始化安全性。