单例模式

单例模式

时间:2026/05/03

关键词:Singleton、Meyers Singleton、线程安全初始化、std::call_once、初始化失败、重复初始化、析构顺序
核心目标:掌握 C++ 里最常见的单例写法,并能回答面试里关于线程安全、初始化失败和生命周期的问题。


1. 单例模式在解决什么问题

单例模式想解决的是:

  • 某个类在整个进程里只需要一个实例
  • 所有地方访问的是同一个对象
  • 对象创建和生命周期由类自己控制

常见场景:

  • 日志系统
  • 配置管理
  • 资源管理器
  • 全局 ID 生成器
  • 游戏里的全局服务入口

但要注意:

单例本质上是一种“受控的全局对象”,不要因为方便就到处用。

如果一个对象只是普通依赖,优先考虑构造函数传参、依赖注入或明确的所有权关系。


2. C++11 后最推荐的基础写法

最常见、最推荐的是函数局部静态变量,也叫 Meyers Singleton。
在同一个进程中,同一个函数内的 static 局部变量只初始化一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Singleton {
public:
static Singleton& instance() {
static Singleton inst;
return inst;
}

void do_something() {
// ...
}

private:
Singleton() = default;
~Singleton() = default;

Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;

Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
};

调用:

1
Singleton::instance().do_something();

这段代码的重点:

  • 构造函数私有,外部不能随便创建
  • 拷贝和移动都删除,避免复制出第二个实例
  • instance() 里用 static Singleton inst
  • C++11 起,函数局部静态变量初始化是线程安全的

3. 为什么不推荐手写裸指针单例

老式写法经常是:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Singleton {
public:
static Singleton* instance() {
if (ptr_ == nullptr) {
ptr_ = new Singleton();
}
return ptr_;
}

private:
Singleton() = default;
static Singleton* ptr_;
};

问题很多:

  • 多线程下可能重复创建
  • 需要手动释放,容易内存泄漏
  • 释放时机难控制
  • 如果加锁写不好,还可能产生性能或竞态问题

所以现代 C++ 里,基础单例优先用函数局部静态变量。


4. 面试常问:单例是否线程安全

如果是 C++11 及之后的 Meyers Singleton:

1
static Singleton inst;

初始化本身是线程安全的。

也就是说:

  • 多个线程同时第一次调用 instance()
  • 只会有一个线程真正执行构造
  • 其他线程会等待初始化完成

但是要分清楚:

单例对象的“初始化线程安全”不等于“对象内部所有方法都线程安全”。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Counter {
public:
static Counter& instance() {
static Counter c;
return c;
}

void add() {
++value_;
}

private:
int value_ = 0;
};

这里 Counter 的创建是线程安全的,但多个线程同时调用 add() 仍然有数据竞争。

如果内部状态会被并发修改,仍然需要:

  • std::mutex
  • std::atomic
  • 或者更清晰的并发设计

5. 面试常问:单例初始化失败怎么办

初始化失败通常指构造函数里抛异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdexcept>

class Config {
public:
static Config& instance() {
static Config cfg;
return cfg;
}

private:
Config() {
if (!load_file()) {
throw std::runtime_error("load config failed");
}
}

bool load_file() {
return false;
}
};

如果 static Config cfg; 初始化时抛异常:

  • 当前这次 instance() 调用会把异常抛出去
  • 这个局部静态对象不会被视为初始化完成
  • 下一次再调用 instance() 时,会重新尝试初始化

也就是说,C++ 的局部静态变量初始化失败后不是永久失败,而是下次会重试。

面试回答可以这样说:

C++11 的函数局部静态变量初始化是线程安全的;如果构造过程抛异常,本次初始化失败,异常向外传播,下次进入该声明时会再次尝试初始化。

5.1 初始化失败要不要重试

这取决于业务语义。

适合重试的情况:

  • 配置文件短暂不可用
  • 网络资源暂时失败
  • 外部服务可能恢复

不适合无限重试的情况:

  • 程序启动参数错误
  • 必要文件不存在
  • 配置格式根本不合法

工程里可以选择:

  • 直接让异常向外抛,启动失败
  • 在外层捕获异常并打印日志
  • 提供显式 init(),让初始化失败变成可控返回值

6. 面试常问:重复初始化怎么办

“重复初始化”有两种情况。

6.1 多次调用 instance()

对于 Meyers Singleton:

1
2
auto& a = Singleton::instance();
auto& b = Singleton::instance();

这不会重复初始化。

第一次调用时构造对象,后面所有调用都返回同一个对象引用。

6.2 显式 init() 被调用多次

如果单例需要配置参数,就容易出现重复初始化问题。

错误倾向是:

1
2
Logger::instance("a.log");
Logger::instance("b.log");

第一次和第二次传了不同参数,到底该听谁的?这会让语义混乱。

更清晰的方式是拆成:

  • init(config):启动阶段显式初始化
  • instance():使用阶段只获取对象

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <mutex>
#include <stdexcept>
#include <string>
#include <utility>

class Logger {
public:
static void init(std::string file) {
std::lock_guard<std::mutex> lk(mutex_);

if (initialized_) {
throw std::runtime_error("Logger already initialized");
}

file_ = std::move(file);
initialized_ = true;
}

static Logger& instance() {
if (!initialized_) {
throw std::runtime_error("Logger not initialized");
}

static Logger logger;
return logger;
}

private:
Logger() = default;

inline static std::mutex mutex_;
inline static bool initialized_ = false;
inline static std::string file_;
};

