本文介绍: 本文将深入探讨单例模式定义实现优化详细介绍单例模式定义解释用途和特点。单例模式是一种结构型设计模式,旨在确保一个类只有一个实例,并提供全局访问点。将逐个介绍单例模式的不同实现版本及其优化,并讨论每个版本的优劣势。

接下来分析一下它的稳定点和变化点,解决什么问题解决什么问题主要来帮助分析具体设计模式解决什么问题

代码结构
(1)私有构造析构。单例模式和程序生命周期相同的,不希望newdelete存在应用程序退出时单例模式才会释放。所以,需要构造函数析构函数隐藏起来,让用户不能调用
(2)禁掉一些构造。把所有能构造方式关闭比如 拷贝构造拷贝赋值构造移动构造、移动拷贝构造。
(3)静态成员函数
(4)静态私有成员变量

结构图
在这里插入图片描述

四、单例模式的实现优化

接下来会了解一下单例模式的代码结构这里通过C++语言进行分析设计模式,所以会涉及到C++语言知识点别的多,单例模式在这里准备了六个版本来进行讲解一步一步的来看一下它分别隐藏了一些什么样的问题,以及是怎么去解决它的。刚刚也跟大家说了要解决的问题就是一个实例全局访问点,那么怎么来实现这个需求呢?这里再反复强调一下,稳定点是通过抽象里面稳定流程实现这个稳定点,让稳定点变得更加稳定;变化点通过扩展方式来进行扩展扩展分为两种:第一个通过继承第二个是通过组合,通常通过这两种方式扩展这些变化点。

类名对于用户而言就希望去利用它、去产生它。要实现一个只有一个实例,显然要关闭这一种行为,以及不希望用户去delete它,也不希望别人去delete我们创建的这一个对象,通常这个单例模式会跟应用程序生命周期是一模一样,应用程序退出时候,单例的对象才会得到释放。所以第一步要把这个构造函数析构函数隐藏掉,让别人不能够去调用它,用户不能够去构造这个对象,以及析构这个对象。

第二个需要去禁掉一些内容,因为单例模式是仅有一个实例,那么所有构造它的方式都要关掉它,主要有四种容易忽视的构造方式

  1. 拷贝构造。
  2. 拷贝赋值构造。
  3. 移动构造。
  4. 移动拷贝构造。

这四个是最容易被忽视的,它们又能够帮助去构建对象,所以呢也要把它进行限定住,让其他的用户不能够去调用它。

小结

下面给大家简单的来看一下几个代码。

4.1、版本一

