一、双重检查锁
1、概念
双重检查锁 double-checked locking = DCL
也被称为"双重检查加锁优化","锁暗示"(Lock hint)
它是一种软件设计模式用来减少并发系统中竞争和同步的开销。
举个例子:
/** * 双重检查锁定 * * @author xiaoshu */ public class DoubleCheckedLocking { private static Instance instance; public static Instance getInstance() { if (null == instance) { //1.第一次检查 synchronized (DoubleCheckedLocking.class) { //2.加锁 if (null == instance) { //3.第二次检查 instance = new Instance(); //4.若是未加volatile修饰的共享变量,问题的根源出在这里 } } } return instance; } } |
上面的锁可以通过同步来实现。synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。
反之,先对变量做判断,就不会立马执行同步方法。这种延迟初始化方案将能提供令人满意的性能。
从示例可以总结出 DCL的基本思路是:首先验证锁定条件(第一次检查),只有通过锁定条件验证,才开始进行加锁逻辑,并再次验证条件(第二次检查)。
简言之:
1)先检查共享变量是否被初始化
2)如果未初始化,则加锁,再二次判断变量是否被初始化,依然满足条件,则执行初始化。
2、存在的问题
上面的示例程序,在多线程访问下,存在线程安全问题。
假设:
共享变量instance
线程A,发现变量instance =null,则加锁,二次判断 instance = null,开始执行 instance = new Instance();
线程B,正当线程A在对instance 进行初始化(但未完成时),发现 instance !=null ,于是提前读取了instance的值,进行后续逻辑操作。这样,程序就可能异常或崩溃了。
为什么未完成初始化的instance 会被线程B 提前取用了?
问题出在 instance = new Instance();
这个操作是 非原子性操作,可以抽象为下面几条JVM指令: 1-2-3
- memory =allocate(); //1:分配对象的内存空间
- ctorInstance(memory); //2:初始化对象
- instance =memory; //3:设置instance指向刚分配的内存地址
本来指令的执行顺序是1-2-3, 但是 JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度,JVM可能会对指令进行重排序。
也就是指令的执行顺序 可能被优化为: 1-3-2
- memory =allocate(); //1:分配对象的内存空间
- instance =memory; //3:instance指向刚分配的内存地址,此时对象还未初始化
- ctorInstance(memory); //2:初始化对象
那么当线程A执行完 1-3后,可能出现3种情况:
1)线程A还没开始执行2,此时线程B 发现 instance=null, 符合预期。
2)线程A开始执行2,但还没完成(对象还没完成初始化),此时线程B 发现 instance != null , 线程B提前取用了instance ,去执行后续的逻辑。 半成品的instance,导致程序运行的不可预知性。
3)线程A执行完2,此时线程B发现 instance != null, 已完成初始化的instance 参与后续逻辑,符合预期。
所以,结论就是 JVM 为了优化性能,进行了指令重排序,可能会导致了共享变量的不安全,从而导致程序运行的未知问题。
如何解决这个问题?
这个问题其实就是, 如何实现线程安全的延迟初始化。
思路:
1)不允许2和3重排序 ———— volatile
2)允许2和3重排序,但不允许其他线程“看到”这个重排序。 —— 类的初始化 (本篇不作介绍)
二、volatile
前言:
volatile 从字面意思看,就是不稳定的,易变的。
在我们上面的这个场景,它就是要适配 共享变量的易变 在 多线程之间可见。
volatile的两大作用
内存可见性
1、JVM内存模型
JVM内存模型: 主内存 + 线程(工作缓存)
原理:
线程中运行的代码最终都是交给CPU执行的,而代码执行时所需使用到的数据来自于内存(或者称之为主存)。
但是CPU是不会直接操作内存的,每个CPU都会有自己的缓存,操作缓存的速度比操作主存更快。
3点小原则:
- 每个线程都有独立的工作内存,只能访问自己的工作内存。线程间的工作缓存相互独立。
- 多个线程的共享变量,存储在主内存当中。
- 当线程想要操作共享变量时,先从主内存copy一个副本到自己的工作内存,操作完毕后再同步回主内存。 —— 这里就引出了主存和线程缓存的数据完整性问题
JVM内存模型,规定了这两部分内存的协议,制定了8种原子操作,如下:
- (1) lock:将主内存中的变量锁定,为一个线程所独占
- (2) unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
- (3) read:将主内存中的变量值读到工作内存当中
- (4) load:将read读取的值保存到工作内存中的变量副本中。
- (5) use:将值传递给线程的代码执行引擎
- (6) assign:将执行引擎处理返回的值重新赋值给变量副本
- (7) store:将变量副本的值存储到主内存中。
- (8) write:将store存储的值写入到主内存的共享变量当中
结合上面的示例场景,以及上述的操作,可以概括为:
1)加锁
2)某个独立线程 读写操作,同步回主存,具体操作包括:
2.1)从主存中将共享变量 copy 到自己的工作缓存中。
2.2)对缓存中的共享变量 进行修改。
2.3)将修改后的值刷新到主存中。
3)解锁
volatile如何保证可见性?
在每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。所以volatile关键字的作用之一就是保证变量修改的实时可见性。
禁止指令重排
在JDK1.5之后,可以使用volatile变量禁止指令重排序。
指令重排 和 原子性操作的概念 在上面已经论述,不再重复。
volatile 如何 禁止 指令重排 ?
加入内存屏障(Memory Barrier),禁止JVM的指令重排。
内存屏障(Memory Barrier)
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
大多数的处理器都支持内存屏障的指令。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。 --- DCL 问题!
- 它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
- Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
最后结论:
共享变量,加了 volatile 关键字修饰后,获得了 内存可见性 + 禁止指令重排序 的能力。
任一个线程 对 共享变量 的修改,对于所有访问它的线程都是及时可见的。同时,对它的非原子性操作 也会通过特定策略 插入 内存屏障 确保 指令序列按照预期 顺序执行。
参考链接:
- https://blog.youkuaiyun.com/jiyiqinlovexx/article/details/50989328 Java并发:volatile内存可见性和指令重排
- https://blog.youkuaiyun.com/truelove12358/article/details/106496259 什么是内存屏障(Memory Barrier)以及在java中的应用
- https://zh.wikipedia.org/wiki/%E5%8F%8C%E9%87%8D%E6%A3%80%E6%9F%A5%E9%94%81%E5%AE%9A%E6%A8%A1%E5%BC%8F
- https://segmentfault.com/a/1190000020959908 DCL 为何添加 volatile