C++单例模式——懒汉与饿汉以及线程安全

单例模式是说,一个类不论创建多少次对象,永远只能得到该类的一个对象的实例。
因为静态成员只属于类而不属于任何一个对象,所以可以想象用静态成员是不是可以实现单例。

一.基本思路

1.要限制对象的个数,首先要限制构造函数————构造函数私有化

2.定义一个唯一的类的实例对象——static变量,只属于类而不属于任何一个对象,且需要类外初始化。如果类内初始化那就违背了

3.定义一个静态成员函数getInstance()来获取该对象。————因为不能通过构造函数获取对象,所以只能通过静态成员函数获取该唯一的对象

4.不仅要限制构造,还要限制拷贝构造,和赋值重载(因为类的功能很多,得主动去掉,不然会忘记),限制的方式也是C++11新特性,将函数赋值为delete

#include<bits/stdc++.h>
using namespace std;

class Singleton
{
public:
    static Singleton* getInstance()
    {
        return &instance;
    }
private:
    static Singleton instance; //2.定义一个唯一的类的实例对象
    Singleton(int x, char y)    //1.构造函数私有化
    {
       a = x;
       b = y;
       cout<<"构造函数调用"<<endl;
    }
    Singleton(const Singleton&) = delete;//C++11中,定义成员函数,可在后面使用 = delete修饰,表示该函数被删除,禁用;
    Singleton& operator=(const Singleton&) = delete;
    int a;
    char b;

};
// 它是类内的成员,所以能访问私有构造函数,此时调用构造函数
Singleton Singleton::instance(2, 'a');// 类的静态成员变量要类外初始化


int main(){
    cout<<"main开始"<<endl;
    Singleton* p1 = Singleton::getInstance();//再写Singleton* p2 = Singleton::getInstance();或p3等,得到的依然是一模一样的地址
    
    //禁用拷贝构造后下面这个语句就会报错了
    //Singleton t = *p1;

    return 0;
}

结果如下:

构造函数调用
main开始

注意,main()前也会有代码要执行。这里的过程是这样的,编译期的时候,0初始化,给instance分配内存并各字段赋值为0,运行期的时候,在main前就运行了一些指令,初始化静态对象instance,执行了instance的构造函数(因为类外初始化的代码,初始化就是在构造对象且赋值),所以到执行getInstance()获取该实例前,对象就已经构造好了,这个就是懒汉式单例。
想想,在main前就已经有这个实例了,那肯定也是多线程安全的,因为这个时候也没创建多个线程,并不会重复构造实例。

饿汉式的缺点就是进程刚开始,main都没执行,就已经在构造实例了,若这个构造函数要做很多事,那导致系统启动卡顿。

二.单例模式的分类

饿汉式单例模式:还没有获取实例对象,实例对象就已经产生了

懒汉式单例模式:唯一的实例对象,直到第一次获取它的时候,才产生

前面的例子中,因为对象是static的,所以在函数没有被调用的时候,对象就已经存在且被构造好了(编译期就存在于全局/静态存储区——或者实际上是在操作系统的数据段,没有初始化的就是在数据段其中的.bss段,初始化了的就是在数据段的.data段)

所以上面是饿汉式的单例饿汉式单例一定是线程安全的,因为多线程就是不同线程都调用函数,而饿汉式单例的对象和函数没有关系,所以一定是线程安全的

但是饿汉式的问题是:不管调用不调用那些函数,用不用这个对象,它都会构造好该对象,而构造函数实际上在业务中可能会做很多事情,又要时间又要内存。

比如一个软件,在启动的时候,还没有用哪个功能呢,就已经在进行初始化了,这可能要卡很久。我们应该在需要使用该对象的功能的时候才实例化它

三.懒汉式单例模式与线程安全

因为饿汉式单例模式的缺点,我们应该用懒汉式单例模式

首先将static类型的对象改成指针,调用函数获取该指针时,首先判断指针是否为空,为空就new一个,不为空直接返回指针

我们初始化其为空,这样就需要new一个了,然后第二次调用该函数getInstance()就不为空了,然后直接返回。

class Singleton
{
public:
    static Singleton* getInstance()
    {
        if(instance == nullptr){
            instance = new Singleton();
        }
        return instance;//不为空则直接返回
    }
    
    
private:
    static Singleton* instance; 
    Singleton()    
    {
        
    }
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

};

Singleton* Singleton::instance = nullptr;//初始为空

int main(){
    Singleton* p1 = Singleton::getInstance();
    return 0;
}
但这个并不是线程安全的!!!

上述在单线程环境下没问题,因为单线程情况下函数getInstance()不会被同时调用,只能调用完,再调用

但是在多线程条件下,该函数不是可重入函数。多线程条件下,某个线程调用该函数,没有执行完即对象instance还没被创建,另一个线程也执行,发现instance为空,那也执行创建语句instance = new Singleton();,所以该函数不是可重入函数

可重入函数是指能够被多个线程“同时”调用的函数,并且能保证函数结果正确不必担心数据错误的函数

使用不可重入的函数就要考虑线程安全了

编译器执行该函数相当于做了三件事:

public:
    static Singleton* getInstance()
    {
        if(instance == nullptr)
        {
        	/*  
        	开辟内存
        	构造对象
        	给instance赋值
        	*/
            instance = new Singleton();
        }
        return instance;
    }

1)就像前面说的,当线程A还在执行new的时候,没有将new出来的对象赋值给instance,线程B又执行该函数发现instance还是nullptr,所以也执行临界区代码去new一个对象,违背了单例。

2)另外,实际上编译器为了加快代码执行速度,可能先赋值,再构造对象————也容易理解,比如该对象属性很多,构造比较久,先给该变量赋值为初始值默认值,再对该变量进行构造

    	开辟内存
    	给instance赋值
    	构造对象

所以,线程A执行开辟内存,对instance赋值,线程B执行到这发现instance不为空,直接return insatnce返回一个未经构造的对象,那后面访问这个未经构造的对象就会出错了,其属性和一些数据就是错误的

所以从上面两个方面来说,我们都得给临界区instance = new Singleton();加锁,不能让多个线程同时执行这一句

#include<mutex>
std::mutex mtx;//全局的锁
class Singleton
{
public:
    static Singleton* getInstance()
    {
        lock_guard<std::mutex> guard(mtx);//C++11新特性,锁管理工具
        
        if(instance == nullptr){
            instance = new Singleton();
        }
        return instance;
    }

std::lock_guard属于C++11特性,锁管理遵循RAII习语管理资源。原理是我们把mtx这个互斥锁赋值给lock_guard的对象guard,它会在它自己的构造函数里调用互斥体的lock()函数进行加锁,在析构函数里调用互斥体的unlock()函数进行解锁,封装好加锁解锁过程
guard是警卫,守卫的意思

这里guard对象是局部变量,函数执行完guard就失效,guard就会自己的执行析构函数,这个时候就会解锁——像只能指针

但是这个锁的粒度太大了,单线程环境下每次调用该函数也会执行加锁操作

所以还得修改,改成下面之后,单线程环境下只有第一次会加锁,第二次以后调用就不会加锁了:

public:
    static Singleton* getInstance()
    {
        if(instance == nullptr)
        {
            lock_guard<std::mutex> guard(mtx);//放到if里面
            instance = new Singleton();
        }
        return instance;
    }

这个情况下,出这个if的括号就会释放锁了,(return instance前面的括号)。这个也有问题,第二个线程阻塞后还是会执行new,所以需要加一个if判断。

锁+双重判断——养成习惯!!!
public:
    static Singleton* getInstance()
    {
        if(instance == nullptr)
        {
            lock_guard<std::mutex> guard(mtx);
            if(instance == nullptr)			//还要判断下是否为空
            {
                instance = new Singleton();
            }
            
        }
        return instance;
    }

这个instance是static的,存储在全局/静态变量区——操作系统的数据段是同一个进程,多个线程共享的数据

CPU在执行线程指令的时候,为了加快多线程的执行速度(或者说是先由编译器做的优化,然后落实到CPU上),会将这些线程共享的数据都拷贝一份到自己的线程寄存器上——我们这里就是instance对象

所以我们要加一个关键字**volatile**

加了之后,当instance这个共享变量改变之后,就是告诉线程该去内存上去找该变量而不是取自己的寄存器上的。因为线程A先取了该变量到自己寄存器上,然后线程B把该变量修改了(修改后它也会同步到内存里),那么线程A要是还在自己的寄存器上取该变量,那读取到的就是未更新的。
所以volatile适合用来修饰随时都可能被修改的变量,告诉将要读取该变量的线程不要从自己的寄存器或者cache上取该变量,而是去内存上取。

下面就是线程安全的懒汉式单例模式:
#include<iostream>
using namespace std;
#include<mutex>

std::mutex mtx;//全局的锁

class Singleton
{
public:
    static Singleton* getInstance()
    {
        if(instance == nullptr)
        {
            lock_guard<std::mutex> guard(mtx);//C++ 11新特性,所管理工具
            if(instance == nullptr)     //双重判断
            {
                instance = new Singleton();
            }
            
        }
        return instance;
    }
 
private:
    static Singleton* volatile instance; //2.定义一个唯一的类的实例对象  //加上volatile
    Singleton()    //1.构造函数私有化
    {
        //省略构造函数的具体实现,实际业务中会有很多功能
    }
    Singleton(const Singleton&) = delete;//C++11中,定义成员函数,可在后面使用 = delete修饰,表示该函数被删除,禁用;
    Singleton& operator=(const Singleton&) = delete;

};

Singleton*volatile Singleton::instance = nullptr; //加上volatile

int main(){
    Singleton* p1 = Singleton::getInstance();
    
    return 0;
}

四.不用互斥锁的线程安全的懒汉单例模式

就是将该对象放在函数里,成为**静态局部变量**。注意,静态局部变量也是在全局区(C++内存模型的叫法),和静态全局变量,全局变量一样,是在编译器就被分配了内存。

但是静态局部变量的初始化是运行到该语句时,进行初始化

C和C++的处理还不一样

c语言,编译时分配内存和初始化的。

C++,编译时分配内存 ,(运行时)首次使用时初始化。

主要是由于C++引入对象后,要进行初始化必须执行相应构造函数和析构函数,在构造函数或析构函数中经常会需要进行某些程序中需要进行的特定操作,并非简单地分配内存。所以C++标准定为全局或静态对象是有首次用到时才会进行构造。

class CSingleton
{
public:
	static CSingleton* getInstance()
	{
		static CSingleton single; // 懒汉式单例模式,定义唯一的对象实例
		return &single;
	}
private:
	static CSingleton *single;
	CSingleton() { cout << "CSingleton()" << endl; }
	~CSingleton() { cout << "~CSingleton()" << endl;}
	CSingleton(const CSingleton&);
};
int main()
{
	CSingleton *p1 = CSingleton::getInstance();
	return 0;
}

也因为是调用该函数才会初始化该对象,没调用的话该对象不会调用构造函数,所以也是一个懒汉式单例模式。

多线程情况下:

函数静态局部变量的初始化,在汇编指令上已经自动添加线程互斥指令了

所以如果线程A调用该函数,在初始化未完成之前,线程B不会执行该初始化操作。线程A完成初始化后,已经初始化过的变量,其它线程不会再重复进行初始化操作,从而只有一个实例对象产生。

参考施磊老师的博客,可以用GDB查看汇编指令
在这里插入图片描述

———————————————————关于静态成员变量的初始化——————————————

上面说的太肤浅了。实际上对于这种自定义类型来说,无论全局静态变量还是局部静态变量,都是执行动态初始化,也就是都得在代码真正执行时,要调用了其构造函数才能初始化(C没有对象的概念,所以C都是编译期初始化,而)。
(简单点就是编译期分配内存且值赋为0或null,运行时构造对象并赋值— —静态对象都这样)

也就是实际上分两个阶:第一是编译时的零初始化(分配内存),第二是运行时的动态初始化

像static int a = 2;这种不需要构造函数来构造,那编译期就初始化好了(内存分配了,值也赋好了)
对于 static int a;这种没有初始化的,也是先分配内存并赋值为0,即0初始化。

其实只要知道静态和动态就好了。静态就是不需要运行程序,在运行前就能放好。动态初始化就得执行程序。

局部static只是在首次经过时初始化,而非首次经过时才分配空间;
静态变量都是在程序开始时就分配空间(main之前)

所以动态初始化的这些,也得先进行静态初始化中的0初始化——分配内存。

所以,自定义类型的静态变量无论全局还是局部,都是和未初始化的静态变量一起先被初始化为0,放入.bss段。代码段中放的就是编译好的程序。
在这里插入图片描述
所以.bss段其实一开始存放的都是0。值得一提的是,初始化为0静态变量也会放在.bss段。程序在执行之前,.bss段会自动清0.

.bss段中的自定义类型静态变量在程序运行后执行动态初始化,调用构造函数给各个成员变量赋值,初始化后也不会移动到.data段,毕竟操作系统已经给他们分配好内存在.bss段了,而可执行程序中也有相应的指针指向.bss中对应的内存。

  • 6
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值