class Singleton {
public:
    static Singleton * GetInstance() {
        if (_instance == nullptr) {
            _instance = new Singleton();
       }
        return _instance;
   }
private:
    Singleton(){}; //构造
    ~Singleton(){};
    // = delete 就是关闭这些行为
    Singleton(const Singleton &) = delete; //拷⻉构造
    Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
    Singleton(Singleton &&) = delete;//移动构造
    Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
    static Singleton * _instance;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化

在单例模式中必须要把拷贝构造、拷贝赋值构造、移动构造、移动拷贝构造这四个禁止掉。在C++11当中直接=delete可以把这些行为都给它关闭掉,这些构造都不能够去构造了,这个对象才能够实现一个类仅有一个实例,都不能够用这一个具体的实例去构造另外一个对象。

接下来要实现一个提供单个实例的全局访问点,全局访问点也就是说在项目当中各个地方都能够访问到该实例。如果是构造一个全局对象,全局对象不是很安全,因为是对象的话很容易被直接使用它(直接去调用构造、拷贝构造函数),现在它的构造都在private进行私有化了,构造不出来,所以前面代码中实现仅有一个实例的时候,它就已经没法实现这种全局对象了,因为所有可以构造的函数都已经放在private下面了,它不能够去生成一个全局对象,只能考虑从堆上去分配内存

因此,可以考虑用一个接口访问对象,提供全局访问点,并且是通过静态成员函数方式来实现访问这一个全局的访问点,这个具体的值放在堆上面。因为是静态成员函数,那么对应变量_instance也必须要是静态成员变量,因为如果不是静态成员变量的话,在静态成员函数里面是不能够访问具体对象的变量的,而只能访问静态全局变量,因此必须要是一个静态的成员变量。

静态成员变量必须要进行一个初始化然后可以通过一个if判断全家实例是否nullptr方式来实现一个全局的访问点,从而保证只有一个实例。因为如果instance == nullptr的话就会通过new Singleton,如果第二次再来调用的时候它肯定不等于指针,不等于空指针就直接返回了。这里通过这种方式,就实现了一个只有一个实例,并且提供了一个全局的访问点。

代码结构
(1)私有的构造和析构。
(2)把所有能构造的方式都禁掉。比如 拷贝构造、拷贝赋值构造、移动构造、移动拷贝构造。
(3)静态类成员函数。通过他来实现我们的全局访问点。
(4)静态私有成员变量。通过它来帮助在堆上去分配一下内存

从上面具体的代码实现当中思考一下存在哪些问题?
首先要注意到_instance在静态全局区进行分配的,它是静态全局分配的;也就是说程序退出的时候这个变量它是能够释放的,通过程序会自动进行释放的,但是这个_instance只是一个指针,它指向了一个堆上的资源,但是这个堆上分配的内存它是不能够回收的。也就是析构函数是不会被调用的,这肯定是有bug的(要注意到析构函数是空的,没必要释放这个对象的内存),如果这个单例当中操作了某一块文件,往文件中写内容,理论上程序推出的时候,这个单例要析构,调用这个析构函数,去把可能要将一些数据刷到文件当中操作继续操作完,然后把这个文件句柄close掉(即把它的资源释放掉),但是如果在析构函数这个地方什么都不处理的话(即这里的析构函数是根本不会被调到),那么文件就不会得到释放,有一些没有来得及写到文件里面内容也不会去写到文件里。

有的朋友可能想到了智能指针,这里使用智能指针是可以解决的,那么思考一下这个地方如果用智能指针的话应该使用什么样的智能指针?智能指针有shared_ptrunique_ptr,显然使用unique_ptr可以解决这一个问题的。

4.2、版本二

接下来先不使用智能指针,自己实现一份代码来解决一下上面的这个问题。

class Singleton {
public:
    static Singleton * GetInstance() {
        if (_instance == nullptr) {
            _instance = new Singleton();
            atexit(Destructor);// 当程序退出时调用atexit设置的Destructor函数
       }
        return _instance;
   }
private:
    static void Destructor() {
        if (nullptr != _instance) { //
            delete _instance;
            _instance = nullptr;
       }
   }
    Singleton(){}; //构造
    ~Singleton(){};
    Singleton(const Singleton &) = delete; //拷⻉构造
    Singleton& operator=(const Singleton&) =
delete;//拷贝赋值构造
    Singleton(Singleton &&) = delete;//移动构造
    Singleton& operator=(Singleton &&) =
delete;//移动拷贝构造
    static Singleton * _instance;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化
// 还可以使⽤ 内部类,智能指针来解决; 此时还有线程安全问题

析构函数没有被调用到的主要的原因没有地方去把这块内存给释放掉,现在增加一接口,在C语言当中有一个当程序退出的时候去回调指定方法接口atexit(...)可以在这个接口当中回调析构函数把相对应的内存释放掉,主动调用delete

这个就是第二个版本,其他的地方都没有改变,这里主要解决了一个内存泄露的问题。

4.3、版本三

在版本二中不支持多线程,它是一个单线程的,不支持多线程。虽然atexit(...)这个方法是一个线程安全的,但是整个类来说不是线程安全的。

可以马上联想到加锁操作,来看一下这个版本是怎么加锁的以及加在哪个地方。很显然哪个地方是临界资源,就给哪个地方加锁_instance = new Singleton();可能会产资源竞争,因为new具体对象的时候会产生资源竞争,需要在这个地方进行加锁

加锁的第一版实现代码:

#include <mutex>
class Singleton { // 懒汉模式 lazy load
public:
    static Singleton * GetInstance() {
    	// RAII
        std::lock_guard<std::mutex> lock(_mutex); // 3.1 切换线程
        if (_instance == nullptr) {
                _instance = new Singleton();
                // 1. 分配内存
                // 2. 调用构造函数
                // 3. 返回指针
                // 多线程环境cpu reorder操作
                atexit(Destructor);
       }
        return _instance;
   }
private:
    static void Destructor() {
        if (nullptr != _instance) {
            delete _instance;
            _instance = nullptr;
       }
   }
    Singleton(){}; //构造
    ~Singleton(){};
    Singleton(const Singleton &amp;) = delete; //拷⻉构造
    Singleton&amp; operator=(const Singleton&) =delete;//拷贝赋值构造
    Singleton(Singleton &&) = delete;//移动构造
    Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
    static Singleton * _instance;
    static std::mutex _mutex;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化
std::mutex Singleton::_mutex; //互斥初始化

按照这样的方式来进行加锁,使用了RAII的思想(即利用类的生命周期好来进行资源管理)。从效率优先角度思考,它仍然具有一些性能上的问题,还可以进行一个优化

对于这整个接口而言,只有第一次调用的时候是需要加锁的,调用这个类的接口的对象要获取这个类的对象的全局访问点,只有第一次调用的时候才需要给进行加锁,因为第一次才会涉及到写操作(会有一个赋值操作,分配一块内存,并且调用他的构造函数),其他的情况下都是读操作,而读操作是没有必要加锁的,所以上面的代码中会导致很多地方涉及到无用的加锁

那么怎么解决它呢?可以使用双重检测,这个是编程当中的经常用到的一个技术(双重检测double checking)。也就是上面的代码改为两次if (_instance == nullptr),而锁加在第一次和第二次检测之间

加锁的第二个版实现代码:

#include <mutex>
class Singleton { // 懒汉模式 lazy load
public:
    static Singleton * GetInstance() {
        if (_instance == nullptr) {
            std::lock_guard<std::mutex>	lock(_mutex); // 3.2
        	if (_instance == nullptr) {
                _instance = new Singleton();
                // 1. 分配内存
                // 2. 调用构造函数
                // 3. 返回指针
                // 多线程环境cpu reorder操作
                atexit(Destructor);
           }
       }
        return _instance;
   }
private:
    static void Destructor() {
        if (nullptr != _instance) {
            delete _instance;
            _instance = nullptr;
       }
   }
    Singleton(){}; //构造
    ~Singleton(){};
    Singleton(const Singleton &) = delete; //拷⻉构造
    Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
    Singleton(Singleton &&) = delete;//移动构造
    Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
    static Singleton * _instance;
    static std::mutex _mutex;
};
Singleton* Singleton::_instance = nullptr;//静态成员需要初始化
std::mutex Singleton::_mutex; //互斥初始化

对于第一次访问这个GetInstance()接口的时候才会涉及到资源竞争,也就是写操作,当出现两个及以上个线程同时进入第一个if (_instance == nullptr)里面,此时只有一个线程能够持有锁,持有锁的线程才会进去new这个对象,另外的线程会在加锁的地方等待,并且是在第二个if (_instance == nullptr)前面进行等待;当获得锁的线程new完对象后并结束持有锁的生命周期第二个线程就可以持有这把锁了,第二个线程持有这把锁的时候就会判断出第二个if (_instance == nullptr)此时不为空,那么就直接把它进行退出了。通过这种方式避免了多个线程同时进入的问题,同时又增加了一个对于后面来访问这个新对象的这些读操作直接是在第一个判断就出去了,因为我们这个对象已经被构造了,所以就直接出去了。

4.4、版本四

上面版本三虽然解决了线程安全问题,但是仍然存在一些问题。在如今的多核时代前面时代已经不一样了,C++ 98版本的语言语义基于单线程的,而C++11在它的基础上进行了一些封装,包括封装的这种线程操作,C++98线程操作都是用的标准外的一些函数,比如pthread_createpthread_mutex等,加锁都是利用标准之外的一些库来帮助加锁;C++11在这上面封装了一些线程安全的操作,比如std::mutex,还封装原子操作,比如std::atomic,以及内存栅栏。在多核时代,前面多线程操作在C++ 98版本都是有问题的,因为在多核时代会进行一些优化,包括编译器重排,CPU重排等。这样子一来会带来了一个问题,可能会违反顺序一致性前面写的语句必须要在后面的语句之前执行),那么在多核时代,编译器跟CPU都会进行一个优化,它会让程序能以最快的方式来执行;只要不影响后面结果的情况下,可能第二条语句执行会在第一条前面,但是这个不影响结果是因为它是考虑到对于CPU运行而言的,这时就会产生一些问题:

