目录
单例模式是C++中最常用的一种设计模式,他确保无论在单线程还是多线程中,都只会有一个实例对象,并提供一个全局访问点。这种模式在配置管理,日志记录,设备驱动等场景中非常有用。本文将从思维迭代的方式,一步步完善单例模式,方便大家理解和记忆。
分析一个设计模式,通常要从稳定点和变化点入手。对于单例模式其稳定点显然是一个类要提供一个一个全局的访问方式,且不允许外部任意构造、拷贝。而变化点理论上是没有的,但是我们强行认为变化点在于使用继承+模板来扩展单例模式。
一、版本一:禁用构造与拷贝
既然单例模式只能提供一个全局访问点,且不能让其他人随意创建,那么很显然需要禁用其构造函数、拷贝构造等。当然这里是懒汉模式,如果你采取饿汉模式,直接在静态区创建单例对象,而非创建单例指针,则可以避免析构调不到的问题。下面我们来分析一下:
class Singleton
{
public:
//获取全局访问点
static Singleton* GetInstance()
{
if (_instance==nullptr)
{
_instance = new Singleton();
}
return _instance;
}
private:
static Singleton* _instance; //全局访问点
//私有化各种构造函数,防止外面任意创建对象
private:
Singleton() {}; //构造
~Singleton() {}; //析构
Singleton(const Singleton&) = delete; //拷贝构造
Singleton& operator=(const Singleton&) = delete; //赋值运算符重载
Singleton(Singleton&&) = delete; //移动构造
Singleton& operator=(Singleton&&) = delete; //移动赋值运算符重载
};
Singleton* Singleton::_instance = nullptr; //初始化静态成员
我们可以看到这个代码存在一些问题。比如他不能自动调用析构函数(即使我们把析构函数public,再手动调用也会出现信号等情况没有执行到这里就退出了),因为单例对象的虽然是创建在堆上的,但是其指针在全局静态区。
当程序声明周期到达、或者以外收到信号退出的时候,该进程的地址空间虽然会被操作系统回收,仅仅会对这个指针销毁,无法析构其指向的内容(堆上的对象必须要手动调用delete才会被析构)。
既然无法调用到析构函数,那么其析构的执行流也无法被执行。当他的析构函数涉及到文件操作、网络连接等资源。比如关闭文件描述符、刷新文件缓冲区到内核态时就会出问题。举个例子:日志对象是一个单例对象,他打开了一系列文件,正常情况下手动调用析构函数会正常关闭文件描述符,而关闭文件描述符是一个把用户态文件缓冲区刷新到内核态的步骤,如果没有close文件描述符,操作系统会直接回收资源,并不管你用户态的缓冲区是否有数据没有刷新,即你丢失了这部分数据。
二、版本二:注册析构函数/嵌套垃圾回收
既然版本一存在这种明显的无法正确析构的问题。而在c库中有一个atexit,它可以向操作系统注册一个函数,该函数仅会在程序正常终止时被调用。
(1)使用atexit注册程序结束时的函数
class Singleton
{
public:
//获取全局访问点
static Singleton* GetInstance()
{
if (_instance == nullptr)
{
_instance = new Singleton();
atexit(Destructor);
}
return _instance;
}
private:
static void Destructor()
{
if (_instance != nullptr)
{
delete _instance;
_instance = nullptr;
}
}
private:
static Singleton* _instance; //全局访问点
//私有化各种构造函数,防止外面任意创建对象
private:
Singleton() {}; //构造
~Singleton() {}; //析构
Singleton(const Singleton&) = delete; //拷贝构造
Singleton& operator=(const Singleton&) = delete; //赋值运算符重载
Singleton(Singleton&&) = delete; //移动构造
Singleton& operator=(Singleton&&) = delete; //移动赋值运算符重载
};
Singleton* Singleton::_instance = nullptr; //初始化静态成员
(2)使用对象嵌套垃圾回收
利用GarbageCollector静态全局对象在程序正常结束的时候,会自动调用其析构函数,而在他的析构函数中又调用了单例对象的析构函数,从而完成回收。简单来说就是利用了智能指针RAII的思路。
class Singleton {
private:
static Singleton* _instance;
// 嵌套垃圾回收类
class GarbageCollector {
public:
~GarbageCollector() {
if (Singleton::_instance != nullptr) {
delete Singleton::_instance;
Singleton::_instance = nullptr;
}
}
};
static GarbageCollector _gc; // 全局静态成员,程序结束时自动析构
Singleton() {
std::cout << "Singleton created" << std::endl;
}
~Singleton() {
std::cout << "Singleton destroyed" << std::endl;
}
public:
static Singleton* GetInstance() {
if (_instance == nullptr) {
_instance = new Singleton();
}
return _instance;
}
// 禁用拷贝和移动操作
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
};
Singleton* Singleton::_instance = nullptr;
Singleton::GarbageCollector Singleton::_gc;
三、版本三:线程安全
虽然版本二在单线程场景下已经足够使用。但在多线程情况,却会出现重复走到if,然后创建多个对象的竞态问题。
这个代码中使用到了双重检测机制。即使在没有创建单例对象的时候,多个线程进入了第一个if里面,然后会因为锁竞争只能有一个线程执行到第二个if里面去创建单例对象。当他释放锁后,别的线程会继续竞争锁并判断是否为nullptr。如果为空则退出。
所以在这个代码中,只会有第一次n个线程进入if后的n次加锁、解锁开销。
// 双重检查锁定(DCL)
class Singleton
{
private:
static Singleton* instance;
static std::mutex mtx;
Singleton() {}
public:
static Singleton* getInstance() {
if (instance == nullptr) { // ① 第一次检查(无锁)
std::lock_guard<std::mutex> lock(mtx); // 加锁
if (instance == nullptr) { // ② 第二次检查(有锁)
instance = new Singleton();
atexit(Destructor);// 向操作系统注册析构函数
}
}
return instance;
}
private:
static void Destructor()
{
if (instance != nullptr)
{
delete instance;
instance = nullptr;
}
}
private:
//禁用各种构造
Singleton() {}; //构造
~Singleton() {}; //析构
Singleton(const Singleton&) = delete; //拷贝构造
Singleton& operator=(const Singleton&) = delete; //赋值运算符重载
Singleton(Singleton&&) = delete; //移动构造
Singleton& operator=(Singleton&&) = delete; //移动赋值运算符重载
};
四、版本四:编译器、CPU指令重排问题
解决了多线程竞态问题后,发现编译器、CPU会按照单线程的执行思想,自以为是的优化执行顺序,这就导致了new本身可能乱序。
new操作符在底层会分为三个步骤:
其中operator new是基于内存池的,所以他是线程安全的。而构造对象这一步是程序员手动执行的,既不线程安全,执行顺序也不能保证。
编译器或 CPU 为了优化性能,可能把步骤 3 调整到步骤 2 之前,变成:
所以我们需要使用内存屏障来保证执行流的可见性问题。
同时由于对普通指针 instance
的读写不是原子操作。在多线程环境下,可能出现线程 A 写入指针的 “中间状态”(比如只更新了低 32 位),线程 B 读取时拿到一个无效的指针值,直接崩溃。所以用原子操作解决原子性问题。
class Singleton
{
public:
static Singleton* getInstance() {
Singleton* tmp = instance.load(std::memory_order_acquire); // 读操作
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(mtx);
tmp = instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton();
// 写操作:禁止重排,保证构造完成后再赋值
instance.store(tmp, std::memory_order_release);
}
}
return tmp;
}
private:
// 用 atomic 修饰指针,禁止指令重排
static std::atomic<Singleton*> instance;
static std::mutex mtx;
};
std::atomic<Singleton*> Singleton::instance(nullptr);
std::mutex Singleton::mtx;
关于这里的内存屏障、原子操作只需要大致认识即可,后续会有文章详细讲解。
虽然这种方式已经足够,但写起来太过繁琐。
五、版本五:局部静态变量线程安全性
在C++11后规定magic static的特性:
- 局部静态变量(如
static Singleton instance
)的初始化是线程安全的。若多个线程同时首次调用GetInstance()
,编译器会保证只有一个线程执行变量初始化,其他线程会阻塞等待初始化完成后再访问,无需手动加锁。即保证了线程安全性,又保证了可见性问题。 - 自动销毁:程序结束时,局部静态变量会按构造的逆序自动销毁,调用
~Singleton()
释放资源。 - 注意:只有局部静态变量才能这么做,如果是全局静态变量则未被标准保证,仍需使用之前的方式。
#include <iostream>
// 如需线程安全验证,可包含此头文件(C++11及以上环境)
#include <thread>
class Singleton {
public:
// 核心:局部静态变量,C++11后保证线程安全初始化
//并使用&来保证访问效率
static Singleton& GetInstance() {
static Singleton instance; // 第一次调用时初始化,后续直接返回引用
return instance;
}
// 示例:单例的业务方法
void DoSomething() const {
std::cout << "Singleton is working, address: " << this << std::endl;
}
private:
// 1. 私有构造:禁止外部直接创建
Singleton() {
std::cout << "Singleton constructed." << std::endl;
}
// 2. 私有析构:禁止外部直接销毁(由系统自动调用)
~Singleton() {
std::cout << "Singleton destructed." << std::endl;
}
// 3. 禁用拷贝语义:防止对象复制
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 4. 禁用移动语义:防止对象移动
Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
};
这种方式也是我们最推荐的写法,他即不用考虑无法自动析构导致资源泄露的问题,也不用考虑线程安全,最后甚至不需要考虑CPU编译器的指令重排,可以说局部静态变量的标准出现,让单例模式得到了显著的进步。
但有人说,这样你每写一个单例类就需要手动禁用一下其构造函数等等,还是稍显麻烦,那么我们下面的写法则将他封装成了一个基类。
六、版本六:模板提高复用率
当父类的各种构造被禁用了,子类想要调用对应的构造,首先会调用父类的,然后发现错误,实现单例模式,且不需要在子类手动禁用。
// 单例模式基类模板
template <typename T>
class Singleton {
public:
// 禁用拷贝构造
Singleton(const Singleton&) = delete;
// 禁用拷贝赋值
Singleton& operator=(const Singleton&) = delete;
// 禁用移动构造
Singleton(Singleton&&) = delete;
// 禁用移动赋值
Singleton& operator=(Singleton&&) = delete;
// 获取单例实例
static T& getInstance() {
// 静态局部变量,C++11后保证线程安全初始化
static T instance;
return instance;
}
protected:
// 保护的构造函数,允许子类构造
Singleton() = default;
// 保护的析构函数,允许子类析构
virtual ~Singleton() = default;
};
当你使用的时候,只需要继承于该基类,然后重写其中的构造函数、析构函数即可,举个例子:
// 1. 日志管理器 - 单例应用场景
class Logger : public Singleton<Logger> {
friend class Singleton<Logger>;
private:
// 私有构造函数,初始化日志系统
Logger() {
std::cout << "Logger initialized. Starting to log messages..." << std::endl;
}
// 私有析构函数,清理日志系统
~Logger() {
std::cout << "Logger shutting down. Finalizing log files..." << std::endl;
}
public:
// 日志级别
enum class Level { INFO, WARNING, ERROR };
// 记录日志的方法
void log(const std::string& message, Level level = Level::INFO) {
// 简单的线程安全处理
std::lock_guard<std::mutex> lock(mtx);
// 根据级别输出不同前缀
std::string prefix;
switch(level) {
case Level::INFO: prefix = "[INFO] "; break;
case Level::WARNING: prefix = "[WARNING]"; break;
case Level::ERROR: prefix = "[ERROR] "; break;
}
// 输出日志信息
std::cout << prefix << message << std::endl;
}
private:
std::mutex mtx; // 确保日志输出线程安全
};
这里可以看到他引入了一个友元类,让基类可以访问到子类的私有构造、析构函数。在之前的设计模式中由于子类重写的函数都是public的,所以不需要友元。这一点需要注意一下。