单例模式
单例模式
时间:2026/05/03
关键词:Singleton、Meyers Singleton、线程安全初始化、
std::call_once、初始化失败、重复初始化、析构顺序
核心目标:掌握 C++ 里最常见的单例写法,并能回答面试里关于线程安全、初始化失败和生命周期的问题。
1. 单例模式在解决什么问题
单例模式想解决的是:
- 某个类在整个进程里只需要一个实例
- 所有地方访问的是同一个对象
- 对象创建和生命周期由类自己控制
常见场景:
- 日志系统
- 配置管理
- 资源管理器
- 全局 ID 生成器
- 游戏里的全局服务入口
但要注意:
单例本质上是一种“受控的全局对象”,不要因为方便就到处用。
如果一个对象只是普通依赖,优先考虑构造函数传参、依赖注入或明确的所有权关系。
2. C++11 后最推荐的基础写法
最常见、最推荐的是函数局部静态变量,也叫 Meyers Singleton。
在同一个进程中,同一个函数内的 static 局部变量只初始化一次。
1 | class Singleton { |
调用:
1 | Singleton::instance().do_something(); |
这段代码的重点:
- 构造函数私有,外部不能随便创建
- 拷贝和移动都删除,避免复制出第二个实例
instance()里用static Singleton inst- C++11 起,函数局部静态变量初始化是线程安全的
3. 为什么不推荐手写裸指针单例
老式写法经常是:
1 | class Singleton { |
问题很多:
- 多线程下可能重复创建
- 需要手动释放,容易内存泄漏
- 释放时机难控制
- 如果加锁写不好,还可能产生性能或竞态问题
所以现代 C++ 里,基础单例优先用函数局部静态变量。
4. 面试常问:单例是否线程安全
如果是 C++11 及之后的 Meyers Singleton:
1 | static Singleton inst; |
初始化本身是线程安全的。
也就是说:
- 多个线程同时第一次调用
instance() - 只会有一个线程真正执行构造
- 其他线程会等待初始化完成
但是要分清楚:
单例对象的“初始化线程安全”不等于“对象内部所有方法都线程安全”。
例如:
1 | class Counter { |
这里 Counter 的创建是线程安全的,但多个线程同时调用 add() 仍然有数据竞争。
如果内部状态会被并发修改,仍然需要:
std::mutexstd::atomic- 或者更清晰的并发设计
5. 面试常问:单例初始化失败怎么办
初始化失败通常指构造函数里抛异常。
1 |
|
如果 static Config cfg; 初始化时抛异常:
- 当前这次
instance()调用会把异常抛出去 - 这个局部静态对象不会被视为初始化完成
- 下一次再调用
instance()时,会重新尝试初始化
也就是说,C++ 的局部静态变量初始化失败后不是永久失败,而是下次会重试。
面试回答可以这样说:
C++11 的函数局部静态变量初始化是线程安全的;如果构造过程抛异常,本次初始化失败,异常向外传播,下次进入该声明时会再次尝试初始化。
5.1 初始化失败要不要重试
这取决于业务语义。
适合重试的情况:
- 配置文件短暂不可用
- 网络资源暂时失败
- 外部服务可能恢复
不适合无限重试的情况:
- 程序启动参数错误
- 必要文件不存在
- 配置格式根本不合法
工程里可以选择:
- 直接让异常向外抛,启动失败
- 在外层捕获异常并打印日志
- 提供显式
init(),让初始化失败变成可控返回值
6. 面试常问:重复初始化怎么办
“重复初始化”有两种情况。
6.1 多次调用 instance()
对于 Meyers Singleton:
1 | auto& a = Singleton::instance(); |
这不会重复初始化。
第一次调用时构造对象,后面所有调用都返回同一个对象引用。
6.2 显式 init() 被调用多次
如果单例需要配置参数,就容易出现重复初始化问题。
错误倾向是:
1 | Logger::instance("a.log"); |
第一次和第二次传了不同参数,到底该听谁的?这会让语义混乱。
更清晰的方式是拆成:
init(config):启动阶段显式初始化instance():使用阶段只获取对象
示例:
1 |
|
这类写法的核心是:
- 重复初始化要么直接忽略,要么明确报错
- 不要让不同配置悄悄覆盖已有配置
- 初始化和使用阶段要有清晰边界
不过上面这版还有个细节:instance() 读取 initialized_ 时没有加锁。更严谨的工程代码要么也加锁,要么用 std::atomic<bool>,要么使用 std::call_once。
7. 使用 std::call_once 的写法
如果不想依赖局部静态变量,或者要做更复杂的初始化,可以用 std::call_once。
1 |
|
特点:
- 初始化逻辑只会成功执行一次
- 多线程同时调用时,只有一个线程执行初始化
- 如果初始化函数抛异常,
once_flag不会被标记完成,下次会继续尝试
不过对于普通单例,Meyers Singleton 更简洁。
8. 面试常问:双重检查锁为什么容易出问题
经典写法类似:
1 | if (ptr == nullptr) { |
它叫 Double-Checked Locking。
问题在于:
- 对象构造和指针赋值涉及内存可见性
- 没有正确的原子操作和内存序,其他线程可能看到“指针非空但对象还没完全构造好”
- 写对很麻烦,写错很隐蔽
现代 C++ 面试里可以直接说:
不建议手写双重检查锁。C++11 后用函数局部静态变量或
std::call_once更简单、更安全。
9. 面试常问:单例什么时候销毁
Meyers Singleton 的对象是函数局部静态变量:
1 | static Singleton inst; |
它会在程序结束时自动析构。
但这里有一个经典问题:
静态对象析构顺序不容易控制。
如果多个全局对象或单例互相依赖,程序退出时可能出现:
- 单例 A 已经析构
- 单例 B 的析构函数里还想用 A
- 访问已经销毁的对象,产生未定义行为
应对方式:
- 避免单例之间在析构阶段互相调用
- 把释放逻辑放到明确的
shutdown()阶段 - 对某些进程级对象,接受“不主动析构”,让操作系统在进程退出时回收
有些日志系统会故意写成泄漏式单例:
1 | static Logger& instance() { |
这样对象不会在程序退出时自动析构,可以避开析构顺序问题。
但代价是:
- 内存检查工具会看到泄漏
- 资源释放不够优雅
- 不适合所有场景
所以这是工程取舍,不是默认推荐写法。
10. 面试常问:单例能不能带参数
可以,但要小心。
不推荐这样:
1 | Config& c1 = Config::instance("dev.yaml"); |
因为第二次调用传入的参数通常不会生效,容易误导调用方。
更推荐:
1 | Config::init("dev.yaml"); |
也就是:
- 初始化参数只在启动阶段传一次
- 之后使用时不再传参数
- 重复初始化时明确报错
11. 单例的优缺点
优点:
- 使用方便
- 保证进程内只有一个实例
- 适合管理全局唯一资源
缺点:
- 本质上是全局状态
- 容易隐藏依赖关系
- 测试时不好替换
- 生命周期复杂时容易踩坑
- 多线程下内部状态仍然需要额外保护
面试里不要只说“单例简单方便”,最好补一句:
单例适合管理少数真正全局唯一的服务,但滥用会让依赖关系变隐式,降低可测试性。
12. 常见面试问题速答
12.1 C++ 单例怎么写最简单安全
用函数局部静态变量:
1 | static Singleton& instance() { |
C++11 后初始化线程安全。
12.2 如何防止创建多个实例
- 构造函数私有
- 删除拷贝构造
- 删除拷贝赋值
- 删除移动构造
- 删除移动赋值
12.3 初始化失败怎么办
构造函数抛异常时,本次初始化失败,异常向外传播;下一次调用 instance() 会再次尝试初始化。
如果失败不可恢复,可以在程序启动阶段捕获异常并直接终止启动。
12.4 重复初始化怎么办
如果只是多次调用 instance(),不会重复初始化。
如果是显式 init(config) 被调用多次,要明确策略:
- 要么幂等,重复相同配置直接返回
- 要么报错
- 不要悄悄覆盖已有配置
12.5 单例对象内部方法一定线程安全吗
不一定。
单例初始化线程安全,只代表对象创建过程安全。对象内部如果有共享可变状态,仍然要自己加锁或使用原子变量。
12.6 单例和全局变量有什么区别
单例可以控制创建时机、禁止复制、封装访问入口。
但它仍然带有全局状态的缺点。
13. 一页总结
现代 C++ 写单例优先记住:
- 用函数局部静态变量实现基础单例
- 私有构造,删除拷贝和移动
- C++11 后局部静态变量初始化线程安全
- 初始化失败抛异常后,下次调用会重试
- 重复初始化要有明确策略
- 单例创建安全不等于内部方法线程安全
- 小心析构顺序和隐藏依赖
如果只记一句:
单例不是“到处方便访问”的借口,而是“进程内确实只应该有一个实例”的受控设计。