  1. 可见性问题。
  2. 执行序问题。

C++ 11为了解决这些问题提供了一个同步原语,除了同步原语还有锁。这个同步原语分为两个部分第一个是原子变量,第二个是内存栅栏啊,或者叫内存屏障。下面就会利用两个技术来帮助解决问题

版本三虽然加了一把锁,但是它没有考虑到可能会出现这种CPU的进行一个指令重排,在这里CPU指令重排会出现在这个new操作,也就是_instance = new Singleton();一条语句,在汇编当中它是由多个指令构成的,而且new是一个操作符,它不是函数,在具体类当中都会有一个操作符的实现,这个操作符呢,默认的情况下,第一步先分配内存,然后去调用它的构造函数,最后返回指针。在多核环境下,CPU会帮助进行一个指令重排,在这个语句当中可能会重排成先调用1、3,然后再调用2,然而本来在单核环境下调用顺序是1、2、3。这时可能就会出现内存异常问题,调用1、3、2也就意味着到3时已经有内存化的地址就直接返回了,但是此时它还没有去构造数据没有构构造数据当另外一个线程走到第一个if (_instance == nullptr)发现_nstance实例不为空,不为空它就返回出去,返回出去它就可能要操作这个对象数据,操作对象数据就会发现这个构造函数都还没有构造,那里面虽然内存指针确实指向了一块区域,但是这块区域没有被初始化,此时去调用里面的数据的时候就可能出现异常,进而导致程序奔溃。

版本三中的加锁只是从单线程语义,就是C++ 98的时候思程序的一种方式,对于现在现代语言,也就是多核时代,这种思考还不够,还要考虑这种指令重排的问题,考虑怎么用C++的语言特性来解决这个问题,即通过C++ 11提供的一些同步原语来帮助解决这个问题。

#include <mutex>
#include <atomic>

class Singleton{
public:
	static Singleton *GetInstance(){
		Singleton *tmp=_instance.load(std::memory_order_relaxed);
		std::atomic_thread_fence(std::memory_order_acquire);//获取内存屏障
		if(tmp==nullptr)
		{
			std::lock_guard<std::mutex> lock(_mutex);
			tmp=_instance.load(std::memory_order_relaxed);
			if(tmp==nullptr){
				tmp= new Singleton;
			
				std::atomic_thread_fence(std::memory_order_release);// 释放内存屏障
				_instance.store(tmp,std::memory_order_relaxed);
				atexit(Destructor);
			}
		}
		return tmp;
	}
private:
	static void Destructor(){
		Singleton* tmp=_instance.load(stdd::memory_order_relaxed);
		if(nullptr!=tmp)
			delete tmp;
	}
	Singleton(){};
	~Singleton(){};
	Singleton(const Singleton &) = delete;
	Singleton& operator=(const Singleton &) = delete;
	Singleton(Singleton &&)=delete;
	Singleton& operator=(Singleton &&)=delete;
	
