要玩转 happens-before 我们需要先简单介绍下几个基本概念
随着 CPU 的快速发展它的计算速度和内存的读写速度差距越来越大,如果还是去读写内存的话那么 CPU 的处理速度就会收到内存读写速度的限制,为了弥补这种差距,为了保证 CPU 的快速处理就出现了高速缓存。
高速缓存特点是读写速度快,容量小,照价昂贵。
随着 CPU 的快速发展,所依赖的高速缓存的读写速度也在不断提升,为了满足更高的要求就发展出了工艺更好也更加快速的缓存,它的照价也更加昂贵。
对于 CPU 来说按照读写速度和紧密程度来说依次分为 L1(一级缓存)、L2(二级缓存)、L3(三级缓存)他们之间的处理速度依次递减,对于现代的计算机来说至少会存在一个 L1 缓存。
Java 线程之间的通信是由 Java 内存模型(JMM)来控制的,JMM 定义了多个线程之间的共享变量存储在主内存中,每个线程私有的数据则存储在线程的本地内存当中,本地内存中又存储了多线程共享变量在主内存中的副本(本地内存是一个虚拟的概念并不存在,指的是缓存区,寄存器等概念)。抽象模型图如下:
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 的规则如下
根据这个规则我们就能够保证线程之间的内存可见性,后面会详细分析,这里先将定义放出来
上面说了 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 读写这些屏障并不一定非得全部按照要求插入,编译器会进行优化发现不需要插入的时候就不会去插入内存屏障,但是它能够保证和我们这种插入屏障方式得到一样的正确的结果,这里就不展开了。
对于加锁了的代码块或者方法来说,他们是互斥执行的,一个线程释放了锁,另外一个线程获得了这个锁之后才能执行。
它有着和 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 从设计上来说并没有这么做。具体怎么做的我们之前已经经过详细的分析了。
而 JMM 对为同步的多线程最了最小化安全性,即线程看到的数据要么是默认值,要么是其它线程写入的值。
最后其实还有 final 的内存语义和 final 带来的内存可见性问题 由于篇幅太长了后面单独写。
每次看 <<Java 并发编程的艺术>> 都有不一样的感触,这次结合自己的思考写篇文章加深下自己的理解。
参考: