单例陷阱——双重检查锁中的指令重排问题

以前我曾经写过一篇文章《单例模式有8种写法,你知道么?》,其中提到了一种实现单例的方法-双重检查锁,最近在读并发方面的书籍,发现双重检查锁使用不当也并不是绝对安全,在这里分享一下。java

单例回顾

首先咱们回顾一下最简单的单例模式是怎样的?程序员

/** *单例模式一:懒汉式(线程安全) */
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带来的性能开销。上面代码表面上看起来,彷佛一箭双鵰:设计模式

  1. 在多个线程试图在同一时间建立对象时,会经过加锁来保证只有一个线程能建立对象。
  2. 在对象建立好以后,执行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无需获取锁!

指令重排序

前面咱们已经分析到,致使问题的缘由在于“指令重排序”,那么什么是“指令重排序”,它为何在并发时会影响到程序处理结果? 首先咱们看一下“顺序一致性内存模型”概念。

顺序一致性理论内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  1. 一个线程中的全部操做必须按照程序的顺序来执行。
  2. (无论程序是否同步)全部线程都只能看到一个单一的操做执行顺序。在顺序一致性内存模型中,每一个操做都必须原子执行且马上对全部线程可见。

实际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种执行顺序:

  1. A->B->C
  2. 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回复【面试题】无套路获取。

相关文章
相关标签/搜索