文章目录
前言
本文将对单例设计模式进行一个详细的分析,以及其各种版本实现的推演。网上大部分仅仅讲到了懒汉模式,饿汉模式,以及仅一层对对象实例化时加的锁,本文将会涉及到以上内容并融合设计模式的原则,内存屏障等内容对单例设计模式进行详细讲解
一、单例设计模式介绍
**定义**:保证一个类仅有一个实例,并提供一个该例的全局访问点(很多地方也叫自行实例化并向外提供)。 因为要求一个类仅有一个实例,意味着该类只能实例化一次,因此我们可以将类实例化对象声明为静态成员变量,将类的构造函数声明为私有成员。 **应用实例**:1、在多线程多进程操作文件过程中,不可避免地出现多个进程或线程同时操作一个文件的现象,所以所有的文件的处理必须通过唯一的实例来进行; 2、一个设备管理器常常设计为单例模式,比如一个电脑有两台打印机,在输出的时候就要处理不能输出到两台打印机,只能一台打印机打印文件; 3、要求生产唯一序列号的时候; 4、web中的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来。 5、创建的一个对象需要消耗的资源过多,比如I/O与数据库的连接二、单例模式的实现
1、写法1,最简单的饿汉模式写法
所谓的懒汉模式还是饿汉模式,其实就是看对象是在用的时候再去实例化,还是类成员变量中就进行实例化。
一个最简单的饿汉单例模式写法如下:
class singleton
{
public:
//在本类的静态成员函数中,返回该对象
static singleton get_instance()
{
return single_instance;
}
//创建并初始化一个singleton静态对象
private:
//私有化构造函数,意味着只有本类的成员函数能调用它,外部不能调用
singleton(){};
//在类加载时,就实例化对象
static singleton single_instance = new singleton();
}
饿汉单例模式优点在于,
(1)实现简单;
(2)加载时就进行了实例化,因此不会有线程安全问题;
(3)类释放时静态成员就会被自动销毁,因此不会有内存泄漏问题;
缺点:
1、类加载时静态变量就被创建并分配内存,即使后面用不到该实例,该内存也会一直存在,是不必要的资源消耗
2、类加载时(在编译过程中)对象就与类进行了绑定,耦合性高;
写法2–最简单的懒汉模式写法
为了解决写法一的不足,我们可以实现如下代码,我们先来看代码,再来讲解其中的优缺点
class singleton{
public:
static singleton *get_instance()
{
if(_instance == nullptr)
{
_instance = new Singleton();
}
return _instance;
}
private:
Singleton(){}
//加拷贝构造函数,防止赋值运算
Singleton(const Singleton &clone){}
Singleton& operator=(const Singleton&){}
static singleton *_instance;
}
//静态成员需要初始化
Singleton *Singleton::_instance = nullptr;
我们这次将不直接在类中实例化类的对象,而是声明一个类的指针,用来指向类的实例化对象。类的实例化只有在获取该指针的时候才会进行。这里值得注意的一个细节的点是,我们声明了一个拷贝构造函数和一个=的重载操作,作用是防止外界以及成员、友元对对象进行拷贝构造操作,保证了实例的唯一性。
优点:
(1)我们在调用get_instance时才会实例化对象,就避免了在用不到实例时不必要的内存消耗。(这就是所说的懒汉模式)
(2)实际调用的时候才会去实例化对象,晚绑定,耦合性低,但目前这种写法还体现不出晚绑定的优势,因为这种写法存在一个致命缺点,见缺点(1)
缺点:
(1)实例化的对象申请的内存没有得到释放,会造成内存泄漏;
(2)多进程多线程环境中,因为_instance = new Singleton();并不是一个原子操作,实际上它至少包含3个操作:分配内存,调用构造函数,赋值操作,多核多线程环境下就容易出现不单一的实例化对象被构造出来。比如线程一执行到了构造,但还没赋值就发生了线程切换,线程2这时拿到_instance还是nullptr,因此也会进行构造。对这里不太理解的可以移步我的另外一篇文章并发计数原理及无锁实现
首先我们来看看第一个问题怎么解决,请接着看写法3。
写法3–解决2中的内存泄漏问题
实现方式一:自定义成员函数销毁对象内存
写法3也是懒汉模式,但我们通过定义一个成员函数,在该函数中释放释放我们申请的对象内存,如下:
class singleton{
public:
static singleton *get_instance()
{
if(_instance == nullptr)
{
_instance = new Singleton();
atexit(Destructor);
}
return _instance;
}
private:
static void Destructor(){
if(nullptr != _instance)
{
delete _instance;
_instance = nullptr;
}
}
Singleton(){}
//加拷贝构造函数,防止赋值运算
Singleton(const Singleton &clone){}
Singleton& operator=(const Singleton&){}
static singleton *_instance;
}
//静态成员需要初始化
Singleton *Singleton::_instance = nullptr;
我们通过定义一个私有的成员函数Destructor(),在程序正常退出的时候调用该函数,释放我们new开辟的对象内存。
写法3其实有多种实现方式,还可以通过内部类,智能指针的方式来实现,如下:
实现方式2–内部类
通过内部类的方式来实现也是另外一种比较常用且巧妙的方法:
class singleton{
public:
static singleton *get_instance()
{
if(_instance == nullptr)
{
_instance = new Singleton();
static destructor singleton_destructor;
}
return _instance;
}
class destructor
{
public:
~destructor()
{
if(singleton::_instance != nullptr)
{
delete singleton::_instance;
singleton::instance == nullptr;
}
}
};
private:
Singleton(){}
//加拷贝构造函数,防止赋值运算
Singleton(const Singleton &clone){}
Singleton& operator=(const Singleton&){}
static singleton *_instance;
}
Singleton *Singleton::_instance = nullptr;
这里在singleton中定义一个内部类destructor,内部类没有其他内容,只有一个析构函数的实现,在get_instance()中,定义一个静态的destructor对象,程序退出时该静态对象被系统回收,回收的过程会调用destructor类的析构函数,在析构函数中会delete掉singleton的实例化的对象空间。智能指针的方式此处不继续展开了,因为智能指针本身就是为了解决内存释放的问题而提出的,感兴趣的可自行去了解实现。我们接着来说我们写法3还有的缺点,也是我们在写法2中提到的,线程安全的问题,多个线程的同步。为了解决这个问题,我们再优化一下,来看写法4
写法4–解决2、3中的多线程同步问题问题
class singleton
{
public:
static singleton *get_instance()
{
//如果再这里加锁,粒度太大,我们不必每次都加锁判断是否为空
//互斥锁会引起上下文切换,在这里加锁就会导致已经初始化的_instance,本不必再加锁,现在还多了上下文切换的开销
//std::lock_guard<std::mutex> lock(_mutex);
if(_instance == nullptr)
{
std::lock_guard<std::mutex> lock(_mutex);
//这里再加一次判断,因为我们是刚加完锁,与上面的判断是否为空结合起来
//假如不加判断,线程1在执行完判断后发生切换,线程2拿到锁进行了对象实例化,再次切换回来又会造成实例化了多个对象
//内层判断只会在初次初始化时执行
if(_instance == nullptr)
{
_instance = new singleton();
atexit(destructor);
}
}
return _instance;
}
private:
static void destructor()
{
if(nullptr != _instance)
{
delete _instance;
_instance = nullptr;
}
}
singleton(){}
singleton(const singleton &cpy){}
singleton& operator=(const singleton&){}
static singleton *_instance;
static std::mutex _mutex;
};
singleton* singleton::_instance = nullptr;
//互斥锁初始化
std::mutex singleton::mutex;
代码中我作了比较详细的注释,所以这里就不赘述了。可能有的读者在其他博客中,单例设计模式的写法也差不多结束了。但其实写法4还是会存在问题,但在介绍写法4之前,需要补充个内存模型与内存屏障的知识,本来不准备写,纠结再三还是写一下。
内存模型与内存屏障简介
内存模型与对象模型的区别
很多人听到内存模型容易与c++对象模型弄混淆,有的博客介绍对象模型的直接就写的内存模型。其实这是两个东西,对象模型主要讲的是对象的内存布局,感兴趣的可以自行去看《C++对象模型》,也是一本很经典的c++必读书籍。但内存模型说的是,在多核多线程下,各种CPU是如何以一种统一的方式来与内存交互的。
CPU的高速缓存与内存可见性,指令乱序问题
CPU与内存之间的数据交换,并不是直接发生的,而是通过高速缓存进行的,通常多处理器下每个CPU都有自己的高速缓存,存储在这个缓存下的数据,其他CPU无法查看,一般编程的时候,我们都只操作内存,而感受不到高速缓存的存在,但在多核多线程的环境下,存在一个问题,当CPU1修改了一个变量a,保存在本地缓存中,其他的CPU如何发现这个修改呢,怎么来保证其他CPU的该变量值是最新的呢?注意这里与我们多线程对临界资源区的不同在于,我们编程时,变量a对我们来说表现是唯一的,是CPU对我们开放的,但在多核控制器中,不同的CPU也就有不同的a,只有保证每个CPU上的a都是最新的,我们的变量a才显得唯一。而不同CPU如何知道这个a值是否被修改呢,这就需要保证其缓存可见性与缓存的一致性,缓存的一致性CPU是通过缓存一致性协议来保证的,CPU1准备更新变量a,但发现其他CPU也持有a的副本(shared状态),那么当CPU-0在更新前,需要通过一个disable消息,告诉其他CPU变量a变成了脏数据,不可用,其他CPU回应一个ack,CPU1收到后,才能开始更新。然而还有一个更糟糕的点,现代的CPU允许指令的一定程度乱序。就拿我们写法4来做例子,上文我们说过我们的new操作,其实是可以拆分为(1)分配内存;(2)调用构造函数;(3)赋值;三个操作的,假如这三个操作都是原子的基础上,我们说允许一定程度的乱序是值,可能执行顺序变成(1)(3)(2),假如我们有线程1此时拿到互斥锁,执行到(3)这时我们的_instance已经不等于nullptr了,此时线程2假如检测到nullptr不等于空,就会直接返回,但对象此时是还没实例化的。所以就有可能出错。乱序是怎么产生的呢,CPU在执行指令(2)的时候,发出了disable,此时并不是干等着,它可能先跳到(3)来执行,因此就出现了乱序。既然出现了指令乱序,我们怎么解决呢,这就是内存屏障的作用了。
我们加了内存屏障,指令就不会被CPU进行优化执行。
而我们所说的内存模型,就是不同平台下的对编程语言和编译器生成的合适的一种抽象,作用就是让我们在编写多线程代码时,不会因为CPU的不同,而要去了解每一个CPU的细节。
但对于C++来说,内存屏障只在C++11及之后的版本有。
写法5–解决写法4指令乱序问题
我们在内存模型的介绍中已经说明了写法4可能会出现的指令乱序问题,那么我们接着来看这种问题的解决实现方案
#include <mutex>
#include <atomic>
class singleton
{
public:
static singleton* get_instance
{
singleton *tmp = _instance.load(std::memory_order_relaxed);
//获取内存屏障
std::atomic_thread_fence(std::memory_order_acquire);
if(tmp == nullptr)
{
std::lockguard<std::mutex> lock(_mutex);
tmp = _instance.load(std::memory_order_relaxed);
if(tmp = nullptr)
{
tmp = new singleton;
//释放内存屏障
std::atomic_thread_fence(std::memory_order_relaxed);
atexit(destructor);
}
}
return tmp;
}
private:
static void Destructor()
{
Singleton* tmp = _instance.load(std::memory_order_relaxed);
if (nullptr != tmp)
{
delete tmp;
}
}
Singleton(){}
Singleton(const Singleton&) {}
Singleton& operator=(const Singleton&) {}
static std::atomic<Singleton*> _instance;
static std::mutex _mutex;
};
std::atomic<Singleton*> Singleton::_instance;
std::mutex Singleton::_mutex;
有耐心看到这里的读者是不是感觉怎么越写越复杂了,我就想用个简单的,下面我们就来写一个几乎具备前面所有优点的简洁版。
写法6–简洁不简单的写法
到前面我们已经解决了所有可能出现的问题,唯一的缺陷可能就是实现变得复杂了,那么我们现在来看一个实现较简单但具备前面几乎所有优点的写法,这种写法利用的是C++11的magic static特性:如果当变量在初始化时,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。
class Singleton
{
public:
~Singleton(){}
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
private:
Singleton(){}
Singleton(const Singleton&) {}
Singleton& operator=(const Singleton&) {}
};
这种写法,利用将静态局部变量放到get_instance中,在使用时才会加载,同时由于是static,系统会自动回收内存,自动调用析构函数,初始化时也不存在new操作带来的cpu乱序和多线程安全问题。
但唯一的一个小缺陷就是不太满足我们的设计模式原则,将对象和类绑定到了一起,耦合性较高。此外,前面所有的版本,我们都将构造函数定义成私有的,意味着我们都无法继承。因此我们还有一个终极的版本写法:
写法7–模板+友元,让单例的继承成为可能
template<typename T>
class singleton
{
public:
static T& get_instance()
{
//这里会初始化child_singleton,会调用到child_singleton的构造函数和父类的构造函数
static T instance;
return instance;
}
virtual ~singleton(){}
//拷贝构造函数需要设置为public,派生类实例化的时候会调用到
singleton(const singleton&){}
singleton &operator =(const singleton&){}
protected:
//singleton()构造函数设置为protected,才能被继承
singleton(){}
};
class child_singleton : public singleton<child_singleton>
{
//friend让singleton<T>能访问到child_singleton的构造函数
friend class singleton<child_singleton>;
private:
child_singleton(){}
child_singleton(const child_singleton&){}
child_singleton& operator=(const chile_singleton&){}
}
这种写法就不仅让我们能实现一个类只存在一个实例,同时可以实现不同类的单例模式。可以说是最优写法。
总结
文章主要是以一种带着问题,解决问题的思路去引领剖析各种单例模式的实现,如果对读者有帮助,自然是最好的。