单例模式是比较常见的一种设计模式,在开发实践中经常看到它的身影,它有很多种实现方式,曾经有人在一篇文章中列举了十几种实现方式,比如饿汉式、懒汉式、双重检查锁、枚举。。。等等,程序员应该都熟悉这些常见的实现方式。本文对其中的一种实现方式-双重检查锁,介绍一下它的实现方式,分析为什么会出现乱序执行导致线程不安全,又是如何避免的。

我们知道单例模式的对象在进程中仅有一份,在多线程环境下为了防止创建出多个对象,需要对创建对象的过程进行互斥操作,这样,当多线程同时竞争时,保证只能由一个线程来创建这个唯一的对象。常见互斥操作的方式就是使用锁,比如互斥锁或者自旋锁。如下面的C++11代码片段就是使用mutex锁来实现的:

class lockSingleton {static mutex lock;static lockSingleton *instance;public:static lockSingleton *getInstance() {lock_guard<mutex> guard(lock); // 每次地调用都要获取互斥锁 if (instance == nullptr) {instance = new lockSingleton();}return instance;}};

这种实现方式的最大的缺点就是,调用getInstance()来获取单例对象时,都要无条件的申请锁,哪怕是单例对象已经成功创建了。也就是说,如果调用了n次getInstance(),可能只有一次是获得锁后并创建对象,而其余的n-1次调用,都是在获得锁后,发现instance不为空指针nullptr,接着就释放锁后直接返回了。

我们看一下和锁操作有关的系统开销,申请锁和释放锁用到了硬件提供的原子操作指令,而原子操作比非原子操作要慢得多,还要锁内存总线,尤其是如果申请不到锁,操作系统要把线程挂起,等到释放锁的时候还要唤醒一个线程,这些都要涉及到系统调用上下文和线程上下文的切换,是一个重量级的操作;再看一下互斥锁所保护的临界区代码,只有区区一个指针判空逻辑(当然第一次获得锁时还有创建对象的逻辑,不过仅有一次)。可见,相对于这个简单的逻辑判断来说,使用锁的开销实在太大了,也就是说一个线程在调用这个方法时,CPU的时间开销几乎都花在了锁的申请和释放上面,边际成本太高了,一点也不划算(而其它不相关的CPU也会造成“失速”,因为当使用硬件原子操作时,会锁定内存总线,造成其它CPU都无法访问内存,此时CPU若要访问内存时就得先等待,直到内存总线锁释放)。既然当对象创建成功以后,每次调用该方法时,指针变量instance肯定是不为nullptr的,那么,何不针对这一特点进行优化呢?

“双重检查锁”就是针对这种场景的优化方式,方法就是在申请锁之前先检查instance是否为空指针,只有当instance为空指针时,才进入申请锁的流程,这样可以避免绝大多数不必要的加解锁操作。这个方法所体现的思想是,先使用低成本的代码检查条件是否满足,如果满足了,再使用带有锁的代码逻辑进行高成本的检查,因为先后共有两次逻辑检查,所以称为“双重检查锁”。下面看一下双重检查锁的完成代码片段:

static dclSingleton *getInstance() {if (instance == nullptr) {          ①lock_guard<mutex> guard(lock);if (instance == nullptr) {②instance = new dclSingleton();③}}return instance;}

我们分析一下这段代码,假设一个线程A在①处读取instance时,另一个线程B获得了锁,正在运行临界区的代码,如果③已经执行了,那么instance就不会是nullptr,线程A直接返回,如果③还没有执行,instance为nullptr,线程A就申请锁,当B执行完③离开临界区时释放锁,A获得锁后执行到②处发现instance已经不为nullptr了,就直接返回。实现机制非常优雅,开销也非常低,一个简单的逻辑判断就能避免锁的操作,貌似是线程安全的,但遗憾的是,仍然无法百分百地保证解决线程安全问题。

从上面的代码可以看到,instance是一个共享变量,它是被多个线程共享使用的,为了线程安全,instance应该作为临界资源进行保护。在程序中有两处访问它的地方,分别在①处和③处,可以看到,在③处使用mutex互斥锁进行了保护,而在①处并没有使用mutex互斥锁进行保护。根据互斥锁的特性,释放锁时有release内存语义,获取锁时有acquire内存语义,按说在①处是对instance进行读,应该有一个acquire语义,保证它和③处释放锁时保持一个release-acquire语义。这样,当在①处读取instance时,如果instance不为nullptr,就能保证③处对单例对象的数据成员的写操作happen-before于①处之前,也就保证了在①处读取instance时,单例对象已经初始化完成,这是锁的内存序语义保证的。然而由于①处的instance访问不在被锁保护的临界区之内,没有任何互斥性及顺序性保证,也就是代码在①处执行时,它被”破防“了,突破了mutex互斥锁保护,不管临界区里面的代码执行顺序如何,可以直接读取临界资源。

我们不妨使用一个实际例子来分析,下面是用C++11编写的一个双重检查锁的单例代码,对象有两个int型数据成员,代码非常简单。

#include <atomic>
#include <mutex>
#include <cstdio>
using namespace std;class dclSingleton {int x;int y;static mutex lock;static dclSingleton *instance;dclSingleton() {x = 0;y = 1;}dclSingleton(const dclSingleton &) = delete;dclSingleton &operator=(const dclSingleton &) = delete;public:static dclSingleton *getInstance() {if (instance == nullptr) {lock_guard<mutex> guard(lock);if (instance == nullptr) {instance = new dclSingleton();}}return instance;}void print() {puts("一个毫无实际用处的例子");}
};dclSingleton *dclSingleton::instance = nullptr;
mutex dclSingleton::lock;int main() {dclSingleton *p = dclSingleton::getInstance();p->print();
}

我们先看创建对象的代码:instance = new dclSingleton();如果我们把代码使用placement new来实现的话,这句话等同于:

dclSingleton *p = (dclSingleton *)::operator new(sizeof(dclSingleton)); // 第一步
new(p) dclSingleton(); // 第二步
instance = p; // 第三步

实际上C++编译器使用new操作符在堆中创建一个对象时,也是按照这个步骤处理的,即分成了3个步骤,第一步是为对象分配内存块;第二步是对这段内存块进行初始化,也就是调用构造函数;第三步才是把this指针赋值给静态变量instance。

尽管C++语义上把创建对象分成了有前后依赖顺序的三个步骤,但编译器在编译优化时,可能会对代码进行重排序,让CPU乱序执行,即有可能让第三步提前到第二步执行。我们从源码中看到,第二步是一个函数调用,而第三步是一个指针赋值操作,它们怎么会又是如何重排序的呢?当然,这里的重排序并不是说编译器在C++源代码级别上把语句重新排序了,而是指在生成底层的汇编指令时,优化器有可能根据优化策略对指令进行重排序,也就是说重排序是编译器在指令级优化的结果。如果为了避免调用构造函数的额外开销,编译器进行了内联(inline)优化,那么整个函数体的指令代码就会在第2句的位置处展开,在此基础上,优化器还会把这些展开以后的指令和它周边的指令在同一个上下文中综合考虑,这样,内联展开后的指令和周边指令有可能发生重排序,从而让第3句的赋值指令安排在构造函数中的一些初始化代码的前面。因此,从宏观上看,就好像是源代码的第二句和第三句重排序了。

可以借助于工具来查看这段程序编译后的汇编代码,在https://godbolt.org/网站上,把上面那段程序粘贴上去,可以查看生成的汇编代码,选择使用x86-64 gcc编译器进行编译,并打开编译优化选项-O2。因为使用了优化选项,编译器对函数进行了内联处理,会把main函数中第一行getInstance()函数的调用进行了内联优化,所生成的汇编代码及其注释如下:

.LC0:.string "\344\270\200\344\270\252\346\257\253\346\227\240\345\256\236\351\231\205\347\224\250\345\244\204\347\232\204\344\276\213\345\255\220"
main:push    rbppush    rbxsub     rsp, 8cmp     QWORD PTR dclSingleton::instance[rip], 0 // 对应第一次if (instance == nullptr) 判断instance是否是空指针je      .L22
.L3:mov     edi, OFFSET FLAT:.LC0call    putsadd     rsp, 8xor     eax, eaxpop     rbxpop     rbpret
.L22:mov     ebx, OFFSET FLAT:_ZL28__gthrw___pthread_key_createPjPFvPvEtest    rbx, rbxje      .L7mov     edi, OFFSET FLAT:dclSingleton::lockcall    __gthrw_pthread_mutex_lock(pthread_mutex_t*) // 申请互斥锁test    eax, eaxjne     .L23cmp     QWORD PTR dclSingleton::instance[rip], 0  // 对应第二次if (instance == nullptr) 判断instance是否是空指针je      .L7// 当为空指针时跳转到进入L7位置,开始创建单例对象
.L8:test    rbx, rbxje      .L3mov     edi, OFFSET FLAT:dclSingleton::lockcall    __gthrw_pthread_mutex_unlock(pthread_mutex_t*)jmp     .L3
.L7:mov     edi, 8call    operator new(unsigned long)  // 分配内存mov     edx, 1mov     QWORD PTR dclSingleton::instance[rip], rax// 指针赋值sal     rdx, 32mov     QWORD PTR [rax], rdx // 初始化数据成员x和yjmp     .L8
.L23:mov     edi, eaxcall    std::__throw_system_error(int)mov     rbp, raxjmp     .L10
main.cold:
dclSingleton::lock:.zero   40
dclSingleton::instance:.zero   8

把构造单例对象的代码片段取出来,同样,代码中没有调用构造函数的指令,因为构造函数太简单了,只有两行简单的赋值语句,编译器也对它进行了内联优化。汇编代码片段及注释如下:

mov  edi, 8 // 单例里面有两个int型的成员,它们的占用的内存空间是4*2=8字节,其中x位于低4字节,y位于高4字节。
call    operator new(unsigned long) //使用operator new操作符分配8个字节的内存空间,返回的地址存放在rax寄存器中,即this指针
mov edx, 1 // 对应y = 1,暂时存放于rdx寄存器的低4字节的寄存器edx中
mov QWORD PTR dclSingleton::instance[rip], rax // 把this指针赋值给instance
sal rdx, 32 // 寄存器rdx左移32位,也就是它的高32位是1,对应数据成员y,低32位是0,对应数据成员x,
mov QWORD PTR [rax], rdx // 为this指针指向的8个字节赋值,即调用构造函数

先解释一下第5条指令,它是把1,0这两个长度为4字节的int型常数合并成了一个8字节的常数,这样只写一次内存就可以同时初始化两个长度为4字节的变量。x位于this(即寄存器rax)指向的位置,而y位于this+4指向的位置,当第6条指令执行完之后,x=0, y=1,即完成了构造函数的调用。

代码段中第1、2行是分配内存,第4行是为变量instance赋值,第3、5、6行是调用构造函数(当然是编译器内联优化之后的代码)。从这里可以看到,在创建单例对象时的步骤为:

