要玩转 happens-before 咱们须要先简单介绍下几个基本概念java
随着 CPU 的快速发展它的计算速度和内存的读写速度差距愈来愈大,若是仍是去读写内存的话那么 CPU 的处理速度就会收到内存读写速度的限制,为了弥补这种差距,为了保证 CPU 的快速处理就出现了高速缓存。程序员
高速缓存特色是读写速度快,容量小,照价昂贵。编程
随着 CPU 的快速发展,所依赖的高速缓存的读写速度也在不断提高,为了知足更高的要求就发展出了工艺更好也更加快速的缓存,它的照价也更加昂贵。缓存
对于 CPU 来讲按照读写速度和紧密程度来讲依次分为 L1(一级缓存)、L2(二级缓存)、L3(三级缓存)他们之间的处理速度依次递减,对于现代的计算机来讲至少会存在一个 L1 缓存。安全
Java 线程之间的通讯是由 Java 内存模型(JMM)来控制的,JMM 定义了多个线程之间的共享变量存储在主内存中,每一个线程私有的数据则存储在线程的本地内存当中,本地内存中又存储了多线程共享变量在主内存中的副本(本地内存是一个虚拟的概念并不存在,指的是缓存区,寄存器等概念)。抽象模型图以下:bash
happens-before 的概念最初是由 Leslie Lamport 在一篇影响深远的论文 (《Time,Clocks and thhe Ordering of Events in a Distributed System》)中提出。它用 happens-before 来描述分布式系统中事件的偏序关系。多线程
从 JDK5 开始,Java 使用 JSR-133 内存模型,JSR-133 使用了 happens-before 的概念来为单线程或者多线程提供内存可见性保证。架构
happens-before 为程序员提供了多线程之间的内存可见性并发
happens-before 的规则以下app
根据这个规则咱们就可以保证线程之间的内存可见性,后面会详细分析,这里先将定义放出来
上面说了 happens-before 主要是为单线程或者多线程提供内存可见性保证,那么内存可见性又是什么呢,咱们先看下下面的定义
堆内存是线程之间共享的,栈内存是线程私有的。堆内存中的数据存在内存可见性问题,栈内存不受内存可见性影响。
内存可见性:其实就是一种多线程可以看到的共享内存的数据状态,这个状态有多是正确的也有多是错误的(固然咱们的目的就是为了保证内存可见性正确)。
下面咱们来分析说明下何时会出现内存可见性问题(也就是在什么状况下,不正确的内存可见性状态会致使多线程程序访问错误)
咱们知道每一个 CPU 都有本身的高速缓存,那么在有多个 CPU 的计算机上,读写一个数据的时候,由于处理器会往高速缓存中写数据(对应的就是 JMM 中的线程私有内存),而高速缓存不会立马刷到内存中(JMM 抽象模型中的主内存),这样就会形成多个 CPU 之间的读写数据不一致,以下
class Test {
int val = 0;
void f() {
val = val + 1;
// ...
}
}
复制代码
上图只是其中一种可能出错的状态,也有多是正确的,多线程未同步就存在不肯定性
能够看到程序员本意是使用 2 个线程对 val 分别执行 + 1 操做,想要获得的结果 val = 2 结果程序运行完毕获得的结果是 val = 1
咱们先来看下什么是指令重排序
void f() {
int a = 1;
int b = 2;
int c = a + b;
}
复制代码
通过编译器或者处理器重排序后,执行的顺序可能变为先执行 b = 2
后执行 a = 1
而 C 是不可能排在上面 2 步以前的,下面会说明。
指令重排序又分为编译器指令重排序、处理器指令重排序。
编译器和处理器为了提升指令运行的并行度而进行指令重排序,它们的目的都是为了加速程序的运行速度,可是不管怎么重排序都必须保证单线程最终的执行结果不能改变,可是若是是在多线程状况下就没法保证了,因此就有可能出现执行结果不正确的状况。
为了保证单线程程序最终的正确性,有一点能够肯定的是若是操做之间存在依赖性,那么不管是编译器仍是处理器都不容许对其进行重排序,这一点如今的编译器和处理都是实现了的。以下
void f() {
int a = 1;
// 这个操做依赖上一步操做 a = 1,因此他们不会被重排序
int b = a + 1;
}
复制代码
那么指令重排序又是如何致使了内存可见性问题的呢?咱们来看一个例子
class Test {
private static Instance instance;
public static Instance getInstance() {
if (instance != null) {
synchronized(Test.class) {
if (instance != null) {
// 错在这里
instance = new Instance();
}
}
}
return instance;
}
}
复制代码
这是一个常见双重检查锁定的单列模型(错误的),它错就错在指令重排序可能致使返回未被初始化的 instance,咱们来分析下为何。
instance = new Instance(); 在处理器执行的时候实际上是拆解为了几步执行的,伪代码以下
// 步骤1 分配内存空间
memory = allocate();
// 步骤2 初始化对象
ctorInstance(memory);
// 步骤3 设置对象的内存地址
instance = memory;
复制代码
咱们能够看到上面这 3 步骤在单线程的场景下对于步骤 2 和步骤 3这两部是没有依赖性的,咱们能够先设置了它的地址再给他初始化对象内容也能够,因此可能会指令重排序以下:
// 步骤1 分配内存空间
memory = allocate();
// 步骤2 设置对象的内存地址
instance = memory;
// 步骤3 初始化对象
ctorInstance(memory);
复制代码
那么在多线程场景下,线程 A 执行到了步骤 2(尚未初始化),而且正好将工做内存刷新到了主内存中,那么线程 B 就看到了 instance,认为已经建立初始化完毕,就直接 return 了,就致使线程 B 可能拿到的是未被初始化的对象,那么后续使用的时候就会出现问题。
正是因为这些缘由致使了内存可见性问题,在多线程的场景下可能会出现意外的状况,咱们要正确获得正确的多线程程序执行的结果,那么咱们就要保证内存可见性的正确性。
内存可见性的正确性保证主要是经过如下一些技术来实现的
当写一个 volatile 变量的时候,JMM 会把线程对应的本地内存中的共享变量值刷新到主内存中去。
volatile 两大特性
JMM 经过限制 volatile 读/写的重排序,针对编译器制定了以下 volatile 重排序规则
是否能重排序 | 第二个操做 | ||
---|---|---|---|
第一个操做 | 普通读 / 写 | volatile 读 | volatile 写 |
普通读 / 写 | NO | ||
volatile 读 | NO | NO | NO |
volatile 写 | NO | NO |
从表能够总结出:
看完这几个规则脑子是否是有点晕,那是由于不知道为何要这么作,咱们先从一个方面去思考。
就是当写一个 volatile 变量的时候,会把线程对应的本地内存变量值刷新到内存中去,意味着若是 volatile 写以前有一个或者多个操做也写了共享变量,那么这个时候会将以前全部修改的共享变量所有刷新到主内存中去,这个特性是否是感受特别重要!
看完后面的内容再来看这个表格就能沉底够理解为何要这么作了。
咱们如今再来看一下以前个单例错误的例子,是因为指令重排序致使的,可是咱们把程序作以下更改就能够保证正确了
class Test {
private static volatile Instance instance;
public static Instance getInstance() {
if (instance != null) {
synchronized(Test.class) {
if (instance != null) {
// 错在这里
instance = new Instance();
}
}
}
return instance;
}
}
复制代码
能够看到加了个 volatile,加了它以后就可以保证下面这段带啊不能被重排序的了,意识就是只能以步骤 1 - > 2 - > 3 的顺序执行了,也就保证了这个单列模型的正确性了。
// 步骤1 分配内存空间
memory = allocate();
// 步骤2 初始化对象
ctorInstance(memory);
// 步骤3 设置对象的内存地址
instance = memory;
复制代码
那么编译器是如何实现这个规则的呢,也就说编译器是用什么技术实现的这样的重排序规则,来限制 volatile 的重排序的呢。
编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,因为插入最优屏障策略过于繁琐几乎难以作到,因此 JMM 采起保守策略插入内存屏障以下
内存屏障解释以下
基于这个策略这个策略,就能够保证在任意处理器平台,任意程序都能获得正确的 volaile 内存语义了,看下图是 volatile 写的场景
StoreStore 可以保证上面全部的普通写在 volatile 写以前刷新到主内存中。
StoreLoad 若是上面这个 volatile 在方法末尾,它就很难确认调用它的方法是否有 volatile 读或者写因此,若是在方法末尾或者 volatile 写后面真的有 volatile 读写这两种状况下都会插入 StoreLoad 屏障。
总结个记忆方法:
下面是 volatile 读的场景
而后咱们来看个代码用 volatile 和 happen-before 规则来分析一下
class Test {
int num = 10;
boolean volatile flag = false;
void writer() {
num = 100; // 1
flag = true; // 2
}
void reader() {
if (flag) { // 3
System.out.println(num); // 4
}
}
}
复制代码
假设有线程 A 执行完了 writer 方法后,线程 B 执行去执行 reader 方法。(忘了规则的上面翻一下)
最后强调一下就是,关于这些 volatile 读写这些屏障并不必定非得所有按照要求插入,编译器会进行优化发现不须要插入的时候就不会去插入内存屏障,可是它可以保证和咱们这种插入屏障方式获得同样的正确的结果。
好比咱们最经常使用的服务 Linux_x86 架构下它禁止了大量的重排序,它只会在 volatile 写后面插入一个 StoreLoad 屏障,而这个屏障就能保证 volatile 写读语义,它会保证在这屏障以前写入缓存的数据所有刷入主存再执行后续的指令
对于加锁了的代码块或者方法来讲,他们是互斥执行的,一个线程释放了锁,另一个线程得到了这个锁以后才能执行。
它有着和 volatile 类似的内存语义
当线程释放锁的时候会把该线程对应的本地内存共享变量刷新到主内存中去。
当线程获取锁的时候,JMM 会把当前线程对应的本地内存置位无效,从而使得被监视器保护的临界区的代码必须重新从主内存中获取共享变量。
咱们来看一段代码
int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public void synchronized reader() { // 4
int i = a; // 5
} // 6
复制代码
假设线程 A 执行了 writer() 方法后线程 B 执行了 reader() 方法,继续用 happens-before 来分析下
顺序一致性模型,JMM,在设计的时候就参考了顺序一致性模型。
咱们来看下顺序一致性模型的定义
第一点和 JMM 中的差异相信能很容易看出来,JMM 中是容许指令重排序的,他们的执行顺序有可能改变,只不过最终的获得的结果是一致的。
对于未同步的程序来讲在顺序一致性模型中是这样的
而 JMM 对为同步的多线程最了最小化安全性,即线程看到的数据要么是默认值,要么是其它线程写入的值。
最后其实还有 final 的内存语义和 final 带来的内存可见性问题 因为篇幅太长了后面单独写。
每次看 <<Java 并发编程的艺术>> 都有不同的感触,此次结合本身的思考写篇文章加深下本身的理解。
参考: