单例陷阱——双重检查锁中的指令重排问题
之前我曾经写过一篇文章《单例模式有8种写法,你知道么?》,其中提到了一种实现单例的方法-双重检查锁,最近在读并发方面的书籍,发现双重检查锁使用不当也并非绝对安全,在这里分享一下。
单例回顾
首先我们回顾一下最简单的单例模式是怎样的?
/**
*单例模式一:懒汉式(线程安全)
*/
public class Singleton1 {
private static Singleton1 singleton1;
private Singleton1() {
}
public static Singleton1 getInstance() {
if (singleton1 == null) {
singleton1 = new Singleton1();
}
return singleton1;
}
}
这是一个懒汉式的单例实现,众所周知,因为没有相应的锁机制,这个程序是线程不安全的,实现安全的最快捷的方式是添加 synchronized
/**
* 单例模式二:懒汉式(线程安全)
*/
public class Singleton2 {
private static Singleton2 singleton2;
private Singleton2() {
}
public static synchronized Singleton2 getInstance() {
if (singleton2 == null) {
singleton2 = new Singleton2();
}
return singleton2;
}
}
使用synchronized之后,可以保证线程安全,但是synchronized将全部代码块锁住,这样会导致较大的性能开销,因此,人们想出了一个“聪明”的技巧:双重检查锁DCL(double checked locking)的机制实现单例。
双重检查锁
一个双重检查锁实现的单例如下所示:
/**
* 单例模式三:DCL(double checked locking)双重校验锁
*/
public class Singleton3 {
private static Singleton3 singleton3;
private Singleton3() {
}
public static Singleton3 getInstance() {
if (singleton3 == null) {
synchronized (Singleton3.class) {
if (singleton3 == null) {
singleton3 = new Singleton3();
}
}
}
return singleton3;
}
}
如上面代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此可以大幅降低synchronized带来的性能开销。上面代码表面上看起来,似乎两全其美:
在多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。 在对象创建好之后,执行getInstance()将不需要获取锁,直接返回已创建好的对象。
程序看起来很完美,但是这是一个不完备的优化,在线程执行到第9行代码读取到instance不为null时(第一个if),instance引用的对象有可能还没有完成初始化。
问题的根源
问题出现在创建对象的语句singleton3 = new Singleton3();
上,在java中创建一个对象并非是一个原子操作,可以被分解成三行伪代码:
//1:分配对象的内存空间
memory = allocate();
//2:初始化对象
ctorInstance(memory);
//3:设置instance指向刚分配的内存地址
instance = memory;
上面三行伪代码中的2和3之间,可能会被重排序(在一些JIT编译器中),即编译器或处理器为提高性能改变代码执行顺序,这一部分的内容稍后会详细解释,重排序之后的伪代码是这样的:
//1:分配对象的内存空间
memory = allocate();
//3:设置instance指向刚分配的内存地址
instance = memory;
//2:初始化对象
ctorInstance(memory);
在单线程程序下,重排序不会对最终结果产生影响,但是并发的情况下,可能会导致某些线程访问到未初始化的变量。
模拟一个2个线程创建单例的场景,如下表:
时间 | 线程A | 线程B |
---|---|---|
t1 | A1:分配对象内存空间 | |
t2 | A3:设置instance指向内存空间 | |
t3 | B1:判断instance是否为空 | |
t4 | B2:由于instance不为null,线程B将访问instance引用的对象 | |
t5 | A2:初始化对象 | |
t6 | A4:访问instance引用的对象 |
按照这样的顺序执行,线程B将会获得一个未初始化的对象,并且自始至终,线程B无需获取锁!
指令重排序
前面我们已经分析到,导致问题的原因在于“指令重排序”,那么什么是“指令重排序”,它为什么在并发时会影响到程序处理结果? 首先我们看一下“顺序一致性内存模型”概念。
顺序一致性理论内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
一个线程中的所有操作必须按照程序的顺序来执行。 (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
实际JMM模型
但是,顺序一致性模型只是一个理想化了的模型,在实际的JMM实现中,为了尽量提高程序运行效率,和理想的顺序一致性内存模型有以下差异:
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。在JMM中不保证单线程操作会按程序顺序执行(即指令重排序
)。
顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
顺序一致性模型保证对所有的内存写操作都具有原子性,而JMM不保证对64位的long型和double型变量的读/写操作具有原子性(分为2个32位写操作进行,本文无关不细阐述)
指令重排序
指令重排序是指编译器或处理器为了优化性能而采取的一种手段,在不存在数据依赖性情况下(如写后读,读后写,写后写),调整代码执行顺序。 举个例子:
//A
double pi = 3.14;
//B
double r = 1.0;
//C
double area = pi * r * r;
这段代码C依赖于A,B,但A,B没有依赖关系,所以代码可能有2种执行顺序:
A->B->C B->A->C 但无论哪种最终结果都一致,这种满足单线程内无论如何重排序不改变最终结果的语义,被称作 as-if-serial语义
,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉: 单线程程序是按程序的顺序来执行的。
双重检查锁问题解决方案
回来看下我们出问题的双重检查锁程序,它是满足as-if-serial语义
的吗?是的,单线程下它没有任何问题,但是在多线程下,会因为重排序出现问题。
解决方案就是大名鼎鼎的volatile关键字,对于volatile我们最深的印象是它保证了”可见性“,它的”可见性“是通过它的内存语义实现的:
写volatile修饰的变量时,JMM会把本地内存中值刷新到主内存 读volatile修饰的变量时,JMM会设置本地内存无效
重点:为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止重排序!
对之前代码加入volatile关键字,即可实现线程安全的单例模式。
/**
* 单例模式三:DCL(double checked locking)双重校验锁
*/
public class Singleton3 {
private static volatile Singleton3 singleton3;
private Singleton3() {
}
public static Singleton3 getInstance() {
if (singleton3 == null) {
synchronized (Singleton3.class) {
if (singleton3 == null) {
singleton3 = new Singleton3();
}
}
}
return singleton3;
}
}
感谢阅读,如有收获,求
点赞
、求关注
让更多人看到这篇文章,本文首发于不止于技术的技术公众号Nauyus
,欢迎识别下方二维码获取更多内容,主要分享JAVA,微服务,编程语言,架构设计,思维认知类等原创技术干货,2019年12月起开启周更模式,欢迎关注,与Nauyus一起学习。
福利一:后端开发视频教程
这些年整理的几十套JAVA后端开发视频教程,包含微服务,分布式,Spring Boot,Spring Cloud,设计模式,缓存,JVM调优,MYSQL,大型分布式电商项目实战等多种内容,关注Nauyus立即回复【视频教程】无套路获取。
福利二:面试题打包下载
这些年整理的面试题资源汇总,包含求职指南,面试技巧,微软,华为,阿里,百度等多家企业面试题汇总。 本部分还在持续整理中,可以持续关注。立即关注Nauyus回复【面试题】无套路获取。