  1. 为单例对象分配内存块
  2. 把分配好的内存块地址赋值给instance,注意此时内存块还没有被初始化
  3. 初始化这块内存块

编译器在优化时,把调用构造函数的代码进行了内联展开,并且展开后的代码和第三步的指针赋值一块进行了优化分析,把指针赋值的操作提到前面去执行,这就是所谓乱序执行,也就说编译器优化代码后打乱了程序的执行顺序。为了方便理解,把它翻译成C++语言:

dclSingleton *p = (dclSingleton *)::operator new (sizeof(dclSingleton)); // 第一步
instance = p; // 第三步
new(p) dclSingleton(); // 第二步

显然,当一个线程执行完第2行代码还未执行第3行的时候,被抢占了,如果另一个线程刚好执行到第一次if语句的空指针检查处,此时就会得到一个已经成功赋值不为nullptr的instance,但它指向的却是一个还没有初始化的单例对象。

那么为什么编译器要对指令进行重排序呢?
我们先分析下面这段创建单例对象的汇编代码,程序员在编程时心目中预想的可能是下面的流程,当然这也是编译器没有指定优化选项时生成的代码:

mov  edi, 8
call    operator new(unsigned long)
mov edx, 1
sal rdx, 32
mov QWORD PTR [rax], rdx
mov QWORD PTR dclSingleton::instance [rip], rax

其中寄存器rax是this指针,它存放了new操作符分配内存成功后返回的地址,当对instance进行赋值时,在编译器看来指令6和指令3、4、5之间都没有前后依赖关系(实际上在C++语言级别上是有依赖关系的,因为这是C++的语义,编程人员知道,编译器的前端也知道,但处于后端的优化器并不知道),既然指令之间没有依赖关系,优化器完全可以按照指令执行的最优化进行重新组合、排序。

指令3是对寄存器edx赋值,指令4是对寄存器rdx进行移位操作,edx是rdx的低32位,因此指令4依赖于指令3,而指令5是使用rdx为rax指向的内存块赋值,即调用内联后的构造函数,rdx的值依赖于指令4的执行结果,因为这些指令之间有前后依赖关系,可见指令3、4、5之间的顺序是不能改变的。

指令6完全可以放在最后执行,符合程序的编程顺序,也符合程序编程人员的心理预期,为什么编译器把它提前了呢。

因为为了性能,既然编译时指定了优化选项,优化器就会根据程序执行的上下文,对前后没有依赖关系的指令执行顺序进行重排序,让CPU执行时可以更合理的分配执行资源,以达到最佳的执行效果。

现代CPU处理器为了提高执行性能,除了在提升时钟频率和使用多核外,同时对单个CPU的处理能力也进行了优化,比如指令多发射机制,CPU的执行单元有多个执行端口(execution port),即CPU在执行指令时,可以一次性的派发多个指令,只要指令之间没有对执行资源形成竞争的话,它们可以并行执行。

前面说过指令3、4、5因为有前后依赖关系,只能依次按顺序执行,在编译生成汇编代码时,不会对它们进行乱序。由于指令4只能等待指令3执行完之后才能执行,既然指令6和前面的指令3、4、5没有依赖关系,那就把它提前到指令3之后,指令6访问内存,指令3只对寄存器赋值并不访问内存,它们之间没有对内存总线的竞争,完全可以同时执行,这样CPU可以同时发射这两个mov指令,从而提高程序的运行速度。但指令6不能放在4之后,因为接下来的指令5也访问内存,这样紧挨着的指令6和5都访问内存,两个不同的执行端口可能会对内存总线总成竞争,无法同时发射这两个mov指令。

当然,如果这段代码运行在没有多发射机制的CPU上,这两个MOV指令就一个一个的执行,只是性能差了点,并不会影响执行结果。显然编译器优化后的代码,无论CPU有没有多发射机制,都可以正确执行,而且在有多发射机制的CPU上还能提高运行速度,编译器这样做是再合理不过了。

不过,优化器不但不知道上层的C++语义,而且也不知道代码的运行环境是单线程还是多线程,都是按照单线程的执行环境进行优化处理。可见,指令乱序执行是一把双刃剑,带来了性能提升的同时,也为在多线程运行环境下带来了内存顺序不一致隐患。

可见,造成双重检查锁实现单例模式线程不安全的原因,一是第一次逻辑检查时临界资源没有使用锁保护,二是临界区的代码乱序执行。只要这两个问题同时存在,就无法解决双重检查锁的问题。如果使用锁为第一次逻辑检查保护,就又走回使用“双重检查锁"优化前的老路子了,显然是行不通的,如果要解决双重检查锁的缺陷,只能在程序执行顺序上考虑:

1、使用-O0编译
不允许编译器优化,保证程序不乱序执行,但是程序的性能可能差一些。

2、指定内存序
让编译器优化时别对程序进行重排序,方法就是使用内存屏障进行内存顺约束。
我们看下面的C++代码片段,这段代码中在调用构造函数和为instance赋值之间添加了一条指定内存屏障的语句,指定使用release语义的内存屏障,告诉编译器不要让dclSingleton* p = new dclSingleton();越过屏障发生在instance = p;的后面。

