在 JMM 中,若是一个操做执行的结果须要对另外一个操做可见,那么这两个操做之间必须存在 happens-before 关系。java
happens-before 原则很是重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,咱们解决在并发环境下两操做之间是否可能存在冲突的全部问题。下面咱们就一个简单的例子稍微了解下happens-before ;程序员
i = 1; // 线程 A 执行
j = i; //线程 B 执行
复制代码
j 是否等于 1 呢?编程
假定线程 A 的操做(i = 1) happens-before 线程 B 的操做(j = i),那么能够肯定,线程 B 执行后 j = 1 必定成立。安全
若是他们不存在 happens-before 原则,那么 j = 1 不必定成立。这就是happens-before原则的威力。多线程
happens-before 原则【定义】以下:并发
若是一个操做 happens-before 另外一个操做,那么第一个操做的执行结果,将对第二个操做可见,并且第一个操做的执行顺序,排在第二个操做以前。app
两个操做之间存在 happens-before 关系,并不意味着必定要按照 happens-before 原则制定的顺序来执行。若是重排序以后的执行结果与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法。性能
上面八条是原生 Java 知足 happens-before 关系的规则,可是咱们能够对他们进行推导出其余知足 happens-before 的规则:优化
这里再说一遍 happens-before 的概念:ui
若是两个操做不存在上述(前面8条 + 后面6条)任一一个 happens-before 规则,那么这两个操做就没有顺序的保障,JVM 能够对这两个操做进行重排序。
若是操做 A happens-before 操做 B,那么操做A在内存上所作的操做对操做B都是可见的。
下面就用一个简单的例子,来描述下 happens-before 的原则:
private int i = 0;
public void write(int j ) {
i = j;
}
public int read() {
return i;
}
复制代码
咱们约定线程 A 执行 #write(int j),线程 B 执行 #read(),且线程 A 优先于线程 B 执行,那么线程 B 得到结果是什么?
就这段简单的代码,咱们来基于 happens-before 的规则作一次分析:
因为两个方法是由不一样的线程调用,因此确定不知足程序次序规则。
两个方法都没有使用锁,因此不知足锁定规则。
变量 i 不是用volatile修饰的,因此 volatile 变量规则不知足。
传递规则确定不知足。
规则 五、六、七、8 + 推导的 6 条能够忽略,由于他们和这段代码毫无关系。
因此,咱们没法经过 happens-before 原则,推导出线程 A happens-before 线程 B 。
虽然,能够确认在时间上,线程 A 优先于线程 B 执行,可是就是没法确认线程B得到的结果是什么,因此这段代码不是线程安全的。
复制代码
那么怎么修复这段代码呢?知足规则 二、3 任一便可。
happen-before原则是JMM中很是重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。
咱们知道volatile 的特性:
下面 经过 happens-before 原则和 volatile 的内存语义,两个方向分析 volatile 。
咱们知道happens-before 是用来判断是否存在数据竞争、线程是否安全的主要依据,它保证了多线程环境下的可见性。下面咱们就那个经典的例子,来分析 volatile 变量的读写,如何创建的 happens-before 关系。
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
// Thread A
public void write(){
i = 2; // 1
flag = true; // 2
}
// Thread B
public void read(){
if(flag) { // 3
System.out.println("---i = " + i); // 4
}
}
}
复制代码
依据 happens-before 原则,就上面程序获得以下关系:
程序顺序原则:操做 1 happens-before 操做 2 ,操做 3 happens-before 操做 4 。
volatile 原则:操做 2 happens-before 操做 3 。
for (int j = 0; j < 100; j++) {
System.out.println(13213123);
}
复制代码
那么3会先执行完
传递性原则:操做 1 happens-before 操做 4 。
操做 一、操做 4 存在 happens-before 关系,那么操做 1 必定是对 操做 4 是可见的。
可能有人就会问,操做 一、操做 2 可能会发生重排序啊,会吗?volatile 除了保证可见性外,还有就是禁止重排序。因此 A 线程在写 volatile 变量以前全部可见的共享变量,在线程 B 读同一个 volatile 变量后,将当即变得对线程 B 可见。
因此 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
那么 volatile 的内存语义是如何实现的呢?对于通常的变量则会被重排序,而对于 volatile 的变量则不能。这样会影响其内存语义,因此为了实现 volatile 的内存语义,JMM 会限制重排序。其重排序规则以下:
volatile 的底层实现,是经过插入内存屏障。可是对于编译器来讲,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,因此,JMM 采用了保守策略。
策略以下:
缘由以下:
StoreStore 屏障:保证在 volatile 写以前,其前面的全部普通写操做,都已经刷新到主内存中。
StoreLoad 屏障:避免 volatile 写,与后面可能有的 volatile 读 / 写操做重排序。
LoadLoad 屏障:禁止处理器把上面的 volatile读,与下面的普通读重排序。
LoadStore 屏障:禁止处理器把上面的 volatile读,与下面的普通写重排序。
复制代码
下面咱们就上面 VolatileTest 例子从新分析下:
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write() {
i = 2;
flag = true;
}
public void read() {
if (flag){
System.out.println("---i = " + i);
}
}
}
复制代码
内存屏障图例
volatile 的内存屏障插入策略很是保守,其实在实际中,只要不改变 volatile 写-读的内存语义,编译器能够根据具体状况优化,省略没必要要的屏障。
public class VolatileBarrierExample {
int a = 0;
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写
}
}
复制代码
没有优化的示例图以下:
咱们来分析,上图有哪些内存屏障指令是多余的:
1:这个确定要保留了
2:禁止下面全部的普通写与上面的 volatile 读重排序,可是因为存在第二个 volatile读,那个普通的读根本没法越过第二个 volatile 读。因此能够省略。
3:下面已经不存在普通读了,能够省略。
4:保留
5:保留
6:下面跟着一个 volatile 写,因此能够省略
7:保留
8:保留
因此 二、三、6 能够省略,其示意图以下:
在执行程序时,为了提升性能,处理器和编译器经常会对指令进行重排序,可是不能随意重排序,不是你想怎么排序就怎么排序,它须要知足如下两个条件:
其实这两点能够归结于一点:没法经过 happens-before 原则推导出来的,JMM 容许任意的排序。
as-if-serial 语义的意思是:全部的操做都可觉得了优化而被重排序,可是你必需要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵照 as-if-serial 语义。注意,as-if-serial 只保证单线程环境,多线程环境下无效。
下面咱们用一个简单的示例来讲明:
int a = 1 ; // A
int b = 2 ; // B
int c = a + b; // C
复制代码
A、B、C 三个操做存在以下关系:
A、B 不存在数据依赖关系,
A和C、B和C存在数据依赖关系,
复制代码
所以在进行重排序的时候,A、B 能够随意排序,可是必须位于 C 的前面,执行顺序能够是 A –> B –> C 或者 B –> A –> C 。可是不管是何种执行顺序最终的结果 C 老是等于 3 。
as-if-serail 语义把单线程程序保护起来了,它能够保证在重排序的前提下程序的最终结果始终都是一致的。
其实,对于上段代码,他们存在这样的 happen-before 关系:
A happens-before B
B happens-before C
A happens-before C
复制代码
一、2 是程序顺序次序规则,3 是传递性。可是,不是说经过重排序,B 可能会排在 A 以前执行么,为什么还会存在存在 A happens-before B 呢?这里再次申明 A happens-before B 不是 A 必定会在 B 以前执行,而是 A 的执行结果对 B 可见,可是相对于这个程序 A 的执行结果不须要对 B 可见,且他们重排序后不会影响结果,因此 JMM 不会认为这种重排序非法。
咱们须要明白这点:在不改变程序执行结果的前提下,尽量提升程序的运行效率。
下面咱们在看一段有意思的代码:
public class RecordExample1 {
public static void main(String[] args){
int a = 1;
int b = 2;
try {
a = 3; // A
b = 1 / 0; // B
} catch (Exception e) {
} finally {
System.out.println("a = " + a);
}
}
}
复制代码
按照重排序的规则,操做 A 与操做 B 有可能会进行重排序,若是重排序了,B 会抛出异常( / by zero),此时A语句必定会执行不到,那么 a 还会等于 3 么?
若是按照 as-if-serial 原则它就改变了程序的结果。
其实,JVM 对异常作了一种特殊的处理,为了保证 as-if-serial 语义,Java 异常处理机制对重排序作了一种特殊的处理:JIT 在重排序时,会在catch 语句中插入错误代偿代码(a = 3),这样作虽然会致使 catch 里面的逻辑变得复杂,可是 JIT 优化原则是:尽量地优化程序正常运行下的逻辑,哪怕以 catch 块逻辑变得复杂为代价。
在单线程环境下,因为 as-if-serial 语义,重排序没法影响最终的结果,可是对于多线程环境呢?
以下代码(volatile的经典用法):
public class RecordExample2 {
int a = 0;
boolean flag = false;
/** * A线程执行 */
public void writer() {
a = 1; // 1
flag = true; // 2
}
/** * B线程执行 */
public void read(){
if (flag) { // 3
int i = a + a; // 4
}
}
}
复制代码
A 线程先执行 #writer(),线程 B 后执行 #read(),线程 B 在执行时可否读到 a = 1 呢?
答案是不必定(注:x86 CPU 不支持写写重排序,若是是在 x86 上面操做,这个必定会是 a = 1 )。
因为操做 1 和操做 2 之间没有数据依赖性,因此能够进行重排序处理。 操做 3 和操做 4 之间也没有数据依赖性,他们亦能够进行重排序,可是操做 3 和操做 4 之间存在控制依赖性。
假如操做1 和操做2 之间重排序:
按照这种执行顺序线程 B 确定读不到线程 A 设置的 a 值,在这里多线程的语义就已经被重排序破坏了。
实际上,操做 3 和操做 4 之间也能够重排序,虽然他们之间存在一个控制依赖的关系,只有操做 3 成立操做 4 才会执行。
当代码中存在控制依赖性时,会影响指令序列的执行的并行度,因此编译器和处理器会采用猜想执行来克服控制依赖对并行度的影响。
假如操做 3 和操做 4 重排序了,操做 4 先执行,则先会把计算结果临时保存到重排序缓冲中,当操做 3 为真时,才会将计算结果写入变量 i 中。
经过上面的分析,重排序不会影响单线程环境的执行结果,可是会破坏多线程的执行语义。
DCL ,即 Double Check Lock ,中文称为“双重检查锁定”。
其实 DCL 不少人在单例模式中用过,可是有不少人都会写错。他们为何会写错呢?其错误根源在哪里?有什么解决方案?下面就一块儿来分析。
咱们先看单例模式里面的懒汉式:
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
复制代码
咱们都知道这种写法是错误的,由于它没法保证线程的安全性。优化以下:
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
复制代码
优化很是简单,就是在 #getInstance() 方法上面作了同步,可是 synchronized 就会致使这个方法比较低效,致使程序性能降低,那么怎么解决呢?聪明的人们想到了双重检查 DCL:
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance(){
if(singleton == null){ // 1
synchronized (Singleton.class){ // 2
if(singleton == null){ // 3
singleton = new Singleton(); // 4
}
}
}
return singleton;
}
}
复制代码
就如上面所示,这个代码看起来很完美,理由以下:
经过上面的分析,DCL 看起确实是很是完美,可是能够明确地告诉你,这个错误的。上面的逻辑确实是没有问题,分析也对,可是就是有问题,那么问题出在哪里呢?在回答这个问题以前,咱们先来复习一下建立对象过程,实例化一个对象要分为三个步骤:
memory = allocate(); //1:分配内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:将内存空间的地址赋值给对应的引用
复制代码
可是因为重排序的缘由,步骤 二、3 可能会发生重排序,其过程以下:
memory = allocate(); // 1:分配内存空间
instance = memory; // 3:将内存空间的地址赋值给对应的引用
// 注意,此时对象尚未被初始化!
ctorInstance(memory); // 2:初始化对象
复制代码
若是 二、3 发生了重排序,就会致使第二个判断会出错,singleton != null,可是它其实仅仅只是一个地址而已,此时对象尚未被初始化,因此 return 的 singleton 对象是一个没有被初始化的对象,以下:
按照上面图例所示,线程 B 访问的是一个没有被初始化的 singleton 对象。
知道问题根源所在,那么怎么解决呢?有两个解决办法:
不容许初始化阶段步骤 二、3 发生重排序。
容许初始化阶段步骤 二、3 发生重排序,可是不容许其余线程“看到”这个重排序。
复制代码
解决方案依据上面两个解决办法便可。
对于上面的DCL其实只须要作一点点修改便可:将变量singleton生命为volatile便可:
public class Singleton {
// 经过volatile关键字来确保安全
private volatile static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){
synchronized (Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
复制代码
当 singleton 声明为 volatile后,步骤 二、3 就不会被重排序了,也就能够解决上面那问题了。
该解决方案的根本就在于:利用 ClassLoder 的机制,保证初始化 instance 时只有一个线程。JVM 在类初始化阶段会获取一个锁,这个锁能够同步多个线程对同一个类的初始化。
public class Singleton {
private static class SingletonHolder{
public static Singleton singleton = new Singleton();
}
public static Singleton getInstance(){
return SingletonHolder.singleton;
}
}
复制代码
Java 语言规定,对于每个类或者接口 C ,都有一个惟一的初始化锁 LC 与之相对应。从C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化阶段期间会获取这个初始化锁,而且每个线程至少获取一次锁来确保这个类已经被初始化过了。
延迟初始化下降了初始化类或建立实例的开销,但增长了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。
若是确实须要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于 volatile 的延迟初始化的方案。 若是确实须要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。
通过上面的讨论,如今对 JMM 作一个比较简单的总结。
JMM 规定了线程的工做内存和主内存的交互关系,以及线程之间的可见性和程序的执行顺序。
一方面,要为程序员提供足够强的内存可见性保证。
另外一方面,对编译器和处理器的限制要尽量地放松。JMM 对程序员屏蔽了 CPU 以及 OS 内存的使用问题,可以使程序在不一样的 CPU 和 OS 内存上都可以达到预期的效果。
Java 采用内存共享的模式来实现线程之间的通讯。编译器和处理器能够对程序进行重排序优化处理,可是须要遵照一些规则,不能随意重排序。
在并发编程模式中,势必会遇到上面三个概念:
原子性:一个操做或者多个操做要么所有执行要么所有不执行。
可见性:当多个线程同时访问一个共享变量时,若是其中某个线程更改了该共享变量,其余线程应该能够马上看到这个改变。
有序性:程序的执行要按照代码的前后顺序执行。
复制代码
JMM 对原子性并无提供确切的解决方案,可是 JMM 解决了可见性和有序性,至于原子性则须要经过锁或者 synchronized 来解决了。
若是一个操做 A 的操做结果须要对操做 B 可见,那么咱们就认为操做 A 和操做 B 之间存在happens-before 关系,即 A happens-before B 。
happens-before 原则,是 JMM 中很是重要的一个原则,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,咱们能够解决在并发环境下两个操做之间是否存在冲突的全部问题。JMM 规定,两个操做存在 happens-before 关系并不必定要 A 操做先于B 操做执行,只要 A 操做的结果对 B 操做可见便可。
在程序运行过程当中,为了执行的效率,编译器和处理器是能够对程序进行必定的重排序,可是他们必需要知足两个条件:
执行的结果保持不变
存在数据依赖的不能重排序。重排序是引发多线程不安全的一个重要因素。
复制代码
同时,顺序一致性是一个比较理想化的参考模型,它为咱们提供了强大而又有力的内存可见性保证,他主要有两个特征:
一个线程中的全部操做必须按照程序的顺序来执行。
全部线程都只能看到一个单一的操做执行顺序,在顺序一致性模型中,每一个操做都必须原则执行且马上对全部线程可见。复制代码