【设计模式精讲】4.单例模式(Singleton)
【摘要】:全局配置、日志器、线程池,这类对象在整个进程里只需要一份。多数人的第一反应是定义一个全局变量,随后便陷入初始化顺序失控、重复构造、多线程竞争的泥潭。本文从全局变量的三宗罪讲起,给出单例模式的意图与结构;重点拆解 C++ 中线程安全单例的三种写法------Meyers' Singleton、双检锁的坑与修正、
std::call_once,并比较它们的行为差异。文章最后讨论单例的代价与替代方案,提醒读者:单例是受限的全局状态,能少用就少用。
【关键词】:单例模式、Meyers' Singleton、双检锁、std::call_once、C++
1. 一个全局配置对象引发的血案
新项目开工,你写了一个全局配置:
cpp
// 说明性片段(省略 Config 的定义)
// config.h
Config g_config; // ❌ 定义在头文件里
// a.cpp / b.cpp 都 include 了 config.h
链接器先报了重复定义;改成 extern Config g_config; 后,程序又在启动时随机崩溃------Logger 的构造函数在 g_config 初始化之前就读取了它。静态对象的初始化顺序在跨编译单元时是未定义的,这正是 C++ 著名的「静态初始化顺序惨案」(static initialization order fiasco)。等团队把配置挪进函数、日志改成懒加载,多线程模块上线后又出现了两份实例各写各的缓存。
三宗罪:初始化顺序不可控、构造时机不可控、多线程下不唯一。单例模式要解决的,就是「一个类恰好一个实例 + 全局可访问」这两件事,并且把「何时构造」的决定权收回到类自己手里。
2. 模式意图与定义
- 一句话定义 (GoF 原文意图的译文):Ensure a class only has one instance, and provide a global point of access to it------保证一个类仅有一个实例,并提供一个访问它的全局访问点。Refactoring Guru 中文版亦称「单件模式」。
- 解决的问题 :Refactoring Guru 指出单例同时解决两个问题------控制实例数量 (控制数据库连接、文件系统这类共享资源的访问,第二次「创建」拿到的是首次的那个对象)和 提供全局访问节点 (像全局变量一样好用,但实例被类保护、无法被外部代码覆盖)。代价是它一身兼两职,天然违反单一职责原则。GoF 的动机举例更直观:系统里可以有多台打印机,但打印后台(spooler)只能有一个;一个数字滤波器只能有一块 A/D 转换器。全局变量做不到「阻止你创建第二个」,所以更优解是 让类自己负责看管自己的唯一实例。
GoF 的原始结构是把构造函数设为私有、静态成员 instance() 返回唯一实例。RG 把实现步骤归纳为四条:私有静态成员存实例、公有静态方法取实例、方法内做延迟初始化、构造函数设私有。经典写法是判断指针是否为空,本文第 4 节会看到它在 C++ 多线程下的问题与修正。
3. UML 图 + 结构说明
#mermaid-svg-m9DJ2niR9c9aOrkL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-m9DJ2niR9c9aOrkL .error-icon{fill:#552222;}#mermaid-svg-m9DJ2niR9c9aOrkL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-m9DJ2niR9c9aOrkL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-m9DJ2niR9c9aOrkL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-m9DJ2niR9c9aOrkL .marker.cross{stroke:#333333;}#mermaid-svg-m9DJ2niR9c9aOrkL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-m9DJ2niR9c9aOrkL p{margin:0;}#mermaid-svg-m9DJ2niR9c9aOrkL g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-m9DJ2niR9c9aOrkL g.classGroup text .title{font-weight:bolder;}#mermaid-svg-m9DJ2niR9c9aOrkL .cluster-label text{fill:#333;}#mermaid-svg-m9DJ2niR9c9aOrkL .cluster-label span{color:#333;}#mermaid-svg-m9DJ2niR9c9aOrkL .cluster-label span p{background-color:transparent;}#mermaid-svg-m9DJ2niR9c9aOrkL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-m9DJ2niR9c9aOrkL .cluster text{fill:#333;}#mermaid-svg-m9DJ2niR9c9aOrkL .cluster span{color:#333;}#mermaid-svg-m9DJ2niR9c9aOrkL .nodeLabel,#mermaid-svg-m9DJ2niR9c9aOrkL .edgeLabel{color:#131300;}#mermaid-svg-m9DJ2niR9c9aOrkL .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-m9DJ2niR9c9aOrkL .label text{fill:#131300;}#mermaid-svg-m9DJ2niR9c9aOrkL .labelBkg{background:#ECECFF;}#mermaid-svg-m9DJ2niR9c9aOrkL .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-m9DJ2niR9c9aOrkL .classTitle{font-weight:bolder;}#mermaid-svg-m9DJ2niR9c9aOrkL .node rect,#mermaid-svg-m9DJ2niR9c9aOrkL .node circle,#mermaid-svg-m9DJ2niR9c9aOrkL .node ellipse,#mermaid-svg-m9DJ2niR9c9aOrkL .node polygon,#mermaid-svg-m9DJ2niR9c9aOrkL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-m9DJ2niR9c9aOrkL .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL g.clickable{cursor:pointer;}#mermaid-svg-m9DJ2niR9c9aOrkL g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-m9DJ2niR9c9aOrkL g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-m9DJ2niR9c9aOrkL .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-m9DJ2niR9c9aOrkL .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-m9DJ2niR9c9aOrkL .dashed-line{stroke-dasharray:3;}#mermaid-svg-m9DJ2niR9c9aOrkL .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-m9DJ2niR9c9aOrkL #compositionStart,#mermaid-svg-m9DJ2niR9c9aOrkL .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #compositionEnd,#mermaid-svg-m9DJ2niR9c9aOrkL .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #dependencyStart,#mermaid-svg-m9DJ2niR9c9aOrkL .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #dependencyStart,#mermaid-svg-m9DJ2niR9c9aOrkL .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #extensionStart,#mermaid-svg-m9DJ2niR9c9aOrkL .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #extensionEnd,#mermaid-svg-m9DJ2niR9c9aOrkL .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #aggregationStart,#mermaid-svg-m9DJ2niR9c9aOrkL .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #aggregationEnd,#mermaid-svg-m9DJ2niR9c9aOrkL .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #lollipopStart,#mermaid-svg-m9DJ2niR9c9aOrkL .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL #lollipopEnd,#mermaid-svg-m9DJ2niR9c9aOrkL .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-m9DJ2niR9c9aOrkL .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-m9DJ2niR9c9aOrkL .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-m9DJ2niR9c9aOrkL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-m9DJ2niR9c9aOrkL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-m9DJ2niR9c9aOrkL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 调用 instance()
Singleton
-Singleton$ instance
-~configData : std::string
-Singleton()
-operator=(Singleton&)
+instance() : Singleton&
-doWork() : void
Client
构造/拷贝全部私有
instance() 是唯一入口
角色只有一个,但三道锁缺一不可:
- 私有构造与析构 :外界无法
new、无法创建栈对象; - 禁用拷贝与赋值:否则「唯一」会被拷贝绕过;
- 静态
instance():唯一访问点,同时负责「首次构造」。
4. 传统 C++ 写法(C++11 之前)
先按 GoF 原书时代的形态实现一遍。那时没有 = delete,禁拷贝的惯用法是「声明为 private 且不给出定义」:
cpp
// 经典懒汉式(C++98/03 写法)
class Config {
public:
static Config* instance() {
if (s_inst == 0) { // 两个线程可能
s_inst = new Config(); // 同时通过判断
} // 造成重复构造
return s_inst;
}
private:
Config() {} // 私有构造
Config(const Config&); // ❌ 只声明不定义
Config& operator=(const Config&);// 即可禁用拷贝
static Config* s_inst;
};
Config* Config::s_inst = 0;
这一版的问题有两个。其一,多线程下不安全:线程 A 执行到 new Config() 分配了内存、还没构造完,线程 B 看到 s_inst 非空就拿去用------读到半成品对象。其二,即使构造受锁保护,普通指针赋值与构造完成之间没有顺序保证 ,直觉的修法是给整个函数加锁,但每次访问都加锁太贵,于是有了「双检锁」(DCLP)。以下以 POSIX pthread 接口为例(Linux 服务端的老代码里随处可见):
cpp
// POSIX 平台片段:pthread 双检锁
#include <pthread.h>
class Config {
public:
static Config* instance() {
if (s_inst == 0) { // 第一道检查:
pthread_mutex_lock(&s_mtx); // 无实例才抢锁
if (s_inst == 0) { // 第二道检查:
s_inst = new Config(); // 抢到锁后再确认
}
pthread_mutex_unlock(&s_mtx);
}
return s_inst;
}
private:
Config() {}
Config(const Config&);
Config& operator=(const Config&);
static pthread_mutex_t s_mtx;
static Config* s_inst;
};
pthread_mutex_t Config::s_mtx =
PTHREAD_MUTEX_INITIALIZER;
Config* Config::s_inst = 0;
严谨地说,这版仍不完美:new 可能被编译器重排成「先赋值指针、后执行构造」,第一道无锁检查就可能读到半成品。POSIX 给出的正解是 pthread_once------干脆不做双检:
cpp
// POSIX 平台片段(Config 定义同上,略)
#include <pthread.h>
static pthread_once_t s_once =
PTHREAD_ONCE_INIT;
static Config* s_cfg = 0;
static void initConfig() {
s_cfg = new Config();
}
Config* getConfig() {
pthread_once(&s_once, initConfig);
return s_cfg;
}
可以看到,前 C++11 时代想写对一个懒汉单例,要么绑死平台接口,要么踩内存模型的坑------用 std::atomic 的 acquire/release 语义堵住重排漏洞的标准 C++ 版双检锁也存在,但正确写法已经繁琐到需要逐行注释才能读懂。这正是下一节 Meyers' Singleton 胜出的原因:C++11 把这些补丁全部收进了语言本身。
5. 现代 C++ 进阶写法
首选:Meyers' Singleton。C++11 起标准保证:局部静态变量的初始化是线程安全的(「如果控制流并发地进入声明,并发执行应当等待初始化完成」stmt.dcl)。于是双检锁的全部代码塌缩成三行:
cpp
// ✅ C++11 起推荐的写法
#include <iostream>
#include <mutex>
#include <string>
class Logger {
public:
static Logger& instance() {
static Logger inst; // 线程安全的首懒初始化
return inst;
}
void log(const std::string& msg) {
std::lock_guard<std::mutex> lk(m_mtx);
std::cout << "[log] " << msg << "\n";
}
private:
Logger() = default;
~Logger() = default;
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
std::mutex m_mtx;
};
使用方式:Logger::instance().log("hello");。要点有三:
- 返回引用而不是指针 ------指针可以被
delete或被复制,引用语义上就是「那一个」; = delete显式禁拷贝(C++11),比 GoF 时代私有不定义的 hack 清晰得多;- 析构如需参与退出顺序,把析构也设为
private并提供destroy(),或干脆用std::shared_ptr管理静态实例。
备选:std::call_once。当构造逻辑无法塞进一个静态局部变量(比如要按参数选择子类)时:
cpp
// 节选(Config 定义见上文,略)
#include <mutex>
static std::once_flag s_flag;
static Config* s_cfg = nullptr;
Config& getConfig() {
std::call_once(s_flag, [] {
s_cfg = new Config(); // 任意复杂的一次性构造
});
return *s_cfg;
}
三者怎么选:默认 Meyers' ;构造有分支/依赖注入需求用 call_once;双检锁仅在被迫维护旧代码或需要返回指针接口时出现,新代码不必手写。
还剩一个所有懒汉写法都绕不开的话题:销毁 。局部静态变量会在进程退出时、按与构造相反的顺序析构,听起来很美,但这个顺序只在「同一个线程先后触发初始化」时才成立。若单例 A 的析构函数里调用了单例 B,而 B 恰好在 A 之后析构,退出阶段就翻车了;更隐蔽的是 exit() 之后仍有后台线程在访问单例。工程上有两种成熟对策:一是 不死单例(leaky singleton) ,刻意 new 出来永不析构,把回收交给操作系统------进程级日志器、指标收集器常用这招,反正退出后资源都会被回收;二是 降级空实现,析构时把内部指针置空、后续调用变成安全空操作。大型框架几乎都选其一,第 7 节的三个真实库给出了各自的答案。
6. 优缺点与适用场景
- ✅ 优点:严格保证唯一实例;懒加载,未用到不付出构造成本;访问点统一,接口集中。
- ❌ 缺点:它就是全局状态------隐藏依赖、妨碍并行测试(单例无法在两个测试间替换)、跨编译单元的销毁顺序仍可能出问题;「唯一」约束在将来需求变化(突然要支持多租户/多实例)时是硬伤。
- 🎯 适用场景:进程级天然唯一的资源------日志器、配置中心、线程池、硬件设备抽象。判断标准是「领域本身就是唯一的」,而不是「我图省事只想建一个」。
〔辨析〕单例 vs 全局变量:全局变量在 main 之前构造、顺序不可控;单例把构造推迟到首次访问,顺序由使用关系决定,且能挡住拷贝。单例 vs 静态类:静态成员函数全是过程的「伪单例」无法实现接口、无法虚函数分发,也拿不到实例语义。
关于测试再多说两句。单例妨害测试的根源是「类自己决定依赖谁」:TaxCalculator 内部直接调 Config::instance(),测试就无法替它换上一份假配置。缓解办法有层次的差别:最轻的是给单例留一个「重置/注入」接口(仅供测试编译期开启);更彻底的是 把唯一性上移 ------只在使用一根装配线的 main 里构造一份,往下用构造参数传递引用。后者其实就是依赖注入:类的代码看不出任何单例痕迹,「全局唯一」变成部署决策而不是类的设计,可测试性随之回归。这也解释了为什么 GoF 之后不少流派(如 Google 的代码可读性指南)都建议:单例能不进类就不进类。
7. 开源项目中的身影
三个真实 C++ 工程库对单例的处理,恰好对应本文讲过的三条技术路线,也各自暴露了不同的取舍。
AOSP(Android):锁保护的全局指针,以及一份「自我否定」 。Android 系统层的 native 库 libutils 提供了 android::Singleton<T> 模板,供 HAL、Binder 服务等系统组件复用(节选并简化自源码):
cpp
// 节选自 AOSP system/core/libutils
// include/utils/Singleton.h(有简化)
namespace android {
template <typename TYPE>
class Singleton {
public:
static TYPE& getInstance() {
Mutex::Autolock _l(sLock);
TYPE* instance = sInstance;
if (instance == nullptr) {
instance = new TYPE();
sInstance = instance;
}
return *instance;
}
static bool hasInstance() {
Mutex::Autolock _l(sLock);
return sInstance != nullptr;
}
protected:
~Singleton();
private:
Singleton();
static Mutex sLock;
static TYPE* sInstance;
};
}; // namespace android
实现要点与本文第 4 节的 pthread 版几乎是同一套思路:Mutex::Autolock 是 RAII 风格的 lock_guard,构造放锁内、返回引用挡住外部 delete;配套宏 ANDROID_SINGLETON_STATIC_INSTANCE(TYPE) 负责在 cpp 文件里定义静态成员,绕开头文件重复定义问题。值得学习的反而是它的结局:这个头文件从 Android 9(P)起被标记废弃,官方注释直接建议改用 C++11 的局部静态变量写法------一个维护了十年的自研模板,最终被语言标准收编。这是「能上 Meyers' 就别手写锁」最有说服力的工程证据。
Boost:用 Meyers',但补上了「析构追踪」 。Boost.Serialization 里有一个被多个库借用的 boost::serialization::singleton<T>,核心骨架(节选并简化):
cpp
// 节选自 boost/serialization/singleton.hpp
// (有简化)
namespace boost {
namespace serialization {
template <class T>
class singleton {
public:
static T& get_instance() {
// 局部静态:线程安全依赖 C++11
// (旧编译器另有锁兜底分支)
static detail::singleton_wrapper<T>
t;
return static_cast<T&>(t);
}
// 供序列化框架在退出阶段查询:
// 单例是否已被析构
static bool is_destroyed() {
return detail::singleton_wrapper<
T>::is_destroyed();
}
// ...(细节略)
};
} // namespace serialization
} // namespace boost
它用的正是本文推荐的 Meyers' 路线,但多包了一层 singleton_wrapper<T>:包装类的析构函数会把一个静态标志位置真,is_destroyed() 据此回答「现在还能不能用」。为什么需要这个?序列化框架的全局类型注册表活到进程尾声,而退出阶段各静态对象的析构顺序不可控------某个延迟的序列化操作可能撞上已析构的注册表。Boost 用「可查询的死亡状态」把未定义行为变成可检测的错误,这提醒我们:单例的麻烦一半在构造,另一半在析构,而后者 Meyers' 也只解决一半。
POCO:不设防的 SingletonHolder,把责任交还给使用者 。POCO 基础库的风格截然不同,它的 Poco::SingletonHolder<S> 模板短得可以整段贴进来(节选自源码,略去版权注释):
cpp
// 节选自 POCO Foundation include/Poco/SingletonHolder.h
namespace Poco {
template <class S>
class SingletonHolder {
public:
SingletonHolder() : _pS(0) {}
~SingletonHolder() { delete _pS; }
S* get() {
if (_pS == 0) _pS = new S();
return _pS;
}
private:
SingletonHolder(const SingletonHolder&);
SingletonHolder& operator=(
const SingletonHolder&);
S* _pS;
};
} // namespace Poco
注意 get() 里 没有任何锁 ------它不是线程安全的。这不是疏忽,而是设计取舍:POCO 把这个模板定位为「单例语义的脚手架」,线程安全由具体使用方按需包一层(例如 Poco::Util::Application 的 instance() 在其上层另行保证)。它同时用析构函数 delete 持有的指针,把生命周期收在 holder 手里,与 Boost「故意 leak 或追问死亡状态」是两种相反的哲学。三份代码放在一起看,同一个模式在不同工程里分别长成了「加锁」「包 wrapper」「裸模板」三种形态------模式给出意图,实现细节永远由具体工程的风险点决定。
反模式提醒收尾:如果发现单例只是为了让「到处都能调到」,先用依赖注入(把 Logger& 作为参数传进去)重写一遍试试------很多「必须单例」的类,其实只是「懒得接线」。AOSP 的废弃通知、Boost 的死亡追踪、POCO 的不设防,说的都是同一件事:单例写对只是及格线,生命周期与并发边界才是真正的考题。
本篇小结
单例模式的全部价值浓缩在 instance() 一个函数里:把唯一性、构造时机与线程安全收进类内部 。C++11 后首选 Meyers' Singleton(局部静态 + 引用返回 + =delete 拷贝),需要复杂初始化时用 std::call_once,双检锁留给历史代码。而比「怎么写对」更重要的是「要不要写」:单例是受限的全局状态,用在天然唯一的资源上,而不是绕过设计的捷径。下一篇工厂方法,我们把注意力从「唯一实例」转向「实例从哪里来」。
本文模式定义与实现步骤参考了 Refactoring Guru《设计模式》中文版「单例」一章,意图译文与动机示例参考了 GoF《Design Patterns》第 3 章 Singleton 一节。