C++单例模式的几种实现方法详解
前言
单例模式(singleton pattern)要保证两件事:一个类在进程内只有一个实例,并提供一个全局访问点。它常被用在配置管理、日志器、连接池这类「逻辑上唯一」的对象上。C++ 里实现单例的写法随着标准演进而变化很大:C++98 时代要手写双检锁并配合平台相关的内存屏障,C++11 之后有了原子操作和「局部静态变量线程安全初始化」的保证,写法大幅简化。
必须先说清两个误解。第一,「单例 = 全局变量」不准确。单例把实例的创建推迟到首次使用(懒汉)或保证在main之前创建(饿汉),并且可以控制销毁时机;裸全局变量做不到这些,还面临跨翻译单元的静态初始化顺序问题。第二,「单例是线程安全的」这句话只在说实例的创建。实例内部的成员数据是否线程安全,取决于你是否给它们加了同步,单例本身不提供任何数据保护。
本文按从繁到简的顺序给出四种实现:饿汉、双检锁懒汉、std::call_once、局部静态变量(Meyers singleton),并讨论销毁顺序、模板化和滥用边界。代码以 C++17 为准,全部按标准语义推演。
一、饿汉式:在main之前构造
最直接的写法是把实例定义为静态对象,instance()只返回引用。
// 适用于 C++17
class Config {
public:
static Config& instance() { return inst_; }
int port() const { return port_; }
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
private:
Config() : port_(8080) {}
static Config inst_;
int port_;
};
Config Config::inst_; // 定义在某个 .cpp 中优点是实现简单、无需加锁、不存在竞态。缺点有三个。
其一,初始化时机不可控。inst_是命名空间作用域的静态对象,在main之前完成动态初始化。如果它的构造函数依赖另一个翻译单元里的静态对象,而那个对象还没构造,就会读到未初始化的值——这就是著名的静态初始化顺序问题(static initialization order fiasco)。标准只保证同一翻译单元内按定义顺序初始化,跨翻译单元的顺序不确定。
其二,无法延迟加载。即使程序这次运行根本用不到Config,它也会被构造,构造代价高时是浪费。
其三,析构顺序问题。静态对象在main返回后按构造的逆序销毁。如果另一个静态对象的析构函数调用Config::instance(),而Config::inst_已经被销毁,就是访问生命周期已结束的对象,属于未定义行为。
二、双检锁懒汉:C++11 的正确写法
懒汉式把构造推迟到第一次instance()调用。多线程下必须保证「只构造一次」且「构造完成后的写入对其它线程可见」。经典的双检锁(double-checked locking pattern, DCLP)在 C++11 之前无法用标准手段正确实现,因为普通指针的读写可能被重排,其它线程可能看到一个「指针已赋值但对象尚未构造完」的状态。C++11 引入std::atomic后可以正确表达。
// 适用于 C++17
#include <atomic>
#include <mutex>
class Config {
public:
static Config& instance() {
Config* p = ptr_.load(std::memory_order_acquire); // 第一次检查
if (p == nullptr) {
std::lock_guard<std::mutex> lk(mtx_);
p = ptr_.load(std::memory_order_relaxed); // 第二次检查
if (p == nullptr) {
p = new Config();
ptr_.store(p, std::memory_order_release); // 发布
}
}
return *p;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
private:
Config() = default;
static std::atomic<Config*> ptr_;
static std::mutex mtx_;
};
std::atomic<Config*> Config::ptr_{nullptr};
std::mutex Config::mtx_;为什么是 acquire/release 而不是默认的seq_cst?release保证「构造过程中的所有写入」不会被重排到store之后;acquire保证读到该指针的线程能看到构造的全部结果。这一对语义正好覆盖发布-订阅模式。用默认的seq_cst也正确,只是约束更强;但绝不能用relaxed,也不能用volatile代替原子变量——volatile不建立线程间同步,数据竞争仍然是未定义行为。
这段代码还有一个隐含问题:new Config()分配的内存永远不会释放。对进程级单例来说这通常可以接受,但如果单例持有文件句柄、网络连接等资源,就需要显式销毁,而显式销毁又会引出「谁来保证销毁时没有其它线程在用」的问题。
三、std::call_once:把「只执行一次」交给标准库
如果不想手写原子内存序,std::call_once配合std::once_flag是更直白的表达。
// 适用于 C++17
#include <mutex>
class Config {
public:
static Config& instance() {
std::call_once(flag_, [] {
inst_.reset(new Config());
});
return *inst_;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
private:
Config() = default;
static std::once_flag flag_;
static std::unique_ptr<Config> inst_;
};
std::once_flag Config::flag_;
std::unique_ptr<Config> Config::inst_;call_once保证:多个线程同时调用时,只有一个线程执行可调用对象,其余线程阻塞到它完成;如果它抛出异常,下一次调用会重新尝试。相比双检锁,代码更容易读,也不用自己推理内存序。开销上,成功执行过一次之后仍需一次原子检查,具体代价由实现决定,不要凭空给出纳秒数字。
四、Meyers singleton:局部静态变量
C++11 起,函数内静态局部变量的初始化是线程安全的:标准规定如果多个线程同时首次执行到该声明,只有一个线程执行初始化,其余线程等待;如果初始化抛出异常,其它线程会重试。这条保证常被称为「magic statics」。于是单例可以写成一行。
// 适用于 C++17
class Config {
public:
static Config& instance() {
static Config inst; // C++11 起线程安全的初始化
return inst;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
private:
Config() = default;
};
// 用法
// Config& c = Config::instance();这是目前最推荐的写法,理由如下。
- 延迟到首次使用才构造,且只构造一次。
- 不需要显式加锁,不引入额外的静态成员定义。
- 销毁发生在程序退出时,由运行时按构造逆序处理。
- 代码量最小,出错面最少。
需要提醒的是编译器支持:GCC 与 Clang 很早实现了该保证,MSVC 从 Visual Studio 2015 起支持。更老的 MSVC(如 2013)没有实现 magic statics,在那种环境下必须回到call_once。这也是本文坚持标注标准版本的原因。
它同样有销毁顺序问题:如果另一个静态对象的析构函数在Config销毁之后调用instance(),行为未定义。常见对策是让单例「永不销毁」——用static Config* inst = new Config();返回引用,故意泄漏一小块内存。这不是疏忽,而是用可控的内存泄漏换取退出阶段的安全,很多框架都这么做。是否采用取决于单例是否持有必须释放的系统资源。
五、模板化与 CRTP 单例
如果多个类都要做单例,可以抽成模板。基类模板提供instance(),派生类通过 CRTP 传入自己。
// 适用于 C++17
template <class T>
class Singleton {
public:
static T& instance() {
static T inst;
return inst;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
protected:
Singleton() = default;
~Singleton() = default; // 不作为多态基类使用,无需 virtual
};
class Logger : public Singleton<Logger> {
friend class Singleton<Logger>; // 让基类能调用私有构造
public:
void log(const char* msg) { /* ... */ }
private:
Logger() = default;
};两个细节。第一,Logger的构造函数是私有的,Singleton<Logger>::instance()里的static T inst;需要访问它,所以必须friend。第二,这个基类模板的析构函数不必是virtual,因为它从不通过基类指针删除对象;如果有人把它当多态基类用,那才是设计错误。若确实需要多态删除,就必须补virtual ~Singleton() = default;。
六、该不该用单例
单例最大的问题不是线程安全,而是隐藏依赖。一个函数内部调用Config::instance(),从签名上看不出它依赖配置,单元测试也无法替换。它本质上是一个可控的全局变量,滥用会让模块之间产生隐式耦合。
比较务实的做法是:在main或组合根(composition root)处创建一个对象,通过构造函数注入给需要它的模块;只有真正全局且无状态争议的东西(日志、内存分配钩子)才用单例。如果只是为了「方便访问」,那多半是依赖注入没做好。
常见坑点
- ❌ 用普通指针写双检锁,靠编译器「应该不会重排」。
✅ 用std::atomic<T*>配 acquire/release,或直接用局部静态变量。
- ❌ 用
volatile修饰单例指针,以为能保证可见性。
✅volatile不是同步原语,数据竞争是未定义行为;用std::atomic或互斥量。
- ❌ 只删除了拷贝构造,忘了拷贝赋值,
a = b仍能编译。
✅ 两个都= delete,必要时连移动操作一起删除。
- ❌ 饿汉式单例的构造函数依赖另一个翻译单元的全局对象。
✅ 改为 Meyers singleton 延迟构造,或改用常量初始化。
- ❌ 在单例的析构函数里调用另一个单例,销毁顺序不确定。
✅ 明确依赖顺序,或让单例不参与退出期清理(有意泄漏)。
- ❌ 认为
instance()线程安全就等于单例的成员访问安全。
✅ 内部数据仍需互斥量或原子操作保护。
- ❌ 在单例构造函数里再次调用
instance(),造成递归初始化。
✅ 构造函数只做自身初始化,不在其中获取自身实例。
- ❌ 把单例当参数传递的替代品,导致任意位置都能拿到全局状态。
✅ 通过构造函数注入依赖,单例只保留给真正全局的对象。
总结
| 实现方式 | 构造时机 | 线程安全 | 主要风险 |
|---|
| 饿汉(静态成员) | main之前 | 构造本身安全 | 跨 TU 初始化顺序、退出期析构 |
双检锁 +atomic | 首次调用 | 需正确内存序 | 内存序写错即 UB |
std::call_once | 首次调用 | 标准保证 | 略啰嗦,仍有销毁问题 |
| 局部静态变量 | 首次调用 | C++11 起保证 | 老编译器不支持、退出期析构 |
| 模板/CRTP 单例 | 首次调用 | 同局部静态 | 私有构造需 friend |
如果工具链支持 C++11 及以上,优先选局部静态变量版本:它最短、最不容易写错,标准明确保证初始化安全。双检锁只在需要精细控制(比如自定义分配、需要保存裸指针做特殊清理)时才有存在价值。无论哪种写法,都要记住单例只解决「唯一实例」,不解决「线程安全」,更不解决「设计耦合」。