    static dclSingleton *getInstance() {if (instance == nullptr) {lock_guard<mutex> guard(lock);if (instance == nullptr) {dclSingleton* p = new dclSingleton();atomic_thread_fence(memory_order_release);  // 在调用构造函数和为p赋值之间添加一个内存屏障instance = p;}}return instance;}

使用相同的优化选项进行编译,生成的汇编代码如下:

mov     edi, 8
call    operator new(unsigned long)
mov     edx, 1
sal     rdx, 32
mov     QWORD PTR [rax], rdx
mov     QWORD PTR dclSingleton::instance[rip], rax

可见,指令没有发生乱序,保证instance指针初始化后,指向的是一个已经完全构造好的对象。

如果在Java环境下,可以通过使用volatile修饰instance变量来保证内存序,这样,在对instance赋值时,编译器生成的指令也不会进行重排序,能够保证对单例对象指针的赋值不会早于对象中的数据成员的初始化。还有一种方式,如果对象中的数据成员都是使用final修饰的话,编译器也能够保证各个数据成员的初始化不会晚于对单例指针的赋值,这样也保证了调用构造函数初始化时不会重排序。这些volatile和final都是JVM的内存语义保证的,不过在对volatile修饰的变量进行访问时,底层一般使用了lock指令前缀,会竞争内存总线,相当于C++中“memory_order_seq_cst”类型的内存序,因为JVM的内存模型要求volatile保证的是强一致性,相对其它弱序的内存序语言,要低效一些。

双重检查锁除了用在单例模式实现外,在C++11中也用在了call_once()、函数内static变量的初始化、weak_ptr.lock()等的实现。此外,在优化自旋锁的实现时,也大都使用双重检查锁的实现方法:先用性能高的代码进行自旋(称作本地自旋),当有可能获得锁的时候,退出本地自旋,再使用性能低的CAS操作来获取自旋锁。

总之,双重检查锁在多线程访问锁时,可以有效地避免频繁的加解锁操作。不过,使用双重检查锁时一定要指定内存顺,防止出现指令的重排序现象,如果不加注意,可能会引起严重的后果。

双重检查锁与单例模式相关推荐

  1. 双重检查锁实现单例模式的线程安全问题

    一.结论 双重校验锁的单例模式代码如下: public class Singleton { private static Singleton singleton; private Singleton( ...

  2. java并发编程(二十六)——单例模式的双重检查锁模式为什么必须加 volatile?

    前言 本文我们从一个问题出发来进行探究关于volatile的应用. 问题:单例模式的双重检查锁模式为什么必须加 volatile? 什么是单例模式 单例模式指的是,保证一个类只有一个实例,并且提供一个 ...

  3. 单例模式之双重检查锁(double check locking)的发展历程

    不安全的单例 没有注意过多线程安全问题的时候,我们的单例可能是这样的: public final class Singleton {private static Singleton instance; ...

  4. java双重检查锁单例真的线程安全吗?

     相信大多数同学在面试当中都遇到过手写单例模式的题目,那么如何写一个完美的单例是面试者需要深究的问题,因为一个严谨的单例模式说不定就直接决定了面试结果,今天我们就要来讲讲看似线程安全的双重检查锁单例模 ...

  5. Java中的双重检查锁(double checked locking)

    起因 在实现单例模式时,如果未考虑多线程的情况,很容易写出下面的代码(也不能说是错误的): public class Singleton {private static Singleton uniqu ...

  6. 双重检查锁模式导致空指针

    今天遇到一个问题:莫名奇妙报了个空指针,后来发现原来单例模式在高并发下引起的: 双重检查锁模式的一般实现: 双重检查锁模式解决了单例.性能.线程安全问题,但是这种写法同样存在问题:在多线程的情况下,可 ...

  7. java 双重检查锁_Java中可怕的双重检查锁定习惯用法

    java 双重检查锁 本文讨论的问题不是新问题,但即使是经验丰富的开发人员也仍然很棘手. 单例模式是常见的编程习惯用法. 但是,当与多个线程一起使用时,必须进行某种类型的同步,以免破坏代码. 在相关文 ...

  8. 双重检查锁,原来是这样演变来的,你了解吗

    最近在看Nacos的源代码时,发现多处都使用了"双重检查锁"的机制,算是非常好的实践案例.这篇文章就着案例来分析一下双重检查锁的使用以及优势所在,目的就是让你的代码格调更加高一个层 ...

  9. 双重检查锁定及单例模式

                                         双重检查锁定及单例模式 转载 http://www.ibm.com/developerworks/cn/java/j-dcl. ...

最新文章

  1. Rancher使用入门
  2. DataUml Design 教程3-模型与数据库同步
  3. html5设置文字不能复制,网页文字不能复制?巧解网页文字不能复制
  4. angular具体用法及代码
  5. SCOM 2012知识分享-21:无代理管理
  6. Eclipse Server runtime设置里找不到apache tomcat的处理方法
  7. 2014北科计算机原理试题答案,北科_计算机组成原理考题-A卷答案
  8. 使用Architecture Explorer分析应用程序及使用层次图
  9. 用三方做的豆瓣电影页面
  10. 山地车中轴进水表现_你一定不知道的自行车中轴知识
  11. 小程序实现tab切换
  12. java与python比较之单引号 双引号用法
  13. 以太坊中的事件机制Feed
  14. 文字绕圆排列:vue
  15. 基于遥感的草原与沙漠化监测
  16. 计算机指令中数据寻址的方式,1.变址寻址需要在指令中提供一个寄存器编号和一个数值。 2.计算机的指令越多,功能越强越好。 3.程序计数...
  17. 西电计算机学院毕设答辩,关于2020年(2016级)第二批本科生毕业设计(论文)盲审及答辩工作安排的通知...
  18. 23种设计模式 单例 (整理摘抄优秀的博文) 记住
  19. 最新千万级中文语音语料开源数据整理分享
  20. TensorFlow实现Pix2Pix

热门文章

  1. 腾讯云主机linux(centos7)服务器基本操作和安装日志
  2. MySQL-InnoDB的索引原理及优化技术
  3. GoLang-4(switch)
  4. 成功编译和运行roslaunch qbo_webi qbo_webi.launch(解决qbo_object_recognition之后的其他问题)
  5. 十六进制相关(计算机存储十六进制负数、与十进制转换)
  6. 各种SQL子查询实例
  7. PaperFree-论文查重
  8. android studio模拟手机黑屏,Android Studio 模拟器启动问题——黑屏 死机 解决方法...
  9. 计算机网络实验以太网帧分析,实验二 用Ethereal捕获并分析以太网帧格式
  10. Python服务器开发(1)