好久没更新文章了,对隔三差五过来刷更新的读者说声抱歉。java
关于 Java 并发也算是写了好几篇文章了,本文将介绍一些比较基础的内容,注意,阅读本文须要必定的并发基础。程序员
本文的主要目的是让你们对于并发程序中的重排序、内存可见性以及原子性有必定的了解,同时要能准确理解 synchronized、volatile、final 几个关键字的做用。面试
另外,本文还对双重检查形式的单例模式为何须要使用 volatile 作了深刻的解释。编程
并发三问题缓存
3.synchronized 关键字
4.单例模式中的双重检查性能优化
6.final 关键字
7.小结架构
这节将介绍重排序、内存可见性以及原子性相关的知识,这些也是并发程序为何难写的缘由。并发
请读者先在本身的电脑上运行一下如下程序:app
public class Test { private static int x = 0, y = 0; private static int a = 0, b =0; public static void main(String[] args) throws InterruptedException { int i = 0; for(;;) { i++; x = 0; y = 0; a = 0; b = 0; CountDownLatch latch = new CountDownLatch(1); Thread one = new Thread(() -> { try { latch.await(); } catch (InterruptedException e) { } a = 1; x = b; }); Thread other = new Thread(() -> { try { latch.await(); } catch (InterruptedException e) { } b = 1; y = a; }); one.start();other.start(); latch.countDown(); one.join();other.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if(x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } } }
几秒后,咱们就能够获得 x == 0 && y == 0 这个结果,仔细看看代码就会知道,若是不发生重排序的话,这个结果是不可能出现的。编程语言
重排序由如下几种机制引发:
编译器优化:对于没有数据依赖关系的操做,编译器在编译的过程当中会进行必定程度的重排。
你们仔细看看线程 1 中的代码,编译器是能够将 a = 1 和 x = b 换一下顺序的,由于它们之间没有数据依赖关系,同理,线程 2 也同样,那就不可贵到 x == y == 0 这种结果了。
指令重排序:CPU 优化行为,也是会对不存在数据依赖关系的指令进行必定程度的重排。
这个和编译器优化差很少,就算编译器不发生重排,CPU 也能够对指令进行重排,这个就不用多说了。
内存系统重排序:内存系统没有重排序,可是因为有缓存的存在,使得程序总体上会表现出乱序的行为。
假设不发生编译器重排和指令重排,线程 1 修改了 a 的值,可是修改之后,a 的值可能尚未写回到主存中,那么线程 2 获得 a == 0 就是很天然的事了。同理,线程 2 对于 b 的赋值操做也可能没有及时刷新到主存中。
前面在说重排序的时候,也说到了内存可见性的问题,这里再啰嗦一下。
线程间的对于共享变量的可见性问题不是直接由多核引发的,而是由多缓存引发的。若是每一个核心共享同一个缓存,那么也就不存在内存可见性问题了。
现代多核 CPU 中每一个核心拥有本身的一级缓存或一级缓存加上二级缓存等,问题就发生在每一个核心的独占缓存上。每一个核心都会将本身须要的数据读到独占缓存中,数据修改后也是写入到缓存中,而后等待刷入到主存中。因此会致使有些核心读取的值是一个过时的值。
Java 做为高级语言,屏蔽了这些底层细节,用 JMM 定义了一套读写内存数据的规范,虽然咱们再也不须要关心一级缓存和二级缓存的问题,可是,JMM 抽象了主内存和本地内存的概念。
全部的共享变量存在于主内存中,每一个线程有本身的本地内存,线程读写共享数据也是经过本地内存交换的,因此可见性问题依然是存在的。这里说的本地内存并非真的是一块给每一个线程分配的内存,而是 JMM 的一个抽象,是对于寄存器、一级缓存、二级缓存等的抽象。
在本文中,原子性不是重点,它将做为并发编程中须要考虑的一部分进行介绍。
说到原子性的时候,你们应该都能想到 long 和 double,它们的值须要占用 64 位的内存空间,Java 编程语言规范中提到,对于 64 位的值的写入,能够分为两个 32 位的操做进行写入。原本一个总体的赋值操做,被拆分为低 32 位赋值和高 32 位赋值两个操做,中间若是发生了其余线程对于这个值的读操做,必然就会读到一个奇怪的值。
这个时候咱们要使用 volatile 关键字进行控制了,JMM 规定了对于 volatile long 和 volatile double,JVM 须要保证写入操做的原子性。
另外,对于引用的读写操做始终是原子的,无论是 32 位的机器仍是 64 位的机器。
Java 编程语言规范一样提到,鼓励 JVM 的开发者能保证 64 位值操做的原子性,也鼓励使用者尽可能使用 volatile 或使用正确的同步方式。关键词是”鼓励“。
在 64 位的 JVM 中,不加 volatile 也是能够的,一样能保证对于 long 和 double 写操做的原子性。关于这一点,我没有找到官方的材料描述它,若是读者有相关的信息,但愿能够给我反馈一下。
并发问题使得咱们的代码有可能会产生各类各样的执行结果,显然这是咱们不能接受的,因此 Java 编程语言规范须要规定一些基本规则,JVM 实现者会在这些规则的约束下来实现 JVM,而后开发者也要按照规则来写代码,这样写出来的并发代码咱们才能准确预测执行结果。下面进行一些简单的介绍。
Java 语言规范对于同步定义了一系列的规则:17.4.4. Synchronization Order,包括了以下同步关系:
对于监视器 m 的解锁与全部后续操做对于 m 的加锁同步
对 volatile 变量 v 的写入,与全部其余线程后续对 v 的读同步
启动线程的操做与线程中的第一个操做同步。
对于每一个属性写入默认值(0, false,null)与每一个线程对其进行的操做同步。
尽管在建立对象完成以前对对象属性写入默认值有点奇怪,但从概念上来讲,每一个对象都是在程序启动时用默认值初始化来建立的。
线程 T1 的最后操做与线程 T2 发现线程 T1 已经结束同步。
线程 T2 能够经过 T1.isAlive() 或 T1.join() 方法来判断 T1 是否已经终结。
若是线程 T1 中断了 T2,那么线程 T1 的中断操做与其余全部线程发现 T2 被中断了同步(经过抛出 InterruptedException 异常,或者调用 Thread.interrupted 或 Thread.isInterrupted )
两个操做能够用 happens-before 来肯定它们的执行顺序,若是一个操做 happens-before 于另外一个操做,那么咱们说第一个操做对于第二个操做是可见的。
若是咱们分别有操做 x 和操做 y,咱们写成 hb(x, y) 来表示 x happens-before y。如下几个规则也是来自于 Java 8 语言规范:
若是操做 x 和操做 y 是同一个线程的两个操做,而且在代码上操做 x 先于操做 y 出现,那么有 hb(x, y)
对象构造方法的最后一行指令 happens-before 于 finalize() 方法的第一行指令。
若是操做 x 与随后的操做 y 构成同步,那么 hb(x, y)。这条说的是前面一小节的内容。
hb(x, y) 和 hb(y, z),那么能够推断出 hb(x, z)
这里再提一点,x happens-before y,并非说 x 操做必定要在 y 操做以前被执行,而是说 x 的执行结果对于 y 是可见的,只要知足可见性,发生了重排序也是能够的。
monitor,这里翻译成监视器锁,为了你们理解方便。
synchronized 这个关键字你们都用得不少了,这里不会教你怎么使用它,咱们来看看它对于内存可见性的影响。
一个线程在获取到监视器锁之后才能进入 synchronized 控制的代码块,一旦进入代码块,首先,该线程对于共享变量的缓存就会失效,所以 synchronized 代码块中对于共享变量的读取须要从主内存中从新获取,也就能获取到最新的值。
退出代码块的时候的,会将该线程写缓冲区中的数据刷到主内存中,因此在 synchronized 代码块以前或 synchronized 代码块中对于共享变量的操做随着该线程退出 synchronized 块,会当即对其余线程可见(这句话的前提是其余读取共享变量的线程会从主内存读取最新值)。
所以,咱们能够总结一下:线程 a 对于进入 synchronized 块以前或在 synchronized 中对于共享变量的操做,对于后续的持有同一个监视器锁的线程 b 可见。虽然是挺简单的一句话,请读者好好体会。
注意一点,在进入 synchronized 的时候,并不会保证以前的写操做刷入到主内存中,synchronized 主要是保证退出的时候能将本地内存的数据刷入到主内存。
咱们趁热打铁,为你们解决下单例模式中的双重检查问题。关于这个问题,大神们发过文章对此进行阐述了,这里搬运一下。
来膜拜下文章署名中的大神们:David Bacon (IBM Research) Joshua Bloch (Javasoft), Jeff Bogda, Cliff Click (Hotspot JVM project), Paul Haahr, Doug Lea, Tom May, Jan-Willem Maessen, Jeremy Manson, John D. Mitchell (jGuru) Kelvin Nilsen, Bill Pugh, Emin Gun Sirer,至少 Joshua Bloch 和 Doug Lea 你们都不陌生吧。
废话少说,看如下单例模式的写法:
public class Singleton { private static Singleton instance = null; private int v; private Singleton() { this.v = 3; } public static Singleton getInstance() { if (instance == null) { // 1. 第一次检查 synchronized (Singleton.class) { // 2 if (instance == null) { // 3. 第二次检查 instance = new Singleton(); // 4 } } } return instance; } }
不少人都知道上述的写法是不对的,可是可能会说不清楚到底为何不对。
咱们假设有两个线程 a 和 b 调用 getInstance() 方法,假设 a 先走,一路走到 4 这一步,执行 instance = new Singleton() 这句代码。
instance = new Singleton() 这句代码首先会申请一段空间,而后将各个属性初始化为零值(0/null),执行构造方法中的属性赋值[1],将这个对象的引用赋值给 instance[2]。在这个过程当中,[1] 和 [2] 可能会发生重排序。
此时,线程 b 刚刚进来执行到 1(看上面的代码块),就有可能会看到 instance 不为 null,而后线程 b 也就不会等待监视器锁,而是直接返回 instance。问题是这个 instance 可能还没执行完构造方法(线程 a 此时还在 4 这一步),因此线程 b 拿到的 instance 是不完整的,它里面的属性值多是初始化的零值(0/false/null),而不是线程 a 在构造方法中指定的值。
回顾下前面的知识,分析下这里为何会有这个问题。
一、编译器能够将构造方法内联过来,以后再发生重排序就很容易理解了。
二、即便不发生代码重排序,线程 a 对于属性的赋值写入到了线程 a 的本地内存中,此时对于线程 b 不可见。
最后提一点,若是线程 a 从 synchronized 块出来了,那么 instance 必定是正确构造的完整实例,这是咱们前面说过的 synchronized 的内存可见性保证。
—————分割线—————
对于大部分读者来讲,这一小节其实能够结束了,不少读者都知道,解决方案是使用 volatile 关键字,这个咱们在介绍 volatile 的时候再说。固然,若是你还有耐心,也能够继续看看本小节。
咱们看下下面这段代码,看看它能不能解决咱们以前碰到的问题。
public static Singleton getInstance() { if (instance == null) { // Singleton temp; synchronized (Singleton.class) { // temp = instance; if (temp == null) { // synchronized (Singleton.class) { // 内嵌一个 synchronized 块 temp = new Singleton(); } instance = temp; // } } } return instance; }
上面这个代码颇有趣,想利用 synchronized 的内存可见性语义,不过这个解决方案仍是失败了,咱们分析下。
前面咱们也说了,synchronized 在退出的时候,能保证 synchronized 块中对于共享变量的写入必定会刷入到主内存中。也就是说,上述代码中,内嵌的 synchronized 结束的时候,temp 必定是完整构造出来的,而后再赋给 instance 的值必定是好的。
但是,synchronized 保证了释放监视器锁以前的代码必定会在释放锁以前被执行(如 temp 的初始化必定会在释放锁以前执行完 ),可是没有任何规则规定了,释放锁以后的代码不能够在释放锁以前先执行。
也就是说,代码中释放锁以后的行为 instance = temp 彻底能够被提早到前面的 synchronized 代码块中执行,那么前面说的重排序问题就又出现了。
最后扯一点,若是全部的属性都是使用 final 修饰的,其实以前介绍的双重检查是可行的,不须要加 volatile,这个等到 final 那节再介绍。
大部分开发者应该都知道怎么使用这个关键字,只是可能不太了解个中原因。
若是你下次面试的时候有人问你 volatile 的做用,记住两点:内存可见性和禁止指令重排序。
咱们仍是用 JMM 的主内存和本地内存抽象来描述,这样比较准确。还有,并非只有 Java 语言才有 volatile 关键字,因此后面的描述必定要创建在 Java 跨平台之后抽象出了内存模型的这个大环境下。
还记得 synchronized 的语义吗?进入 synchronized 时,使得本地缓存失效,synchronized 块中对共享变量的读取必须从主内存读取;退出 synchronized 时,会将进入 synchronized 块以前和 synchronized 块中的写操做刷入到主存中。
volatile 有相似的语义,读一个 volatile 变量以前,须要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个 volatile 属性会当即刷入到主内存。因此,volatile 读和 monitorenter 有相同的语义,volatile 写和 monitorexit 有相同的语义。
你们还记得以前的双重检查的单例模式吧,前面提到,加个 volatile 能解决问题。其实就是利用了 volatile 的禁止重排序功能。
volatile 的禁止重排序并不局限于两个 volatile 的属性操做不能重排序,并且是 volatile 属性操做和它周围的普通属性的操做也不能重排序。
以前 instance = new Singleton() 中,若是 instance 是 volatile 的,那么对于 instance 的赋值操做(赋一个引用给 instance 变量)就不会和构造函数中的属性赋值发生重排序,能保证构造方法结束后,才将此对象引用赋值给 instance。
根据 volatile 的内存可见性和禁止重排序,那么咱们不可贵出一个推论:线程 a 若是写入一个 volatile 变量,此时线程 b 再读取这个变量,那么此时对于线程 a 可见的全部属性对于线程 b 都是可见的。
volatile 修饰符适用于如下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其余线程能够当即获得修改后的值。在并发包的源码中,它使用得很是多。
volatile 属性的读写操做都是无锁的,它不能替代 synchronized,由于它没有提供原子性和互斥性。由于无锁,不须要花费时间在获取锁和释放锁上,因此说它是低成本的。
volatile 只能做用于属性,咱们用 volatile 修饰属性,这样 compilers 就不会对这个属性作指令重排序。
volatile 提供了可见性,任何一个线程对其的修改将立马对其余线程可见。volatile 属性不会被线程缓存,始终从主存中读取。
volatile 提供了 happens-before 保证,对 volatile 变量 v 的写入 happens-before 全部其余线程后续对 v 的读操做。
volatile 可使得 long 和 double 的赋值是原子的,前面在说原子性的时候提到过。
用 final 修饰的类不能够被继承,用 final 修饰的方法不能够被覆写,用 final 修饰的属性一旦初始化之后不能够被修改。固然,咱们不关心这些段子,这节,咱们来看看 final 带来的内存可见性影响。
以前在说双重检查的单例模式的时候,提过了一句,若是全部的属性都使用了 final 修饰,那么 volatile 也是能够不要的,这就是 final 带来的可见性影响。
在对象的构造方法中设置 final 属性,同时在对象初始化完成前,不要将此对象的引用写入到其余线程能够访问到的地方(不要让引用在构造函数中逸出)。若是这个条件知足,当其余线程看到这个对象的时候,那个线程始终能够看到正确初始化后的对象的 final 属性。
上面说得很明白了,final 属性的写操做不会和此引用的赋值操做发生重排序,如:
x.finalField = v; ...; sharedRef = x;
若是你还想查看更多的关于 final 的介绍,能够移步到我以前翻译的 Java 语言规范的 final属性的语义 部分。
并发问题是程序员都离不开的话题,说到这里顺便给你们推荐一个交流学习群:650385180,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,相信对于已经工做和遇到技术瓶颈的码友,在这个群里必定有你须要的内容。
以前看过 Java8 语言规范《深刻分析 java 8 编程语言规范:Threads and Locks》,本文中的不少知识是和它相关的,不过那篇直译的文章的可读性差了些,但愿本文能给读者带来更多的收获。
描述该类知识须要很是严谨的语言描述,虽然我仔细检查了好几篇,但仍是担忧有些地方会说错,一来这些内容的正误很是受我自身的知识积累影响,二来也和我在行文中使用的话语有很大的关系。但愿读者能帮助指正我表述错误的地方。
update:2018-07-06 留个小问题给读者咱们不可贵出一个推论:线程 a 若是写入一个 volatile 变量,此时线程 b 再读取这个变量,那么此时对于线程 a
可见的全部属性对于线程 b 都是可见的。文中我写了上面这么一句,读者能够考虑下这个结论是怎么推出来的。