⭐️在这个怀疑的年代,我们依然需要信仰。
个人主页 :YYYing.
⭐️设计模式系列专栏:设计模式系列
系列上期内容:【设计模式系列 (二) 】万字详解工厂模式
系列下期内容:【设计模式系列 (四) 】建造者模式
单例要解决的问题只有一句话:保证一个类只有一个实例,并提供一个全局访问点。
本文代码取自design-pattern-cpp 仓库的design-pattern/singleton/,使用 C++11。为阅读方便,片段省略了头文件保护宏。
1. 概述
Wikipedia:In software engineering, the singleton pattern is a software design pattern that restricts the instantiation of a class to one object. This is useful when exactly one object is needed to coordinate actions across the system.
在软件工程中,单例模式是一种软件设计模式,它将类的实例化限制为一个对象。当需要一个对象来协 调整个系统的操作时,这很有用。
GoF:Ensure a class only has one instance, and provide a global point of access to it.
确保某一个类只有一个实例,并提供一个全局的访问点来访问这个实例。
有些类在整个系统中本来就只需要一个对象——配置管理器、日志器、线程池、资源池。多造几个不但浪费资源,还会让状态分散到多处难以协调。单例模式就是把这个"唯一性"用代码强制约束住,而不是靠开发者自觉。
2. 结构
单例模式只有一个角色:Singleton(单例类)。它需要三件事同时成立:
| 约束 | 手段 | 目的 |
|---|---|---|
外部不能new | 构造函数私有化 | 杜绝随意创建 |
| 对象不能复制 | delete拷贝构造、delete赋值运算符 | 杜绝Singleton s2 = *s1;绕过唯一性 |
| 提供唯一入口 | 静态成员变量 + 静态getInstance() | 全局唯一访问点 |
仓库中把这套约束抽成了宏(Macros.h):
/** 禁止赋值运算符/拷贝构造/无参构造 */ #define SINGLETON_HEPLER(TypeName) \ private: \ TypeName() {}; \ TypeName(const TypeName&) = delete; \ TypeName& operator=(const TypeName&) = delete;如果我们想要让代码解耦,用宏无疑是非常好的选择,但我们还有一种很好的用法,他的名字叫做"CRTP奇异递归模版",如果你对智能指针比较熟悉,可能会对这个东西有印象,这个东西我们留作悬念,将在本文的最终展示。
3. 五种实现
按初始化时机分两大类:
饿汉模式:程序启动(类加载)时就创建实例,空间换时间。
懒汉模式:第一次调用
getInstance()时才创建,时间换空间。
3.1 饿汉 · 非局部静态(Singleton1)
class Singleton1 { SINGLETON_HEPLER(Singleton1) private: static Singleton1 instance; public: static Singleton1* getInstance(); }; // cpp singleton::Singleton1 singleton::Singleton1::instance; singleton::Singleton1* singleton::Singleton1::getInstance() { return &instance; }最简单,没有并发问题,也没有效率问题。但埋着一颗雷:非局部静态对象的初始化顺序是不确定的。
静态对象指具有静态存储期限的对象,从定义处开始分配的内存一直保留到程序结束,包括全局变量、命名空间内的对象、
static修饰的对象。其中块作用域内的称为局部静态对象,其余的都叫非局部静态对象。
看一段演示代码。A.cpp里有个全局对象a,构造时把key置为2;B.cpp里有个全局对象b,构造时读取a.getKey()并打印:
// A.cpp A::A() { key = 2; } A a; // A 的全局变量 // B.cpp B::B() { std::cout << a.getKey() << std::endl; } B b; // B 的全局变量 // main.cpp int main() { return 0; }换个链接顺序,结果就变了:
g++ -o main B.cpp A.cpp main.cpp # 输出 2 g++ -o main A.cpp B.cpp main.cpp # 输出 0原因是:同一个编译单元内的非局部静态对象按定义顺序初始化(顺序确定);不同编译单元之间的初始化顺序是未定义的。第二行的b先于a构造,读到的key是零初始化的0。
这就是著名的静态初始化顺序问题(SIOF)。如果 A 和 B 都是单例,B 的初始化又依赖 A 的实例,程序行为就是随机的。
3.2 饿汉 · 局部静态 + 辅助类(Singleton2)
思路:把单例对象从"非局部静态"改成"局部静态",再用一个辅助类在静态初始化阶段主动触发一次构造。
class Singleton2 { SINGLETON_HEPLER(Singleton2) public: static Singleton2* getInstance(); private: class Creator { public: Creator(); static Creator m_creator; // 非局部静态,静态初始化阶段就会被构造 }; }; // cpp singleton::Singleton2::Creator singleton::Singleton2::Creator::m_creator; singleton::Singleton2::Creator::Creator() { Singleton2::getInstance(); // 在辅助类构造时触发单例的构造 } singleton::Singleton2* singleton::Singleton2::getInstance() { static Singleton2 instace; // 局部静态 return &instace; }为什么这样就安全了?局部静态对象是"首次执行到该声明时"构造的,这个时机由调用者决定,而不由链接器决定。于是:
如果别的类在自己的静态初始化里先调用了
Singleton2::getInstance(),单例就在那一刻被构造,顺序天然正确;如果谁都没调用,
m_creator的构造也会兜底触发一次。
两种情况都只会构造一次,顺序始终明确,SIOF 被消掉了。
3.3 懒汉 · 裸指针(Singleton3,有缺陷)
class Singleton3 { SINGLETON_HEPLER(Singleton3) public: static Singleton3* getInstance(); private: static Singleton3* instance; }; // cpp singleton::Singleton3* singleton::Singleton3::instance = nullptr; singleton::Singleton3* singleton::Singleton3::getInstance() { if (!instance) { instance = new singleton::Singleton3(); } return instance; }两个缺陷,都是面试考点:
线程不安全。线程 1 判断
instance为空,开始new;线程 2 此时判断instance仍然为空(还没赋值回去),也去new——最终创建出两个对象,甚至可能拿到半成品对象。内存泄漏。类里只负责
new,没人负责delete。
3.4 懒汉 · shared_ptr + 双检锁(Singleton4)
class Singleton4 { SINGLETON_HEPLER(Singleton4) public: static Singleton4* getInstance(); private: static std::shared_ptr<Singleton4> instance; static std::mutex m_mutex; }; // cpp std::shared_ptr<singleton::Singleton4> singleton::Singleton4::instance = nullptr; std::mutex singleton::Singleton4::m_mutex; singleton::Singleton4* singleton::Singleton4::getInstance() { if (!instance) // 第一重检查:避免每次都加锁 { std::lock_guard<std::mutex> lock(m_mutex); if (!instance) // 第二重检查:防止重复创建 { instance = std::shared_ptr<Singleton4>(new Singleton4()); } } return instance.get(); }为什么判断两次?(高频八股)
| 检查 | 作用 |
|---|---|
外层if | 实例已经存在时直接返回,不加锁。锁的开销不小,而getInstance()是高频调用,不能每次都锁 |
内层if | 两个线程可能同时通过外层检查,然后排队等锁。第一个线程创建完释放锁后,第二个线程拿到锁必须再判一次,否则会重复创建 |
这个版本的改进:
用
std::shared_ptr(RAII)管理资源,析构时自动delete,解决内存泄漏;用
std::mutex加锁,解决线程安全。
但它仍然不完美:
依赖 C++11(
shared_ptr、mutex);双检锁在某些平台下会失效。第一重检查在锁外读
instance,与锁内的写构成数据竞争,编译器/CPU 的指令重排序可能让另一个线程读到"指针已赋值但对象尚未构造完"的状态。这是经典的 DCLP 问题(想了解的,链接打不开就自己问ai或者搜搜其他文章吧)。C++11 下要写对,需要把instance换成std::atomic<Singleton*>并配 acquire/release 内存序——但那还不如直接看下一节。
虽然不是很完美,但我也在其他面经中见到过双检锁单例的考察,还是建议记下。
3.5 懒汉 · 局部静态(Meyers Singleton,推荐)
class Singleton5 { SINGLETON_HEPLER(Singleton5) public: static Singleton5* getInstance(); }; // cpp singleton::Singleton5* singleton::Singleton5::getInstance() { static Singleton5 instance; return &instance; }这是 C++ 里公认最简洁、最正确的写法,作者是《Effective C++》的 Meyers,所以叫Meyers Singleton。它依赖 C++11 的Magic Static特性:
If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
如果多个线程并发进入该变量的声明语句,而变量正在初始化,则并发线程会阻塞等待初始化完成。
一句话总结:线程安全(编译器保证)、不会内存泄漏(静态对象自动析构)、代码最短。
五种写法对比
| Singleton1 | Singleton2 | Singleton3 | Singleton4 | Singleton5 | |
|---|---|---|---|---|---|
| 类别 | 饿汉 | 饿汉 | 懒汉 | 懒汉 | 懒汉 |
| 创建时机 | 程序启动 | 静态初始化阶段 | 首次调用 | 首次调用 | 首次调用 |
| 线程安全 | ✅(启动期单线程) | ✅ | ❌ | ⚠️ 双检锁有平台风险 | ✅ |
| 内存泄漏 | ✅ 无 | ✅ 无 | ❌ 有 | ✅ 无(RAII) | ✅ 无 |
| 主要问题 | SIOF 初始化顺序不确定 | 多一个辅助类 | 两个缺陷齐活 | 依赖 C++11、DCLP 风险 | — |
| 推荐度 | ⚠️ | ✅ | ❌ | ⚠️ | ⭐推荐 |
4. 优缺点与适用环境
主要优点
提供对唯一实例的受控访问:单例类封装了唯一实例,能严格控制客户何时、如何访问它;
节约系统资源:内存中只有一个对象,对频繁创建销毁的对象尤其明显;
可扩展为多例模式:用同样的控制思路获得指定个数的实例。
主要缺点
没有抽象层,扩展困难:单例类通常直接暴露具体类名,难以替换为子类;
职责过重,违背单一职责原则:既当工厂(提供创建方法)又当产品(承载业务方法);
全局状态难以测试:单元测试之间会互相污染,依赖关系被隐藏在调用点而不是构造函数里;
在带 GC 的语言里,长时间不使用的单例可能被回收,导致状态丢失。
适用环境
系统只需要一个实例对象,如唯一序列号生成器、资源管理器,或资源消耗过大只允许创建一个对象;
客户端调用该类的单个实例时只允许通过一个公共访问点,不能有其他途径。
5. 面试专题
5.1 开场题:说说单例模式吧
考察点是概念、创建方式、应用场景三块,按这个顺序答:
概念:单例模式确保一个类只有一个实例,并提供一个全局访问点。实现上要私有化构造函数、禁止拷贝与赋值、提供静态的
getInstance()。创建方式:分饿汉和懒汉两类。饿汉在程序启动时就创建,简单但没有延迟加载;懒汉在首次使用时创建,需要考虑线程安全。懒汉的演进路线是:裸指针版(线程不安全 + 内存泄漏)→ 智能指针加双检锁(RAII 解决泄漏,加锁解决并发,但双检锁在某些平台会失效)→局部静态版(Meyers Singleton),依靠 C++11 的 Magic Static,既线程安全又不会泄漏,是最推荐的写法。
应用场景:系统只需要一个实例,或需要唯一的全局访问点时,比如配置管理、日志、线程池、序列号生成器。
5.2 高频追问清单
| 追问 | 答法 |
|---|---|
| 饿汉和懒汉的区别? | 初始化时机不同:饿汉在程序启动/类加载时创建(空间换时间),懒汉在首次调用时创建(时间换空间) |
| 为什么要私有构造函数? | 防止外部随意new,唯一性从源头被破坏 |
| 为什么还要删除拷贝构造和赋值运算符? | 私有构造只挡住了new,Singleton s2 = *s1;或s2 = *s1;仍能绕过,必须一并delete |
| 双检锁为什么要判断两次? | 外层为了已存在时免加锁(性能),内层为了防止多个线程同时通过外层检查后重复创建 |
| 双检锁一定安全吗? | 不一定。锁外读、锁内写构成数据竞争,指令重排序可能让别的线程读到未构造完的对象。C++11 正解是用std::atomic配 acquire/release,或者直接用 Meyers 单例 |
| 局部静态为什么线程安全? | C++11 的 Magic Static 保证:并发进入初始化时会阻塞等待初始化完成 |
| 单例的内存泄漏怎么解决? | 用静态对象(自动析构)或智能指针 RAII;裸指针方案可以配atexit注册清理,但不如前两者干净 |
| 单例对象什么时候析构? | 局部静态对象在main()结束后按构造的逆序析构;裸指针new出来的永远不会析构 |
| 单例是线程安全的吗? | 陷阱题。线程安全的是"创建过程",不是"单例本身"。单例持有的共享数据仍然需要自己加锁保护 |
| 单例有什么缺点? | 没有抽象层扩展难、职责过重违背单一职责、全局状态导致单元测试互相污染、依赖关系被隐藏 |
| 单例和静态类(全静态方法的类)有什么区别? | 单例可以继承、可以实现接口、可以延迟初始化、可以多态,并且明确持有一个对象;静态类全部编译期绑定,不受 OOP 规则约束 |
| 单例可以继承吗? | 可以,把构造函数改成protected。但实践中很少这么用,因为单例往往就是终态类 |
5.3 建造者/工厂里也藏着单例
一个很好的"工程印证"素材:仓库里的ClassFactory(反射机制的核心)自己就是 Meyers 单例:
ClassFactory& ClassFactory::getInstance() { static ClassFactory instance; return instance; }而它的注册动作来自DynamicClass的构造函数——IMPLEMENT_CLASS宏在静态初始化阶段 new 出一个DynamicClass,构造时把自己的工厂函数注册进单例。这里同时用到了单例和非局部静态对象的初始化时机两个知识点,是一道很好的综合面试题。
5.4 什么时候不该用单例
单例最大的问题不是"写不出来",而是被当成了全局变量的遮羞布。出现以下信号就该警惕:
只是为了"到处都能拿到这个对象"而用单例 → 应该用依赖注入把对象传进去;
单例里有大量可变状态,且被多个模块读写 → 本质是全局变量,并发和测试都会出问题;
单例之间有依赖,初始化有先后 → 容易踩 SIOF,慎用饿汉模式。
6. 彩蛋:CRTP 奇异递归模版
前面五种写法都是在"具体类"里手写单例约束。如果工程里有十几个类要做单例,在你不用宏的情况下,样板代码会爆炸。CRTP(奇异递归模板模式)把这套约束抽成一个基类模板:任何类只要继承它,就自动变成单例。
6.1 实现
#pragma once #include <memory> #include <mutex> #include <iostream> using namespace std; template <typename T> class Singleton { protected: Singleton() = default; Singleton(const Singleton<T>&) = delete; Singleton& operator=(const Singleton<T>& st) = delete; static std::shared_ptr<T> _instance; public: static std::shared_ptr<T> GetInstance() { static std::once_flag s_flag; std::call_once(s_flag, [&]() { _instance = shared_ptr<T>(new T); }); return _instance; } void PrintAddress() { std::cout << _instance.get() << endl; } ~Singleton() { std::cout << "this is singleton destruct" << std::endl; } }; template <typename T> std::shared_ptr<T> Singleton<T>::_instance = nullptr;6.2 使用
class Point : public Singleton<Point> { public: Point() { cout << "Point construct" << endl; } }; int main() { auto p1 = Singleton<Point>::GetInstance(); auto p2 = Singleton<Point>::GetInstance(); p1->PrintAddress(); p2->PrintAddress(); return 0; }输出:
Point construct 0123ABCD 0123ABCD this is singleton destruct两次地址相同(shared_ptr指向同一个对象),Point construct只打印一次;this is singleton destruct在main()返回之后才出现——因为_instance是静态存储期对象,直到程序退出才释放。
6.3 CRTP 是什么
Curiously Recurring Template Pattern:派生类把自己作为模板参数传给基类模板。
class Point : public Singleton<Point> // ^^^^^^^^^^^^^^^ 自己传给自己这样一来,基类在编译期就知道"我派生出了谁",于是可以:
在基类里直接
new T、返回T的指针——不需要知道T的名字;提供编译期多态:基类里写
static_cast<T*>(this)->foo()就能调到派生类的实现,没有虚函数表、没有运行期开销。
本例只用到第一点(GetInstance()是静态函数,连static_cast都不需要)。如果想让基类提供非静态的接口给派生类用,就得靠static_cast<T*>(this)向下转型,那才是 CRTP 的典型用法。
如果智能指针那块你比较熟练,不妨可以发现我们enable_shared_from<xxx>是同一个东西,也就是我们的CRTP。
6.4 这段代码里的三个关键点
| 机制 | 作用 |
|---|---|
protected构造 +delete拷贝/赋值 | 与SINGLETON_HEPLER宏一样的效果,但类型安全、可带成员、可调试,不是文本替换 |
std::call_once+ 局部静态once_flag | 保证"只构造一次",且是 C++11 标准给的线程安全工具,没有 DCLP 的重排序风险 |
shared_ptr<T> _instance | RAII 自动释放,不泄漏;= nullptr属于常量初始化,不参与动态初始化的顺序竞争,天然避开 SIOF |
关于call_once相对双检锁的优势,可以正面回答"为什么不用双检锁":once_flag的内部状态由标准库维护,调用方不需要自己安排内存序,写错了也不会退化成数据竞争。
关于_instance = nullptr:它是常量初始化,在动态初始化开始之前就已经完成,所以这个指针本身不存在"谁先谁后"的问题(对比 3.1 节的 SIOF)。
6.5 三个坑
坑一:挡不住外部new T。上面Point的构造函数是public的,new Point照样能造出第二个对象,此时它已经不是严格意义上的单例。要真正封住,得让T自己私有化构造,并把Singleton<T>设为友元:
class Point : public Singleton<Point> { friend class Singleton<Point>; // 让基类的 new T 能访问私有构造 private: Point() = default; };注意派生类的私有构造不会自动被基类访问,friend声明不能省。
坑二:call_once里抛异常会重试。如果T的构造函数抛异常,这次调用算失败,once_flag不会被置为已完成,下一次GetInstance()会重新尝试构造。这既是特性(构造失败允许重试),也是坑(构造函数里已经产生的副作用会重复执行一遍)。
坑三:模板静态成员的定义必须放在头文件里。template <typename T> std::shared_ptr<T> Singleton<T>::_instance = nullptr;写进.h是模板的正常写法,不会链接冲突——因为它是模板,每个T各自实例化一份。
6.6 与其他实现对比
宏SINGLETON_HEPLER | Meyers 单例 | CRTP 模板单例 | shared_ptr+ 双检锁 | |
|---|---|---|---|---|
| 复用方式 | 宏展开 | 手写 | 继承基类模板 | 手写 |
| 类型安全 | ❌ 文本替换 | — | ✅ | — |
| 线程安全 | 取决于写法 | ✅ | ✅(call_once) | ⚠️ DCLP 风险 |
| 内存泄漏 | 取决于写法 | ✅ 无 | ✅ 无 | ✅ 无 |
| 返回类型 | 自定义 | 引用/指针 | shared_ptr<T> | 指针 |
| 主要缺点 | 无法调试、易踩宏展开坑 | 每个类都要写一遍 | 构造函数需friend配合,析构非虚 | 平台相关性 |
如果只想让代码最短,C++11 之后还有一个更简的变体(基类是个空模板,只负责提供GetInstance):
template <typename T> class Singleton { public: static T& GetInstance() { static T instance; return instance; } };本质还是 Meyers 单例,只是用 CRTP 把"写一遍"变成了"继承一下"。
6.7 面试问答
| 追问 | 答法 |
|---|---|
| 什么是 CRTP? | 奇异递归模板模式:派生类把自己作为模板参数传给基类模板,class D : public Base<D>。基类因此在编译期就知道派生类的类型,可实现编译期多态,无虚函数开销 |
| 为什么用 CRTP 写单例? | 把"私有构造 + 禁止拷贝 + 静态实例 + 唯一访问点"这套约束抽成基类模板,派生类继承即可,避免每个单例类重复样板代码,比宏更类型安全 |
| CRTP 单例和 Meyers 单例的关系? | 不冲突,是正交的两件事:Meyers 说的是"用局部静态实现懒汉",CRTP 说的是"如何复用单例约束"。CRTP 基类里同样可以塞一个局部静态的 Meyers 单例 |
为什么这里用call_once而不是双检锁? | once_flag由标准库保证"只执行一次"并且自带正确的内存序,不需要手写atomic和 acquire/release,避开了 DCLP 的重排序问题 |
call_once里的函数抛异常会怎样? | 这次算执行失败,once_flag不置位,下次调用会重新执行一遍 |
| 这段代码算严格单例吗? | 算一半。如果派生类构造函数是public,外部仍能new,唯一性被破坏。必须把派生类构造私有化并friend class Singleton<T> |
为什么_instance定义在头文件里没问题? | 它是类模板的静态成员,每个T各自实例化一份,模板定义放头文件是标准做法,不会重复定义 |
为什么用shared_ptr而不是裸指针? | RAII:静态存储期的shared_ptr在程序退出时自动delete对象,不需要atexit或手工清理;同时GetInstance()返回它时共享所有权,析构时机可控 |
结语
单例模式仍然是面试经常考的一个点,像我前面讲的这几篇对应的设计模式都是较为常用也常考的,希望这篇文章能对你有所帮助。
我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。
无限进步,我们下次再见!