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


之前我曾经写过一篇文章《单例模式有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带来的性能开销。上面代码表面上看起来,似乎两全其美:

  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回复【面试题】无套路获取。

原文地址:https://www.cnblogs.com/lkxsnow/p/12293791.html

时间: 2024-12-29 21:42:33

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

从单例的双重检查锁想到的

常说的单例有懒汉跟饿汉两种写法.饿汉由于类加载的时候就创建了对象,因此不存在并发拿到不同对象的问题,但会由于开始就加载了对象,可能会造成一些启动缓慢等性能问题:而懒汉虽然避免了这个问题,但普通的写法会在高并发环境下创建多个对象,单纯加synchronize又会明显降低并发效率,较好的两种写法是静态内部类跟双重检查锁两种. 双重检查锁这个,大家都很熟悉了,上代码: public class SingleTest { private static SingleTest singleTest; //获

单例模式中 的 双重检查锁 概念与用法

public class Singleton { //私有的 静态的 本类属性 private volatile static Singleton _instance; //私有化构造器 private Singleton() {} /* * 1st version: creates multiple instance if two thread access * this method simultaneouslyX */ public static Singleton getInstance

Java并发笔记——单例与双重检测

单例模式可以使得一个类只有一个对象实例,能够减少频繁创建对象的时间和空间开销.单线程模式下一个典型的单例模式代码如下: ① 1 class Singleton{ 2 private static Singleton singleton; 3 private Singleton(){} 4 5 public static Singleton getInstance(){ 6 if(singleton == null){ 7 singleton = new Singleton(); //1 8 }

单例模式中用volatile和synchronized来满足双重检查锁机制

背景:我们在实现单例模式的时候往往会忽略掉多线程的情况,就是写的代码在单线程的情况下是没问题的,但是一碰到多个线程的时候,由于代码没写好,就会引发很多问题,而且这些问题都是很隐蔽和很难排查的. 例子1:没有volatile修饰的uniqueInstance public class Singleton { private static Singleton uniqueInstance; private Singleton(){ } public static Singleton getInsta

关于并发场景下,通过双重检查锁实现延迟初始化的优化问题隐患的记录

首先,这个问题是从<阿里巴巴Java开发手册>的1.6.12(P31)上面看到的,里面有这样一句话,并列出一种反例代码(以下为仿写,并非与书上一致): 在并发场景下,通过双重检查锁(double-checked locking)实现延迟初始化的优化问题隐患,推荐解决方案中较为简单的一种(适用于JDK5及以上的版本),即目标属性声明为volatile型. 1 public class Singleton { 2 private static Singleton instance=null; 3

线程安全的单例模式及双重检查锁—个人理解

在web应用中服务器面临的是大量的访问请求,免不了多线程程序,但是有时候,我们希望在多线程应用中的某一个类只能新建一个对象的时候,就会遇到问题. 首先考虑单线程,如果要求只能新建一个对象,那么构造函数我们要设为private.简单的想法: class singleton{ private singleton(){ //..... } private static singleton instance; public static singleton getinstance(){ if(insta

[杂谈]C++的双重检查锁并不安全

原文地址 http://www.cnblogs.com/hebaichuanyeah/p/6298513.html 一个典型的单例模式构建对象的双重检查锁如下: static Singleton * getSingleObject() { if(singleObject==NULL) { lock(); if(singleObject==NULL) { singleObject = new Singleton(); } unlock(); } return singleObject; } 该代码

C++的双重检查锁并不安全(转)

一个典型的单例模式构建对象的双重检查锁如下: 1 static Singleton * getSingleObject() 2 { 3 if(singleObject==NULL) 4 { 5 lock(); 6 if(singleObject==NULL) 7 { 8 singleObject = new Singleton(); 9 } 10 unlock(); 11 } 12 return singleObject; 13 } 该代码的逻辑是:getSingleObject()函数获得对象

Java单例模式中双重检查锁的问题

单例创建模式是一个通用的编程习语.和多线程一起使用时,必需使用某种类型的同步.在努力创建更有效的代码时,Java 程序员们创建了双重检查锁定习语,将其和单例创建模式一起使用,从而限制同步代码量.然而,由于一些不太常见的 Java 内存模型细节的原因,并不能保证这个双重检查锁定习语有效. 它偶尔会失败,而不是总失败.此外,它失败的原因并不明显,还包含 Java 内存模型的一些隐秘细节.这些事实将导致代码失败,原因是双重检查锁定难于跟踪.在本文余下的部分里,我们将详细介绍双重检查锁定习语,从而理解它