C++ 单例模式完全指南:从饿汉式到现代 C++ 的最佳实践
单例模式大概是面试中出现频率最高的设计模式,没有之一。它看似简单------"一个类只有一个实例"------但实现起来细节极多:线程安全、内存释放、C++11 的静态局部变量特性、懒汉 vs 饿汉、双重检查锁定......
今天我们从零开始,把单例模式的所有知识点一一拆解。
1. 什么是单例模式?为什么需要它?
定义 :确保一个类只有一个实例 ,并提供一个全局访问点。
现实场景:
- 日志管理器:整个程序共用一个日志输出通道
- 配置管理器:全局共享同一份配置
- 数据库连接池:只有一个连接池实例
- 线程池:全局共享
cpp
// 理想的使用方式
Logger::getInstance().log("Application started");
ConfigManager::getInstance().get("db.host");
核心要求:
- 构造函数必须私有,防止外部创建
- 提供一个静态方法获取唯一实例
- 删除拷贝构造和拷贝赋值,防止复制
2. 最简单的版本(非线程安全)
cpp
class Singleton {
private:
static Singleton* instance; // 静态指针,存储唯一实例
Singleton() {} // 构造函数私有
public:
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton(); // 第一次调用时创建
}
return instance;
}
// 禁止拷贝
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
// 静态成员初始化
Singleton* Singleton::instance = nullptr;
问题:
- 非线程安全 :多线程同时第一次调用
getInstance(),可能创建多个实例 - 内存泄漏 :没人释放
new出来的实例
3. 线程安全版本进化史
3.1 加锁的懒汉式(性能差)
cpp
#include <mutex>
class Singleton {
private:
static Singleton* instance;
static std::mutex mtx;
Singleton() {}
public:
static Singleton* getInstance() {
std::lock_guard<std::mutex> lock(mtx); // 每次调用都加锁
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mtx;
问题:每次获取实例都要加锁,高并发下性能很差。实际上只有第一次创建时需要锁。
3.2 双重检查锁定(DCLP)------C++11 前不可靠
cpp
class Singleton {
private:
static Singleton* instance;
static std::mutex mtx;
Singleton() {}
public:
static Singleton* getInstance() {
if (instance == nullptr) { // 第一重检查:避免不必要的加锁
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) { // 第二重检查:避免重复创建
instance = new Singleton();
}
}
return instance;
}
};
重要警告 :在 C++11 之前,双重检查锁定不是线程安全的 !原因是 new Singleton() 并非原子操作,它分为三步:
- 分配内存
- 调用构造函数
- 将指针指向分配的内存
编译器可能重排序 步骤 2 和步骤 3。如果线程 A 先执行了步骤 3(但步骤 2 还没完成),线程 B 在第一重检查时看到 instance 不为空,直接返回了一个未构造完成的对象,这就是著名的"部分构造"问题。
C++11 后可以用原子操作修复这个问题,但太复杂,不推荐手写。
3.3 饿汉式:程序启动时就创建
cpp
class Singleton {
private:
static Singleton* instance;
Singleton() {}
public:
static Singleton* getInstance() {
return instance; // 不需要检查,直接返回
}
};
// 在 main() 之前就创建好
Singleton* Singleton::instance = new Singleton();
优点 :天生线程安全(在进入 main 之前就构造完成)
缺点:
- 启动变慢:即使从不使用也会创建
- 初始化顺序问题:如果单例依赖其他全局变量,可能出错
- 无法控制创建时机
4. Meyers 单例:C++11 的最优解
C++11 标准保证了局部静态变量初始化的线程安全性。Scott Meyers 最早推广这种写法,因此被称为 Meyers 单例。
cpp
class Singleton {
private:
Singleton() = default;
public:
static Singleton& getInstance() {
static Singleton instance; // C++11 保证这行是线程安全的
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
// 使用
Singleton::getInstance().doSomething();
这是现代 C++ 中推荐的单例写法,原因如下:
- 线程安全:C++11 标准保证局部静态变量在多线程环境下只初始化一次
- 延迟初始化:第一次调用时才创建(懒加载)
- 自动销毁:程序结束时自动调用析构函数,不会内存泄漏
- 简洁:代码极少,不易出错
- 不需要手动管理指针:返回引用,不是指针
C++11 标准的原话(简化):如果多个线程同时试图初始化同一个局部静态变量,初始化只会发生一次。一个线程会执行初始化,其他线程会等待。
完整版本
cpp
class Logger {
private:
std::ofstream logFile;
// 构造函数私有
Logger() {
logFile.open("app.log", std::ios::app);
if (!logFile.is_open()) {
throw std::runtime_error("Cannot open log file");
}
}
// 析构函数公有(或私有,但要能访问)
public:
~Logger() {
if (logFile.is_open()) {
logFile.close();
}
}
public:
static Logger& getInstance() {
static Logger instance;
return instance;
}
void log(const std::string& message) {
logFile << message << std::endl;
}
// 禁止拷贝和移动
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
Logger(Logger&&) = delete;
Logger& operator=(Logger&&) = delete;
};
// 使用
Logger::getInstance().log("Application started");
5. 模板化的单例基类
如果有多个单例类,可以用 CRTP(奇异递归模板模式)来复用代码:
cpp
template<typename T>
class Singleton {
public:
static T& getInstance() {
static T instance;
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
protected:
Singleton() = default;
~Singleton() = default;
};
// 使用
class Logger : public Singleton<Logger> {
friend class Singleton<Logger>; // 允许基类访问私有构造
private:
Logger() = default;
public:
void log(const std::string& msg) {
std::cout << msg << std::endl;
}
};
class Config : public Singleton<Config> {
friend class Singleton<Config>;
private:
Config() = default;
public:
std::string get(const std::string& key) { /* ... */ }
};
// 使用
Logger::getInstance().log("Hello");
Config::getInstance().get("db.host");
注意 :需要将 Singleton<Logger> 声明为 Logger 的友元,因为基类需要调用派生类的私有构造函数。
6. 单例模式的释放问题
6.1 Meyers 单例自动释放
cpp
static Logger& getInstance() {
static Logger instance; // 程序结束时自动调用 ~Logger()
return instance;
}
Meyers 单例的局部静态变量会在程序结束时自动销毁,无需手动管理。
6.2 指针版本需要手动释放
如果你坚持用指针版本(不推荐),可以这样安全释放:
cpp
class Singleton {
private:
static Singleton* instance;
Singleton() {}
// 内嵌的垃圾回收类
class GC {
public:
~GC() {
if (Singleton::instance != nullptr) {
delete Singleton::instance;
Singleton::instance = nullptr;
}
}
};
static GC gc; // 程序结束时,gc 的析构会释放 instance
public:
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
};
Singleton* Singleton::instance = nullptr;
Singleton::GC Singleton::gc; // 这个静态对象销毁时会释放单例
这种利用"静态对象析构"来自动清理的技巧,在历史代码中很常见。但在现代 C++ 中,直接用 Meyers 单例就好。
7. 各种实现方式对比
| 实现方式 | 线程安全 | 懒加载 | 自动释放 | 代码复杂度 | 推荐度 |
|---|---|---|---|---|---|
| 简单懒汉 | 否 | 是 | 否 | 低 | 仅单线程 |
| 加锁懒汉 | 是 | 是 | 否 | 中 | 不推荐(性能差) |
| 双重检查锁 | C++11 前不可靠 | 是 | 否 | 高 | 不推荐 |
| 饿汉式 | 是 | 否 | 需手动 | 低 | 特定场景 |
| Meyers | 是 | 是 | 是 | 极低 | 强烈推荐 |
8. 单例模式的争议与替代方案
8.1 单例的缺点
- 全局状态:本质上是全局变量,增加了模块耦合
- 难以测试:单例状态在测试间残留,难以隔离
- 隐藏依赖 :调用
Singleton::getInstance()不反映在接口上 - 生命周期不可控:多个单例之间的析构顺序不确定
- 多线程复杂性:虽然 Meyers 解决了创建时的线程安全,但使用时的线程安全仍需自行保证
8.2 替代方案:依赖注入
cpp
// 代替单例:通过构造函数传入依赖
class Service {
Logger& logger;
Config& config;
public:
Service(Logger& log, Config& cfg) : logger(log), config(cfg) {}
// 依赖关系清晰可见
};
// 使用时
Logger logger;
Config config;
Service service(logger, config);
依赖注入使得依赖关系显式化,更容易测试和维护。在现代 C++ 项目中,依赖注入往往是比单例更好的选择。
8.3 什么时候还可以用单例?
- 确实需要全局唯一的资源(日志系统、硬件抽象层)
- 工具类函数集合(无状态或状态确实是全局的)
- 性能敏感且不想传递依赖的场景
9. 面试常考清单
9.1 什么是单例模式?如何保证只有一个实例?
答案要点:
- 构造函数私有化
- 静态成员存储唯一实例
- 静态方法提供全局访问点
- 禁止拷贝和移动
9.2 饿汉式和懒汉式的区别?各有什么优缺点?
答案要点:
- 饿汉式:程序启动就创建,天生线程安全,但可能浪费资源,初始化顺序难控制
- 懒汉式:第一次使用时创建,节省资源,但需要考虑线程安全问题
9.3 双重检查锁定是什么?C++11 之前有什么问题?
答案要点 :双重检查锁定是先检查实例是否存在(避免加锁),再加锁二次检查。C++11 之前的问题是 new 操作并非原子,编译器和 CPU 可能重排序指令,导致线程看到未完全构造的对象。
9.4 C++11 之后最推荐的写法是什么?为什么?
答案要点:Meyers 单例。使用函数内局部静态变量,C++11 保证了多线程环境下的安全初始化,代码简洁,自动释放资源。
9.5 如何防止单例被拷贝和移动?
答案要点:
cpp
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
Singleton(Singleton&&) = delete;
Singleton& operator=(Singleton&&) = delete;
9.6 单例模式有什么缺点?
答案要点:全局状态增加耦合、难以单元测试、隐藏依赖关系、多单例间的析构顺序不确定。
9.7 如何销毁单例实例?
答案要点:Meyers 单例会在程序结束时自动销毁。如果是指针版本,可以利用内嵌的静态 GC 类在析构时释放。
9.8 多个单例之间的析构顺序问题怎么解决?
答案要点:很难完美解决。可以设计为不依赖析构函数做关键清理工作,或者使用依赖注入代替单例,将生命周期管理交给外部。
10. 最佳实践总结
cpp
// 现代 C++ 单例模式的黄金模板
class MySingleton {
private:
// 1. 构造和析构私有
MySingleton() = default;
~MySingleton() = default;
public:
// 2. Meyers 单例获取方法
static MySingleton& getInstance() {
static MySingleton instance;
return instance;
}
// 3. 禁止拷贝和移动
MySingleton(const MySingleton&) = delete;
MySingleton& operator=(const MySingleton&) = delete;
MySingleton(MySingleton&&) = delete;
MySingleton& operator=(MySingleton&&) = delete;
// 4. 业务方法
void doSomething() { /* ... */ }
};
记住:Meyers 单例 + = delete 拷贝移动 = 现代 C++ 单例的唯一正解。
同时也要记住:单例不是银弹。在可以使用依赖注入的场景,优先考虑将依赖显式化,让你的代码更容易测试、更容易理解、更容易维护。