前言 🚀
单例模式表面上只是"一个类只能有一个对象",但真正理解这部分时,关键并不在于背下"饿汉""懒汉"两个名字,而在于想清楚:为什么某些类必须全局只有一份实例,这份实例为什么不能让外部随便创建、拷贝和销毁,以及在多线程和程序退出阶段又会引出哪些问题。
很多配置中心、日志系统、字典管理器、任务调度入口,都带有很强的"全局唯一"色彩。若这类对象被随意拷贝出多份,状态就会分裂;若创建时机不受控,又可能引入启动顺序问题、线程安全问题或释放时机问题。所以,单例模式真正要解决的,不是"写一个全局变量",而是把唯一实例、访问入口、创建时机、释放策略统一约束起来。
顺着这条主线往下看,饿汉模式和懒汉模式的区别就会非常清楚:前者把"唯一对象"提前准备好,换取实现简单;后者把"唯一对象"延后到真正使用时再创建,换取更灵活的时机控制。但一旦延后创建,就必须继续面对线程安全和析构回收这些现实问题。
一. 单例模式到底在约束什么 🧠
单例模式的核心约束只有一句话:
一个类在系统中只允许存在一个实例,并且要提供一个全局可访问入口。
1.1 为什么不能直接用普通对象替代
因为普通类默认具备这些能力:
- 外部可以随便构造对象
- 外部可以继续拷贝对象
- 对象数量没有天然上限
- 生命周期也未被统一管理
而单例类要的恰恰相反,是:
- 只能有一份对象
- 外部不能自行创建第二份
- 外部不能拷贝出副本
- 只能通过统一入口拿到实例
1.2 所以单例类通常会做哪些限制
- 构造函数私有
- 拷贝构造禁用
- 赋值运算符重载禁用
- 通过静态成员函数返回唯一实例
二. 为什么构造函数要私有,拷贝也要禁用 🔍
2.1 构造函数私有化的意义
如果构造函数仍然是公有的,那么外部就可以直接写:
cpp
A obj;
A* ptr = new A;
这样"只能有一个对象"的前提立刻失效。所以单例类首先要做的,就是把构造函数收起来,不让类外随便创建对象。
2.2 为什么还必须禁用拷贝和赋值
即使只允许类内部构造出那一份实例,如果拷贝构造和赋值运算符还开放着,那么外部依然可以通过已有实例继续复制出新对象。
cpp
A(const A&) = delete;
A& operator=(const A&) = delete;
这样做的本质不是"写法严格",而是为了彻底堵住"第二份实例"的所有旁路入口。
💡 避坑指南:
单例类不是只把构造函数私有就够了。如果拷贝和赋值没禁掉,唯一性仍然可能被破坏。
三. 饿汉模式:为什么说它简单,但创建时机太早 🧱
3.1 核心思路
饿汉模式的做法是:
在程序启动阶段,就把唯一实例直接创建好。
典型写法如下:
cpp
class A
{
public:
static A* GetInstance()
{
return &_inst;
}
void Add(const string& key, const string& value)
{
_dict[key] = value;
}
void Print()
{
for (auto& kv : _dict)
{
cout << kv.first << ":" << kv.second << endl;
}
cout << endl;
}
private:
A()
{}
A(const A&) = delete;
A& operator=(const A&) = delete;
private:
map<string, string> _dict;
static A _inst;
};
A A::_inst;
3.2 它为什么实现简单
因为唯一对象在静态存储区里直接准备好了,后续 GetInstance() 只需要返回地址即可,不涉及判空、不涉及动态分配,也不涉及第一次调用时再初始化。
3.3 它的优点
- 实现直接
- 访问逻辑简单
- 不需要手动考虑析构释放
- 在很多编译环境下天然避开了第一次创建时的并发竞争问题
3.4 它的缺点
问题也同样明显:
-
创建太早
程序一启动就构造,即使后面根本没用到,也已经付出了初始化成本。
-
可能拖慢启动速度
若单例对象本身初始化很重,那么进程启动阶段就会更慢。
-
多个单例之间的初始化顺序不易控制
若某个单例依赖另一个单例,而二者都采用饿汉模式,初始化先后顺序就可能成为隐患。
💡 避坑指南:
饿汉模式最大的问题不是"占内存",而是"创建时机不可延后,也不容易精细控制依赖顺序"。
四. 懒汉模式:为什么说它更灵活,但会引出线程安全问题 💻
4.1 核心思路
懒汉模式的策略正好相反:
对象不提前创建,等第一次真正需要时再创建。
典型写法如下:
cpp
class B
{
public:
static B* GetInstance()
{
if (_inst == nullptr)
{
_inst = new B;
}
return _inst;
}
void Add(const string& key, const string& value)
{
_dict[key] = value;
}
void Print()
{
for (auto& kv : _dict)
{
cout << kv.first << ":" << kv.second << endl;
}
cout << endl;
}
private:
B()
{}
B(const B&) = delete;
B& operator=(const B&) = delete;
private:
map<string, string> _dict;
static B* _inst;
};
B* B::_inst = nullptr;
4.2 它为什么没有饿汉模式的时机缺点
因为只在真正调用 GetInstance() 时才创建对象,所以:
- 没被用到就不创建
- 启动阶段没有额外初始化开销
- 某些依赖关系可以延后到真正使用时再建立
4.3 但它为什么立刻会遇到线程安全风险
因为下面这段逻辑:
cpp
if (_inst == nullptr)
{
_inst = new B;
}
在单线程下没问题,但若两个线程同时第一次进入这里,可能都会看到 _inst == nullptr,于是各自 new 一次,最终产生两份对象。
4.4 所以懒汉模式最核心的现实问题是什么
第一次创建阶段必须解决并发竞争。
五. 懒汉模式的线程安全为什么不能靠"运气没撞上"来理解 ⚠️
单线程调试时,很多人会误以为懒汉模式已经"能跑就行"。但真正的问题并不在于你本次运行有没有撞上,而在于:
只要存在两个线程同时首次访问实例入口,这段代码理论上就不安全。
5.1 出错的本质过程
- 线程 1 进入
GetInstance() - 线程 2 也进入
GetInstance() - 二者都读到
_inst == nullptr - 二者分别执行
new B - 最终出现多个实例
5.2 这说明什么
说明"单例唯一性"不只是接口设计问题,还和并发下的初始化原子性强相关。只要是懒加载,就几乎绕不开这一层考虑。
六. 为什么很多示例里说"懒汉模式一般不用手动释放" 🔍
若懒汉模式里的单例是通过 new 创建的,那么理论上最终是应该释放的。但在很多教学示例里,经常会看到"不释放也行"的说法。
6.1 这种说法的现实背景
因为进程结束后,操作系统会回收该进程占用的地址空间和相关资源,从"进程生命周期最终结束"的角度看,这块内存不会永久泄漏到系统范围之外。
6.2 但这不等于"析构逻辑就不重要"
若单例类的析构函数里本身还承担其他职责,例如:
- 刷新缓存到文件
- 保存配置
- 关闭日志
- 归档统计信息
那么"不手动释放"就意味着这些析构逻辑根本不会执行。
6.3 所以问题的关键不在于"内存要不要还给系统"
而在于:
这个对象销毁时是否还有额外业务收尾动作必须发生。
七. 如果析构里要做收尾工作,为什么还要额外套一层 gc 🧩
7.1 问题来源
懒汉模式里单例对象通常是:
cpp
_inst = new B;
创建出来的。若没有后续 delete,析构函数就不会触发。
7.2 一种常见处理思路
额外定义一个内部清理类,在它的析构里统一调用删除实例的逻辑。这样既能保留单例的全局访问方式,又能让程序结束时自动触发回收。
典型思路可以写成:
cpp
class gc
{
public:
~gc()
{
DelInstance();
}
};
static gc _gc;
7.3 这种设计真正想达到什么效果
- 平时仍然通过
GetInstance()显式访问单例 - 程序退出时,借助静态对象
_gc的析构自动做收尾 - 让"可以手动显示调用"和"可以自动处理释放"这两条路同时成立
7.4 为什么这本质上还是 RAII 思想
虽然这里包的是一个"清理器对象",但背后依然是在利用对象生命周期:让静态对象退出时自动执行析构,从而触发回收逻辑。
💡 避坑指南:
单例析构是否需要显式设计,不取决于"内存会不会被系统回收",而取决于析构里是否还有业务收尾责任。
八. 饿汉和懒汉到底该怎么比较 🗺️
| 方案 | 创建时机 | 优点 | 缺点 |
|---|---|---|---|
| 饿汉模式 | 程序启动阶段 | 简单、直接、访问轻量 | 启动早、初始化成本前置、依赖顺序不易控 |
| 懒汉模式 | 第一次使用时 | 时机灵活、没用到就不创建 | 线程安全要额外处理、释放策略更麻烦 |
8.1 什么时候更偏向饿汉
- 单例对象很轻
- 一定会被使用
- 不想额外处理首次初始化竞争
- 更看重实现简单和稳定性
8.2 什么时候更偏向懒汉
- 单例初始化开销较大
- 可能根本不会被用到
- 创建时机需要尽量延后
- 可以接受额外处理并发与释放策略
九. 这一章最该建立起来的设计意识 📌
单例模式看起来只是一个"全局对象写法",但真正值得建立起来的,其实是这几层设计意识:
-
唯一性不是口头保证,而是靠接口约束实现的
构造、拷贝、赋值都要配合限制。
-
访问入口和创建时机是两件事
都叫
GetInstance(),不代表底层创建策略相同。 -
懒加载一定要继续考虑并发
只要是"第一次创建",就天然可能有竞争。
-
对象释放不仅是内存问题,更是业务收尾问题
若析构有职责,就必须认真设计释放路径。
总结 📝
单例模式真正要解决的,不只是"这个类只有一个对象",而是:如何让这个唯一对象的创建、访问、复制限制和销毁策略都统一收口到一个受控设计里。
顺着这条主线回头看整章内容,逻辑其实非常统一:
- 构造函数私有化,是为了阻止外部随便创建实例
- 拷贝和赋值禁用,是为了防止复制出第二份对象
GetInstance()是统一访问入口- 饿汉模式把实例提前准备好,换来实现简单
- 懒汉模式把实例延后到真正使用时再创建,换来时机灵活
- 但懒汉随之引入线程安全和释放时机问题
- 若析构里承担文件写回等职责,还必须继续设计清理机制
所以,这一章最值得记住的一句话可以压缩成:
单例模式的本质,不是"写一个静态变量",而是"为全局唯一对象建立一套受控的创建与访问规则"。
当这条认识真正建立起来之后,后面再看线程安全单例、配置中心、日志器、对象池入口甚至工厂注册表时,就不会把它们看成零散技巧,而会自然落到同一套"全局唯一资源如何被安全管理"的设计主线上。