	static std::atomic<Singleton*> _instance;
	static std::metex _mutex;
};

std::atomic<Singleton*> Singleton::_instance;//静态成员变量需要初始化
std::mutex Singleton::_mutex;//互斥锁初始化
// 编译
// g++ Singleton.cpp -o singleton -std=c++11

这里使用到一个原子变量,我们把这个具体的指针对象的指针加上std::atomic<>类型的一个原子变量,现在的_instance是一个原子变量,原子变量解决了三个问题:
(1)原子执行的问题。也就是同一时间只有一个线程执行它。
(2)可见性问题。原子变量提供了load(可以看见其他线程最新操作的数据)和和store修改数据让其他线程可见)来解决。 store通常是写操作,store操作目的是在线里面操作修改的数据能够让其他线程对这个数据是可见的,这里面要涉及到内核知识,这里就不讲特别复杂;在这里有一级缓存二级缓存、三级缓存(只是核心的私有缓存),在这里修改数据其他线程是不可见的, store作用就是让其他线程可以看到数据的修改load()是可以看见其他线程最新操作的数据。
(3)执行绪问题。使用内存模型解决,memory_order_acquirememory_order_release。C++ 11给了六个内存模型,即六种内存序,这里只给大家解释两个内存序。memory_order_acquire通常对应的读操作,它的意思是它后面的语句不能够优化到外面去(即这一个语句的上面),因为有CPU指令重排,所以这个指令要求它不能够优化到上面去;memory_order_release意思是它上面的代码不能够优化到它下面来;这两个一起使用的就是它们中间的代码既不能出去(不能在往上面去)也不能够往下面去。

内存栅栏不是具体的原子变量,它主要解决了可见性的问题跟执行序的问题。

解决了这个原子序的问题后,安全性就解决了,多线程环境下单例模式这个时候就彻底没有问题了。

4.5、版本五:最安全、最精简的单例模式

上面版本四写的太复杂了,写一个安全线程的代码太长了,如果有多个类是构造函数,那写代码的时候就有一点要抓狂的;那么有一个更精简的方式:直接使用静态成员变量,不使用指针。主要是利用了c++11 的 magic static 特性:如果当变量在初始化的时候,并发同时进⼊声明语句,并发线程将会阻塞等待初始化结束

// c++11 magic static 特性:如果当变量在初始化的时候,并发同时进⼊声明语句,并发线程将会阻塞等待初始化结束
// c++ effective
class Singleton
{
public:
    static Singleton& GetInstance() {
        static Singleton instance;
        return instance;
   }
private:
    Singleton(){}; //构造
    ~Singleton(){};
    Singleton(const Singleton &) = delete; //拷⻉构造
    Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
    Singleton(Singleton &&) = delete;//移动构造
    Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
};
// 继承 Singleton
// g++ Singleton.cpp -o singleton -std=c++11
/*该版本具备 版本4 所有优点:
1. 利⽤静态局部变量特性延迟加载;
2. 利⽤静态局部变量特性系统⾃动回收内存,⾃动调⽤析构函
数;
3. 静态局部变量初始化时,没有 new 操作带来的cpu指令
reorder操作;
4. c++11 静态局部变量初始化时,具备线程安全;
*/

版本四写了一个很长的代码才实现了一个安全的单例模式。其实可以利用C++ 11的特性用最简单的方式来实现一个安全的单例模式,并且是一个线程安全的。主要利用了C++11的magic特性,由c++ effective这个作者提出来的。这是一种最安全的、最精简、最简单的一个单例模式(直接用一个静态的全局变量构造对象,因为静态的全局变量只会初始化一次,并且是多线程安全的,最重要的是它不会进行CPU指令重排和在生命周期时可以调用析构)。

该版本具备 版本4 所有优点:

