上一篇文章并发 Bug 之源有三,请睁大眼睛看清它们 谈到了可见性/原子性/有序性
三个问题,这些问题一般违背咱们的直觉和思考模式,也就致使了不少并发 Bugjava
擅自
优化 ( Java代码在编译后会变成 Java 字节码, 字节码被类加载器加载到 JVM 里, JVM 执行字节码, 最终须要转化为汇编指令在 CPU 上执行) ,致使有序性问题初衷是好的,但引起了新问题,最有效的办法就禁止缓存和编译优化,问题虽然能解决,但「又回到最初的起点,呆呆地站在镜子前」是很尴尬的,咱们程序的性能就堪忧了.面试
俗话说:「没有什么事是开会解决不了的,若是有,那就再开一次」😂shell
JSR-133 的专家们就有了新想法,既然不能彻底禁止缓存和编译优化,那就按需禁用缓存和编译优化,按需就是要加一些约束,约束中就包括了上一篇文章简单提到过的 volatile,synchronized,final 三个关键字,同时还有你可能听过的 Happens-Before 原则(包含可见性和有序性的约束),Happens-before 规则也是本章的主要内容编程
为了知足两者的强烈需求,照顾到双方的情绪,因而乎: JMM 就对程序猿说了一个善意的谎话: 「会严格遵照 Happpen-Befores 规则,不会重排序」让程序猿放心,私下却有本身的策略:缓存
咱们来用个图说明一下:markdown
这就是那个善意的谎话,虽是谎话,但仍是照顾到了程序猿的利益,因此咱们只须要了解 happens-before 规则就能获得保证 (图画了很久,不知道是否说明了谎话的所在😅,欢迎留言)多线程
Happens-before 规则主要用来约束两个操做,两个操做之间具备 happens-before 关系, 并不意味着前一个操做必需要在后一个操做以前执行,happens-before 仅仅要求前一个操做(执行的结果)对后一个操做可见, (the first is visible to and ordered before the second)并发
说了这么多,先来看一小段代码带你逐步走进 Happen-Befores 原则,看看是怎样用该原则解决 可见性 和 有序性 的问题:app
class ReorderExample {
int x = 0;
boolean flag = false;
public void writer() {
x = 42; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
System.out.println(x); //4
}
}
}
复制代码
假设 A 线程执行 writer 方法,B 线程执行 reader 方法,打印出来的 x 可能会是 0,上一篇文章说明过: 由于代码 1 和 2 没有数据依赖关系,因此可能被重排序工具
flag = true; //2
x = 42; //1
复制代码
因此,线程 A 将 flag = true
写入但没有为 x 从新赋值时,线程 B 可能就已经打印了 x 是 0
那么为 flag 加上 volatile 关键字试一下:
volatile boolean flag = false;
复制代码
即使加上了 volatile 关键字,这个问题在 java1.5 以前仍是没有解决,但 java1.5 和其以后的版本对 volatile 语义作了加强,问题得以解决,这就离不开 Happens-before 规则的约束了,总共有 6 个规则,且看
一个线程中的每一个操做, happens-before 于该线程中的任意后续操做 第一感受这个原则是一个在理想状态下的"废话",而且和上面提到的会出现重排序的状况是矛盾的,注意这里是一个线程中的操做,其实隐含了「as-if-serial」语义: 说白了就是只要执行结果不被改变,不管怎么"排序",都是对的
这个规则是一个基础规则,happens-before 是多线程的规则,因此要和其余规则约束在一块儿才能体现出它的顺序性,别着急,继续向下看
对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读
我将上面的程序添加两行代码做说明:
public class ReorderExample {
private int x = 0;
private int y = 1;
private volatile boolean flag = false;
public void writer(){
x = 42; //1
y = 50; //2
flag = true; //3
}
public void reader(){
if (flag){ //4
System.out.println("x:" + x); //5
System.out.println("y:" + y); //6
}
}
}
复制代码
这里涉及到了 volatile 的内存加强语义,先来看个表格:
可否重排序 | 第二个操做 | 第二个操做 | 第二个操做 |
---|---|---|---|
第一个操做 | 普通读/写 | volatile 读 | volatile 写 |
普通读/写 | - | - | NO |
volatile 读 | NO | NO | NO |
volatile 写 | - | NO | NO |
从这个表格 最后一列 能够看出:
若是第二个操做为 volatile 写,无论第一个操做是什么,都不能重排序,这就确保了 volatile 写以前的操做不会被重排序到 volatile 写以后 拿上面的代码来讲,代码 1 和 2 不会被重排序到代码 3 的后面,但代码 1 和 2 可能被重排序 (没有依赖也不会影响到执行结果),说到这里和 程序顺序性规则是否是就已经关联起来了呢?
从这个表格的 倒数第二行 能够看出:
若是第一个操做为 volatile 读,无论第二个操做是什么,都不能重排序,这确保了 volatile 读以后的操做不会被重排序到 volatile 读以前 拿上面的代码来讲,代码 4 是读取 volatile 变量,代码 5 和 6 不会被重排序到代码 4 以前
volatile 内存语义的实现是应用到了 「内存屏障」,由于这彻底够单独写一章的内容,这里为了避免掩盖主角 Happens-before 的光环,保持理解 Happens-before 的连续性,先不作过多说明
到这里,看这个规则,貌似也没解决啥问题,由于它还要联合第三个规则才起做用
若是 A happens-before B, 且 B happens-before C, 那么 A happens-before C 直接上图说明一下上面的例子
从上图能够看出
x =42
和 y = 50
Happens-before flag = true
, 这是规则 1flag=true
Happens-before 读变量(代码 4) if(flag)
,这是规则 2根据规则 3传递性规则,x =42
Happens-before 读变量 if(flag)
谜案要揭晓了: 若是线程 B 读到了 flag 是 true,那么
x =42
和y = 50
对线程 B 就必定可见了,这就是 Java1.5 的加强 (以前版本是能够普通变量写和 volatile 变量写的重排序的)
一般上面三个规则是一种联合约束,到这里你懂了吗?规则还没完,继续看
对一个锁的解锁 happens-before 于随后对这个锁的加锁
这个规则我以为你应该最熟悉了,就是解释 synchronized 关键字的,来看
public class SynchronizedExample {
private int x = 0;
public void synBlock(){
// 1.加锁
synchronized (SynchronizedExample.class){
x = 1; // 对x赋值
}
// 3.解锁
}
// 1.加锁
public synchronized void synMethod(){
x = 2; // 对x赋值
}
// 3. 解锁
}
复制代码
先获取锁的线程,对 x 赋值以后释放锁,另一个再获取锁,必定能看到对 x 赋值的改动,就是这么简单,请小伙伴用下面命令查看上面程序,看同步块和同步方法被转换成汇编指令有何不一样?
javap -c -v SynchronizedExample
复制代码
这和 synchronized 的语义相关,小伙伴能够先自行了解一下,锁的内容时会作详细说明
若是线程 A 执行操做 ThreadB.start() (启动线程B), 那么 A 线程的 ThreadB.start() 操做 happens-before 于线程 B 中的任意操做,也就是说,主线程 A 启动子线程 B 后,子线程 B 能看到主线程在启动子线程 B 前的操做,看个程序就秒懂了
public class StartExample {
private int x = 0;
private int y = 1;
private boolean flag = false;
public static void main(String[] args) throws InterruptedException {
StartExample startExample = new StartExample();
Thread thread1 = new Thread(startExample::writer, "线程1");
startExample.x = 10;
startExample.y = 20;
startExample.flag = true;
thread1.start();
System.out.println("主线程结束");
}
public void writer(){
System.out.println("x:" + x );
System.out.println("y:" + y );
System.out.println("flag:" + flag );
}
}
复制代码
运行结果:
主线程结束
x:10
y:20
flag:true
Process finished with exit code 0
复制代码
线程 1 看到了主线程调用 thread1.start() 以前的全部赋值结果,这里没有打印「主线程结束」,你知道为何吗?这个守护线程知识有关系
若是线程 A 执行操做 ThreadB.join() 并成功返回, 那么线程 B 中的任意操做 happens-before 于线程 A 从 ThreadB.join() 操做成功返回,和 start 规则恰好相反,主线程 A 等待子线程 B 完成,当子线程 B 完成后,主线程可以看到子线程 B 的赋值操做,将程序作个小改动,你也会秒懂的
public class JoinExample {
private int x = 0;
private int y = 1;
private boolean flag = false;
public static void main(String[] args) throws InterruptedException {
JoinExample joinExample = new JoinExample();
Thread thread1 = new Thread(joinExample::writer, "线程1");
thread1.start();
thread1.join();
System.out.println("x:" + joinExample.x );
System.out.println("y:" + joinExample.y );
System.out.println("flag:" + joinExample.flag );
System.out.println("主线程结束");
}
public void writer(){
this.x = 100;
this.y = 200;
this.flag = true;
}
}
复制代码
运行结果:
x:100
y:200
flag:true
主线程结束
Process finished with exit code 0
复制代码
「主线程结束」这几个字打印出来喽,依旧和线程什么时候退出有关系
写-读
与锁的释放-获取
有相同的内存效果;volatile 写和锁的释放有相同的内存语义; volatile 读与锁的获取有相同的内存语义,⚠️⚠️⚠️(敲黑板了) volatile 解决的是可见性问题,synchronized 解决的是原子性问题,这绝对不是一回事,后续文章也会说明本文的好多表格是从官网粘贴的,如何将其直接转换成 MD table 呢?那么 www.tablesgenerator.com/markdown_ta… 就能够帮到你了,不管是生成 MD table,仍是粘贴内容生成 table 和内容都是极好的,固然了不止 MD table,本身发现吧,更多工具,公众号回复 「工具」得到
欢迎持续关注公众号:「日拱一兵」
- 前沿 Java 技术干货分享
- 高效工具汇总 | 回复「工具」
- 面试问题分析与解答
- 技术资料领取 | 回复「资料」
以读侦探小说思惟轻松趣味学习 Java 技术栈相关知识,本着将复杂问题简单化,抽象问题具体化和图形化原则逐步分解技术问题,技术持续更新,请持续关注......