C++ 单例模式总结:once_flag + call_once vs static 局部静态对象
一、前言
单例模式是 C++ 面试里非常常见的一道手撕题。
- 线程安全吗?
- 为什么线程安全?
call_once和static局部静态对象有什么区别?- 实战里更推荐哪一种?
这篇文章就专门总结两种最常见、也最容易在面试中出现的写法:
std::once_flag + std::call_once- 函数内
static局部静态对象(Meyers Singleton)
二、方案一:once_flag + call_once
1. 示例代码
1 |
|
2. once_flag 和 call_once 是什么?
std::once_flag
它是一个“只执行一次”的标记对象。
本身不负责执行业务逻辑,而是配合 std::call_once 使用。
std::call_once
它的作用是:
保证某段代码在多线程环境下只会执行一次。
例如:
1 | static std::once_flag flag; |
意思就是:
- 多个线程即使同时进入
GetInstance() - 也只有一个线程会真正执行 lambda 里的初始化代码
- 其他线程会等待这次初始化完成
- 初始化完成后,所有线程拿到的都是同一个对象
3. 为什么它线程安全?
因为标准库已经帮你保证了:
- 初始化逻辑只执行一次
- 多个线程并发调用不会创建多个对象
- 其他线程不会看到“初始化一半”的对象
所以它适合写“懒加载 + 线程安全”的单例。
4. 这种写法的优点
(1)显式表达“只初始化一次”
代码语义很明确,一眼就能看出:这里就是一次性初始化。
(2)线程安全
不需要手写双重检查锁,也不容易写错。
(3)适合复杂初始化逻辑
如果初始化过程很复杂,或者需要多步控制,call_once 的可读性会更好。
(4)适合模板封装
像上面这种写成模板单例基类时,call_once 比较自然。
5. 这种写法的缺点
(1)代码更长
相比 static 局部静态对象,它写法更重。
(2)通常需要动态分配对象
上面的写法里用了:
1 | _instance = std::shared_ptr<T>(new T); |
这会涉及堆内存分配和智能指针管理。
(3)把 shared_ptr 暴露出去不一定最优
单例更强调“唯一实例”,而 shared_ptr 的语义是“共享所有权”,严格来说不是最贴切。
三、方案二:static 局部静态对象
1. 示例代码
1 |
|
2. 这种写法为什么线程安全?
关键点在于:
C++11 以后,标准规定:函数内局部静态变量的初始化必须是线程安全的。
也就是说:
1 | static Singleton instance; |
如果多个线程同时第一次执行到这里:
- 只有一个线程会真正执行构造
- 其他线程会等待
- 构造完成后,所有线程拿到的是同一个对象
所以它天然适合实现单例。
3. 你可以怎么理解这种线程安全?
你可以把它粗略理解成:
1 | if (还没初始化) { |
当然编译器底层不一定真这么写,但逻辑效果类似。
也就是说,不是 static 关键字自己加锁了,而是 编译器和运行时根据 C++11 标准帮你完成了这一层保护。
4. 这种写法的优点
(1)代码最短
这是单例模式里最经典、最适合面试手撕的写法。
(2)线程安全
C++11 后标准直接保证局部静态变量初始化线程安全。
(3)不需要手动加锁
不用写 mutex,也不用 call_once。
(4)不需要自己管理堆内存
对象直接是静态局部对象,不需要 new,也不需要智能指针。
(5)不容易写错
比双重检查锁更稳,也更容易讲清楚。
5. 这种写法的缺点
(1)灵活性略差
如果你想做非常复杂的初始化控制,call_once 可能更适合。
(2)不适合某些模板基类封装场景
虽然也能做,但不如 call_once 版本自然。
(3)析构时机固定
它的析构通常发生在程序结束阶段,若你想显式控制生命周期,就没那么方便。
四、两种写法的本质区别
| 对比项 | once_flag + call_once |
static 局部静态对象 |
|---|---|---|
| 初始化方式 | 显式一次性初始化 | 局部静态对象首次调用时初始化 |
| 线程安全 | call_once 保证 |
C++11 标准保证 |
| 代码长度 | 较长 | 最短 |
| 是否需要动态分配 | 通常需要 | 不需要 |
| 是否需要智能指针 | 常见 | 不需要 |
| 生命周期控制 | 更灵活 | 相对固定 |
| 面试手撕友好度 | 较好 | 最好 |
| 工程封装能力 | 更适合模板封装 | 更适合简单直接场景 |
五、两者都“线程安全”,到底安全在哪?
这里有一个非常容易被面试官追问的点:
它们保证的是“初始化过程线程安全”
也就是说:
- 单例对象只会被创建一次
- 多线程首次访问不会重复构造
- 不会读到“半初始化对象”
但它们不保证“业务操作线程安全”
例如:
1 | class Singleton { |
如果多个线程同时做:
1 | Singleton::GetInstance().cnt++; |
这个 仍然可能不安全。
因为:
- 单例对象创建是线程安全的
- 但
cnt++不是线程安全的
所以要分清:
- 单例初始化线程安全
- 单例内部成员访问线程安全
这是两回事。
六、面试里更推荐写哪个?
如果只是“手撕单例”
最推荐写:
1 | class Singleton { |
原因很简单:
- 最短
- 最稳
- 最容易写对
- 最容易讲清楚
- 面试官最容易接受
如果面试官追问“还有别的写法吗”
你可以补充:
还有一种是
std::once_flag + std::call_once,它也能保证线程安全的懒加载初始化。
这种写法更适合模板封装,或者初始化流程比较复杂、希望显式表达“只初始化一次”的场景。
这样回答会显得你不只是会背一个模板,而是真的理解原理。
七、面试答题模板
1. 问:为什么 static 局部静态对象线程安全?
可以这样答:
C++11 以后,函数内局部静态变量的初始化由标准保证线程安全。多个线程同时第一次进入时,只有一个线程会完成初始化,其他线程会等待初始化结束,所以用它实现单例不需要自己额外加锁。
2. 问:once_flag + call_once 为什么线程安全?
可以这样答:
std::call_once会配合std::once_flag保证某段初始化代码在并发环境下只执行一次,所以即使多个线程同时调用GetInstance(),也只会创建一个实例,其他线程会等待初始化完成后再继续执行。
3. 问:那单例是不是就完全线程安全了?
可以这样答:
不是。它们只能保证单例对象的初始化过程线程安全,不能自动保证单例对象内部成员访问线程安全。比如多个线程同时修改同一个成员变量,仍然需要额外加锁或者用原子变量处理。
4. 问:你更推荐哪种写法?
可以这样答:
如果是面试手撕,我更推荐局部静态对象版本,因为代码最短、最稳、最不容易写错。
如果是工程里要做模板化封装,或者初始化逻辑比较复杂,call_once版本也很常见。
八、补充:template <typename T> std::shared_ptr<T> Singleton<T>::_instance = nullptr; 是什么意思?
很多人在看模板单例时会卡在这句:
1 | template <typename T> |
它的意思是:
Singleton<T>是一个类模板- 类里面声明了一个静态成员
_instance - 这句是在 类外定义这个静态成员变量
也就是说:
1 | template <typename T> |
这里只是声明。
而下面这句才是真正定义:
1 | template <typename T> |
对于不同的 T,都会各自生成一份独立的 _instance。
例如:
Singleton<A>有一份_instanceSingleton<B>也有一份_instance
它们互不干扰。
九、最终结论
once_flag + call_once
适合:
- 想显式表达“初始化只执行一次”
- 模板封装
- 初始化流程较复杂的场景
static 局部静态对象
适合:
- 面试手撕
- 简洁直接的单例实现
- 希望代码最短、最稳、不容易出错的场景
面试中最佳策略
如果面试官让你手撕单例,优先写 局部静态对象版本。
然后补充一句:
如果需要模板封装或者更显式的一次性初始化控制,也可以使用
once_flag + call_once版本。
这样既能写对,也能体现理解深度。
十、推荐记忆版
你只要记住下面这几句话,面试基本就够用了:
static局部静态对象:C++11 后初始化线程安全call_once + once_flag:保证初始化逻辑只执行一次- 它们保证的是“初始化线程安全”,不是“业务访问线程安全”
- 面试手撕优先写
static局部静态对象版本 - 工程封装或复杂初始化可以考虑
call_once版本
十一、附:两份可直接手撕的代码
1. 面试最推荐版本
1 | class Singleton { |
2. 模板 + call_once 版本
1 |
|
十二、结束语
单例模式本身并不难,真正容易被问住的地方其实是:
- 为什么线程安全
- 安全保证的边界是什么
call_once和static的区别- 模板静态成员为什么要类外定义
把这些点讲清楚,面试里的单例题基本就算稳了。