这类写法的核心是:

  • 重复初始化要么直接忽略,要么明确报错
  • 不要让不同配置悄悄覆盖已有配置
  • 初始化和使用阶段要有清晰边界

不过上面这版还有个细节:instance() 读取 initialized_ 时没有加锁。更严谨的工程代码要么也加锁,要么用 std::atomic<bool>,要么使用 std::call_once


7. 使用 std::call_once 的写法

如果不想依赖局部静态变量,或者要做更复杂的初始化,可以用 std::call_once

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <memory>
#include <mutex>

class Singleton {
public:
static Singleton& instance() {
std::call_once(flag_, [] {
ptr_ = std::unique_ptr<Singleton>(new Singleton());
});
return *ptr_;
}

private:
Singleton() = default;

inline static std::once_flag flag_;
inline static std::unique_ptr<Singleton> ptr_;
};

特点:

  • 初始化逻辑只会成功执行一次
  • 多线程同时调用时,只有一个线程执行初始化
  • 如果初始化函数抛异常,once_flag 不会被标记完成,下次会继续尝试

不过对于普通单例,Meyers Singleton 更简洁。


8. 面试常问:双重检查锁为什么容易出问题

经典写法类似:

1
2
3
4
5
6
if (ptr == nullptr) {
std::lock_guard<std::mutex> lk(mutex);
if (ptr == nullptr) {
ptr = new Singleton();
}
}

它叫 Double-Checked Locking。

问题在于:

  • 对象构造和指针赋值涉及内存可见性
  • 没有正确的原子操作和内存序,其他线程可能看到“指针非空但对象还没完全构造好”
  • 写对很麻烦,写错很隐蔽

现代 C++ 面试里可以直接说:

不建议手写双重检查锁。C++11 后用函数局部静态变量或 std::call_once 更简单、更安全。


9. 面试常问:单例什么时候销毁

Meyers Singleton 的对象是函数局部静态变量:

1
static Singleton inst;

它会在程序结束时自动析构。

但这里有一个经典问题:

静态对象析构顺序不容易控制。

如果多个全局对象或单例互相依赖,程序退出时可能出现:

  1. 单例 A 已经析构
  2. 单例 B 的析构函数里还想用 A
  3. 访问已经销毁的对象,产生未定义行为

应对方式:

  • 避免单例之间在析构阶段互相调用
  • 把释放逻辑放到明确的 shutdown() 阶段
  • 对某些进程级对象,接受“不主动析构”,让操作系统在进程退出时回收

有些日志系统会故意写成泄漏式单例:

1
2
3
4
static Logger& instance() {
static Logger* logger = new Logger();
return *logger;
}

这样对象不会在程序退出时自动析构,可以避开析构顺序问题。

但代价是:

  • 内存检查工具会看到泄漏
  • 资源释放不够优雅
  • 不适合所有场景

所以这是工程取舍,不是默认推荐写法。


10. 面试常问:单例能不能带参数

可以,但要小心。

不推荐这样:

1
2
Config& c1 = Config::instance("dev.yaml");
Config& c2 = Config::instance("prod.yaml");

因为第二次调用传入的参数通常不会生效,容易误导调用方。

更推荐:

1
2
Config::init("dev.yaml");
auto& config = Config::instance();

也就是:

  • 初始化参数只在启动阶段传一次
  • 之后使用时不再传参数
  • 重复初始化时明确报错

11. 单例的优缺点

优点:

  • 使用方便
  • 保证进程内只有一个实例
  • 适合管理全局唯一资源

缺点:

  • 本质上是全局状态
  • 容易隐藏依赖关系
  • 测试时不好替换
  • 生命周期复杂时容易踩坑
  • 多线程下内部状态仍然需要额外保护

面试里不要只说“单例简单方便”,最好补一句:

单例适合管理少数真正全局唯一的服务,但滥用会让依赖关系变隐式,降低可测试性。


12. 常见面试问题速答

12.1 C++ 单例怎么写最简单安全

用函数局部静态变量:

1
2
3
4
static Singleton& instance() {
static Singleton inst;
return inst;
}

C++11 后初始化线程安全。

12.2 如何防止创建多个实例

  • 构造函数私有
  • 删除拷贝构造
  • 删除拷贝赋值
  • 删除移动构造
  • 删除移动赋值

12.3 初始化失败怎么办

构造函数抛异常时,本次初始化失败,异常向外传播;下一次调用 instance() 会再次尝试初始化。

如果失败不可恢复,可以在程序启动阶段捕获异常并直接终止启动。

12.4 重复初始化怎么办

如果只是多次调用 instance(),不会重复初始化。

如果是显式 init(config) 被调用多次,要明确策略:

  • 要么幂等,重复相同配置直接返回
  • 要么报错
  • 不要悄悄覆盖已有配置

12.5 单例对象内部方法一定线程安全吗

不一定。

单例初始化线程安全,只代表对象创建过程安全。对象内部如果有共享可变状态,仍然要自己加锁或使用原子变量。

12.6 单例和全局变量有什么区别

单例可以控制创建时机、禁止复制、封装访问入口。
但它仍然带有全局状态的缺点。


13. 一页总结

现代 C++ 写单例优先记住:

  1. 用函数局部静态变量实现基础单例
  2. 私有构造,删除拷贝和移动
  3. C++11 后局部静态变量初始化线程安全
  4. 初始化失败抛异常后,下次调用会重试
  5. 重复初始化要有明确策略
  6. 单例创建安全不等于内部方法线程安全
  7. 小心析构顺序和隐藏依赖

如果只记一句:

单例不是“到处方便访问”的借口,而是“进程内确实只应该有一个实例”的受控设计。