单例模式保证一个类只有一个实例,并且提供了一个访问他的全局访问点。
我曾经在用 写一个小游戏时,需要记录玩家的信息和分数。在 中的一个方法是创建一个物体,将保存着玩家信息和分数的脚本挂载在这个物体上,然后在场景切换时指定物体不进行析构。
在使用了 这个 后,每次游戏场景切换时,虽然原本的物体不会析构了,但是可能会重新创建一个新的同名的物体从而引起错误。
显然玩家的信息和分数只有一份,如果同一个玩家的信息有多份,那么无论从逻辑上还是实现上都是不正确的,而且在任何时候都有可能需要这份信息,所以我们应该设计一个全局的类。
这些需求引出了单例模式这样的一个设计模式。单例模式保证了一个类只有唯一的一个实例。在有实例的情况下,任何试图新创建或者新复制一个实例的行为都会被拒绝。并且他提供了一个全局的得到唯一实例的入口。
单例模式分为两种类型:懒汉模式和饿汉模式。
懒汉模式
对象只有在第一次用到时被创建。叫做延迟初始化。
//1.0
class LazySingleton
{
private:
static LazySingleton *instance; //指向唯一实例的指针
LazySingleton() {} //禁用构造函数
LazySingleton(const LazySingleton &) {} //禁用复制构造函数
LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
~LazySingleton();
public:
static LazySingleton *GetInstance()
{
if (instance == nullptr) //如果没有实例则创建一个
instance = new LazySingleton();
return instance; //获得唯一实例
}
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化
非常简单,注释也写的十分清晰了。
问题一:内存管理
但是有个问题,在 中 出来的对象并没有析构。
这时我们想起来中的一句话,在构造函数中new出来的对象要在析构函数中 。
那我们大胆尝试一下:在析构函数中 掉 出来的指针会怎么样:
//bad
#include
using namespace std;
class LazySingleton
{
private:
static LazySingleton *instance; //指向唯一实例的指针
LazySingleton() {} //禁用构造函数
LazySingleton(const LazySingleton &) {} //禁用复制构造函数
LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
public:
static LazySingleton *GetInstance()
{
if (instance == nullptr) //如果没有实例则创建一个
instance = new LazySingleton();
return instance; //获得唯一实例
}
~LazySingleton()
{
cout << "Destructer" << endl;
if (instance != nullptr)
delete instance;
}
};
LazySingleton *LazySingleton::instance = nullptr;
int main()
{
LazySingleton *p = LazySingleton::GetInstance();
delete p;
}
运行后发现程序不断输出 直到奔溃,说明这样写析构函数肯定是不对的。在一开始学习时我也有这样一个疑问,查阅资料并且仔细分析后发现,这个静态指针是指向自己的对象,当调用 时,首先会调用自己的析构函数,在析构函数中再次 ,那么又会再次调用自己的析构函数,这样就形成了无穷递归。
实际上和复制构造函数传参的道理差不多,在自己的函数中调用自己。
那么既然无法直接在析构函数中 ,我们有其他的两种手段:
- 智能指针
- 使用静态的嵌套对象
其实思想是差不多的,既然自己无法析构自己,那么我们可以通过其他的一个类来析构。
//1.1
class LazySingleton
{
private:
static LazySingleton *instance; //指向唯一实例的指针
LazySingleton() {} //禁用构造函数
LazySingleton(const LazySingleton &) {} //禁用复制构造函数
LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
~LazySingleton();
class Garbo //类内的嵌套类
{
public:
~Garbo()
{
cout << "Garbo!" << endl;
if (LazySingleton::instance != nullptr)
delete instance;
}
};
static Garbo garbo;
public:
static LazySingleton *GetInstance()
{
if (instance == nullptr) //如果没有实例则创建一个
instance = new LazySingleton();
return instance; //获得唯一实例
}
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化
静态成员变量只有在程序结束时析构。但是这样是完全没有必要的,因为程序在结束时会自动摧毁所有变量,所以这个也没什么意义。实际上在 及以后已经有了一种完全正确且又方便好写的单例模式,后面也会提到。
问题二:多线程环境下
上面的 和 在单线程环境下都是正确的,但是在多线程环境下可能会出错。
试想两个线程 和 ,以 的程序为例,一开始指针为空,线程 进入 的判断中,判断为正确,准备迎接新 出来的实例,注意这时候 依然为空。这时被线程B抢占,同样通过了判断,准备迎接另一个 的实例。
这样就出现了两个实例,产生了错误。
容易想到加锁来保证只有一个线程进入创建实例的步骤。于是能得到下面的代码:
//2.0
mutex mu;
class LazySingleton
{
private:
static LazySingleton *instance; //指向唯一实例的指针
LazySingleton() {} //禁用构造函数
LazySingleton(const LazySingleton &) {} //禁用复制构造函数
LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
~LazySingleton();
public:
static LazySingleton *GetInstance()
{
lock_guard lock(mu);
if (instance == nullptr) //如果没有实例则创建一个
instance = new LazySingleton();
return instance; //获得唯一实例
}
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化
这是普通的加锁,当另一个进程进入后如果没有获取到锁则会被堵塞。
但是这样对性能产生的影响会很大,如果多个线程在同时获取实例时,同一时间只有一个线程能获取到。
注意到原来可能会发生错误的时候只有在创建第一个实例时才可能发生,那么我们可以进行优化,创建完第一个实例后如果再进行获取就不需要加锁了,从而得到了双检测锁模式。
//2.1
mutex mu;
class LazySingleton
{
private:
static LazySingleton *instance; //指向唯一实例的指针
LazySingleton() {} //禁用构造函数
LazySingleton(const LazySingleton &) {} //禁用复制构造函数
LazySingleton &operator=(const LazySingleton &); //禁用赋值运算符
~LazySingleton();
public:
static LazySingleton *GetInstance()
{
if (instance == nullptr)
{
lock_guard lock(mu);
if (instance == nullptr) //如果没有实例则创建一个
instance = new LazySingleton();
}
return instance; //获得唯一实例
}
};
LazySingleton *LazySingleton::instance = nullptr; //类外静态变量初始化
看上去非常完美了,实际上之前很长一段时间内各种专家也是这样认为的,直到后来有大神发现编译器在处理这段代码时可能会有 现象,也就是编译器在编译的时候并不一定会像人们通常想象那样产生指令。
可能会出现这种情况:
线程 正在 一个新的对象,指令执行时系统为对象分配了空间,然后把指向这段空间的指针返回给 ,但是这个时候还没来得及调用类的构造器对这段空间初始化,线程 抢占了时间片,这时的 已经不为空了,于是返回了一个指向还没初始化完的空间的指针。
所以上面的代码依然是有问题的,下面给出一个 的跨平台的实现, 可以通过 实现。
//2.2
//从课件上抄来的
std::atomic Singleton::m_instance;
std::mutex Singleton::m_mutex;
//保证先分配内存,再调用构造器,最后返回内存地址
Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);//获取内存fence
if (tmp == nullptr) {
std::lock_guard lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
std::atomic_thread_fence(std::memory_order_release);//释放内存fence
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}
可以看到较为繁琐,好在 已经要求函数内静态变量 的线程安全性。现在最通用也是最优美的实现方法如下:
//2.3
class LazySingleton
{
private:
LazySingleton() {}
LazySingleton(const LazySingleton &) {}
LazySingleton &operator=(const LazySingleton &) = delete;
public:
static LazySingleton &GetInstance()
{
static LazySingleton instance;
return instance;
}
};
这里返回了引用,实际上返回指针也行,差不多的。
- 保证了 的线程安全性,而且这个变量会自动在第一次调用时产生,省去了指针的判断,省时省力。
- 赋值构造函数声明为 意思是指定不产生默认的复制构造函数,从语言的层面杜绝赋值。
- 析构函数没写,直接用默认的,因为从逻辑上考虑,既然单例模式的对象是全局的,那么也不应该手动析构,在程序结束时会自动析构掉,所以也不需要写什么析构函数了。当然这条并不是绝对的,应该随机应变。
可以说这样的一个程序是目前较为优美的一个解决方案了。
饿汉模式
对象在程序运行前被初始化。
与懒汉模式相比,饿汉模式是用空间换取时间。
在程序运行前就初始化完成,每次调用时也不用判断或者新创建一个对象了,直接返回就完事,但是代价就是时刻占用这一个对象的内存。
//3.1
class EagerSingleton
{
private:
static const EagerSingleton *instance;
EagerSingleton() {}
EagerSingleton(const EagerSingleton &);
EagerSingleton &operator=(const EagerSingleton &);
public:
static const EagerSingleton *GetInstance()
{
return instance;
}
};
const EagerSingleton *EagerSingleton::instance = new EagerSingleton();//记得在主函数前初始化
相比懒汉模式,饿汉模式相对简单。
参考资料
https://zhuanlan.zhihu.com/p/37469260
大话设计模式