  1. 利⽤静态局部变量特性,延迟加载
  2. 利⽤静态局部变量特性,系统⾃动回收内存,⾃动调⽤析构函数。
  3. 静态局部变量初始化时,没有 new 操作带来的cpu指令reorder操作。
  4. c++11 静态局部变量初始化时,具备线程安全。

4.6、版本六:可复用

template<typename T>
class Singleton {
public:
	static T& GetInstance() {
        static T instance; // 这⾥要初始化DesignPattern,需要调⽤DesignPattern 构造函数,同时会调⽤⽗类的构造函数。
        return instance;
   }
protected:
    virtual ~Singleton() {}
    Singleton() {} // protected修饰构造函数,才能让别⼈继承
private:
    Singleton(const Singleton &) = delete; //拷⻉构造
    Singleton& operator=(const Singleton&) =delete;//拷贝赋值构造
    Singleton(Singleton &&) = delete;//移动构造
    Singleton& operator=(Singleton &&) =delete;//移动拷贝构造
};

class DesignPattern : public Singleton<DesignPattern> {
	//friend 能让Singleton<T> 访问到 DesignPattern构造函数
    friend class Singleton<DesignPattern>; 
private:
    DesignPattern() {}
    ~DesignPattern() {}
};

这个版本就是在版本的基础上添加多态,因为还有一个变化点没有解决,如果项目当中有多个类都是单例,能不能够去复用这个代码呢?因为不想每一个单例都跑去实现这样的一个静态的变量、静态的函数,还要写这么多这种把它拷贝构造、拷贝赋值、移动构造、移动拷贝构造全部把它给关闭掉,太复杂了,想去重复利用这一块代码,这个时候只能使用模板来实现了,通过模板加继承的方式去解决这一个变化点的问题,因为变化点的扩展通常是通过继承的方式来扩展它,并且加入多态的方式。用上面在版本五的基础上来进行迭代,使用友元类可以让基类用到子类的构造函数(因为构造函数声明了private,不设置友元会无法正常调用)。

总结

思维导图:
在这里插入图片描述

在这里插入图片描述

原文地址:https://blog.csdn.net/Long_xu/article/details/134625887

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任

如若转载,请注明出处:http://www.7code.cn/show_45156.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱suwngjj01@126.com进行投诉反馈,一经查实,立即删除

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注