单例模式
1. 单例模式
代码示例
#include <iostream>
#include <string>
#include <mutex>
using namespace std;
class ConfigManager {
public:
// 【核心获取接口】
// C++11 保证:局部静态变量的初始化是线程安全的。
// 这被称为 "Meyers' Singleton"
static ConfigManager& GetInstance() {
static ConfigManager instance; // 唯一实例,懒加载 (Lazy Initialization)
return instance;
}
// 业务方法:设置配置
void SetConfig(const string& key, const string& value) {
// 模拟线程锁(虽然这里只是示例,但在真实单例中通常需要锁业务数据)
// lock_guard<mutex> lock(mtx);
this->configData = key + "=" + value;
cout << "[单例] 配置已更新: " << configData << " (地址: " << this << ")" << endl;
}
// 业务方法:读取配置
string GetConfig() {
return configData;
}
// 【关键点 1】删除拷贝构造函数
// 防止代码中出现: ConfigManager c = ConfigManager::GetInstance();
ConfigManager(const ConfigManager&) = delete;
// 【关键点 2】删除赋值运算符
// 防止代码中出现: c1 = c2;
ConfigManager& operator=(const ConfigManager&) = delete;
private:
// 【关键点 3】构造函数私有化
// 禁止外部直接 new ConfigManager() 或 ConfigManager c;
ConfigManager() {
cout << ">>> 单例对象被创建 (只执行一次) <<<" << endl;
configData = "default";
}
// 【关键点 4】析构函数私有化 (可选,通常设为私有或默认)
// 程序结束时系统会自动回收 static 变量
~ConfigManager() {
cout << ">>> 单例对象被销毁 <<<" << endl;
}
string configData;
};
// -----------------------------------------------------------
void TestThreadA() {
// 无论哪里调用,获取到的都是同一个引用
ConfigManager& mgr = ConfigManager::GetInstance();
mgr.SetConfig("Theme", "Dark");
}
void TestThreadB() {
ConfigManager& mgr = ConfigManager::GetInstance();
cout << "线程B 读取配置: " << mgr.GetConfig() << endl;
}
int main() {
cout << "=== 程序启动 ===" << endl;
// 第一次调用 GetInstance,触发构造函数
TestThreadA();
// 第二次调用,直接返回已存在的实例,不触发构造
TestThreadB();
// 试图创建新对象会报错:
// ConfigManager c; // 编译错误:构造函数是私有的
cout << "=== 程序结束 ===" << endl;
return 0;
}
1. 定义
- 原文: 保证一个类只有一个实例,并提供一个访问它的全局访问点。
- 解读:
- “只有一个实例”:系统里只能有一个“皇帝”。比如系统配置、日志管理器、显卡驱动对象。如果不止一个,可能会导致资源冲突(比如两个对象同时写一个日志文件)。
- “全局访问点”:
GetInstance()就是那个“皇宫大门”。你不需要在各个函数之间传递这个对象,任何地方只要调用这个静态方法就能找到它。
2. 解决的问题 (Stable vs. Changing)
单例模式比较特殊,它主要控制的是对象的生命周期。
- 稳定点 (Stable):实例的唯一性
- 无论系统怎么运行,这个类在内存中永远只有一份。这个规则是绝对锁死的。
- 访问方式 (
GetInstance) 也是稳定的。
- 变化点 (Changing):实例的内部状态
- 虽然人只有一个,但他衣服可以换(配置数据可以变)。
- 调用者的位置是变化的(可以在 Main 里调,也可以在子线程调)。
3. 代码结构 (Code Structure)
C++ 实现单例的“四大金刚”:
- 私有构造函数 (Private Constructor):
- 关门打狗。禁止外部使用
new随意创建对象。
- 关门打狗。禁止外部使用
- 静态获取方法 (Static Public Method):
static ConfigManager& GetInstance()。这是外界唯一的入口。- 使用引用 (
&) 返回,避免拷贝。
- 局部静态变量 (Local Static Variable):
static ConfigManager instance;- 懒加载 (Lazy Load): 只有第一次代码执行到这一行时,对象才会被创建。如果不调用,就不创建,节省内存。
- 线程安全: C++11 标准强制规定 static 变量初始化必须是线程安全的(底层编译器加了锁)。
- 删除拷贝/赋值 (Delete Copy/Assign):
= delete。防止好不容易生成的唯一实例,被不知情的程序员通过ConfigManager b = a;给克隆了一份,破坏了唯一性。
4. 符合哪些设计原则?
- 单一职责原则 (SRP) —— (注意:这里有争议)
- 严格来说,单例模式违反了 SRP。因为它既负责了“业务逻辑”(存取配置),又负责了“管理自己的生命周期”(确保唯一)。
- 但是,为了便利性和资源的唯一控制,我们通常接受这种“适度的违规”。
- 迪米特法则 (LoD) —— (反面教材)
- 单例模式通常会违反最少知道原则。因为全局都能访问它,导致系统各处都隐含地依赖了这个单例。这会让代码耦合度变高,单元测试变难(你很难 mock 一个静态单例)。
- 受控访问原则
- 这是单例最大的优点。它严格控制了谁能创建我,以及什么时候创建。
5. 如何扩展? (单例的局限性)
单例模式的扩展性较差,这通常是它的痛点:
- 很难通过继承扩展:
- 因为构造函数是私有的,子类无法调用父类构造函数。
- 如果要支持多态单例(比如根据配置决定生成
FileLogger还是DbLogger单例),通常需要将单例改为工厂模式 + 单例的注册表机制,比较复杂。
- 如果需求变了:
- 如果有一天需求变成“需要两个配置管理器(一个管前台,一个管后台)”,你需要修改
GetInstance的代码,甚至把单例模式删掉。
- 如果有一天需求变成“需要两个配置管理器(一个管前台,一个管后台)”,你需要修改
2.单例模式的演变
第一阶段:原始时代(教科书式的懒汉写法)
这就是你图片中展示的代码。
-
写法:先判断指针是否为空,为空则创建。
-
代码核心:
static Singleton* GetInstance() { if (_instance == nullptr) { _instance = new Singleton(); } return _instance; } -
评价:
- ❌ 致命弱点:线程不安全。如果两个线程同时进入
if,会创建两个对象,甚至导致内存泄漏。 - ✅ 优点:简单,符合直觉。单线程环境下没问题。
- ❌ 致命弱点:线程不安全。如果两个线程同时进入
第二阶段:加锁时代(简单粗暴的线程安全)
为了解决线程冲突,最直接的办法就是加锁。
-
写法:进入函数直接加锁。
-
代码核心:
static Singleton* GetInstance() { std::lock_guard<std::mutex> lock(_mutex); // 自动加锁解锁 if (_instance == nullptr) { _instance = new Singleton(); } return _instance; } -
评价:
- ✅ 优点:绝对的线程安全。
- ❌ 致命弱点:性能极差。每次调用
GetInstance都要加锁解锁,即使对象已经创建好了(此时本不需要锁),这在高并发下是巨大的瓶颈。
第三阶段:双重检查锁定 (DCLP) 时代(聪明反被聪明误)
为了解决性能问题,程序员想出了“双重检查锁定”(Double-Checked Locking Pattern)。
-
写法:先判断是否为空(避免不必要的加锁),如果为空再加锁,加锁后再判断一次(防止并发穿透)。
-
代码核心:
static Singleton* GetInstance() { if (_instance == nullptr) { // 第一次检查 std::lock_guard<std::mutex> lock(_mutex); if (_instance == nullptr) { // 第二次检查 _instance = new Singleton(); } } return _instance; } -
评价:
- 这是一个C++历史上的经典“坑”。
- 在 C++11 之前:这种写法是错误的。原因在于 CPU 指令重排 (Instruction Reordering)。
_instance = new Singleton();这行代码实际分为三步:- 分配内存。
- 在内存上构造对象。
- 将
_instance指针指向这块内存。
- 编译器或 CPU 可能会把步骤 2 和 3 颠倒。如果线程 A 执行了 1 和 3(指针非空了),但还没执行 2(对象还没造好),此时线程 B 进来判断指针非空,直接拿去用,就会导致程序崩溃。
- 在 C++11 之后:配合
std::atomic和内存序(Memory Order),DCLP 才可以正确实现,但写起来非常麻烦且容易出错。
第四阶段:Meyers' Singleton(现代 C++ 的最佳实践)
这是由《Effective C++》作者 Scott Meyers 提出的写法。它利用了 C++11 的新特性:静态局部变量的初始化是线程安全的。
-
写法:去掉指针,去掉
new,直接在函数内部定义一个static对象。 -
代码核心:
class Singleton { public: static Singleton& GetInstance() { static Singleton instance; // 局部静态变量 return instance; } // ... 禁用拷贝和移动 ... private: Singleton() {} }; -
原理:C++11 标准规定,如果多个线程同时试图初始化同一个静态局部变量,并发是会被自动控制的(编译器会自动加锁)。
-
评价:
- ✅ 极其优雅:代码最少。
- ✅ 线程安全:标准保证。
- ✅ 懒加载:第一次调用函数时才初始化。
- ✅ 自动内存管理:程序结束时自动析构,没有内存泄漏风险。
- 这是目前面试和工程中的首选写法。
补充知识
1. C++98 时代:多核时代的“蛮荒期”
在 C++11 标准发布之前,C++ 标准库本身并没有包含多线程支持。
- 依赖原生 API (
pthread):- 开发者必须依赖操作系统提供的底层 API,例如 Linux 下的
pthread库(pthread_create,pthread_join等)。 - 这导致代码的可移植性较差(Windows 和 Linux 的 API 不同)。
- 开发者必须依赖操作系统提供的底层 API,例如 Linux 下的
- 面临的严峻挑战(优化与一致性):
- 编译器与 CPU 重排 (Reordering): 图片中特别指出了“编译器重排”和“CPU重排”。为了优化性能,编译器和 CPU 可能会打乱指令的执行顺序。
- 违反顺序一致性: 在单线程中,这种重排通常是安全的;但在多线程中,这会导致严重的逻辑错误。
- 可见性问题 (Visibility): 一个线程修改了变量,另一个线程可能无法立即看到(因为数据可能还停留在 CPU 缓存中,未同步到主存)。
- 执行序问题: 代码的执行顺序无法得到保证。
2. C++11 时代:标准化的并发支持
C++11 引入了全新的内存模型和标准线程库,正式将多线程编程纳入标准。
- 标准库工具:
- 线程 (
std::thread): 跨平台的线程创建与管理。 - 互斥锁 (
std::mutex): 用于保护临界区,防止数据竞争。 - 原子操作 (
std::atomic): 提供了不需要加锁的线程安全操作,是实现无锁编程的基础。 - 内存栅栏 (Memory Barrier): 用于控制指令重排,强制保证内存操作的顺序,解决可见性和执行序问题。
- 线程 (
- 关于 Volatile:
- 图片中提到了
volatile java。这是一个对比:Java 中的volatile关键字保证了变量的可见性和有序性。 - 注意: 在 C++ 中,
volatile不保证原子性或线程同步(它只用于防止编译器优化读取)。C++11 中解决线程同步和可见性问题的正确工具是std::atomic和std::mutex。
- 图片中提到了
3. 同步原语 (Synchronization Primitives)
图片右下角的框图总结了 C++11 并发编程的核心基石:
- 原子变量 (Atomic Variable): 最底层的同步机制。
- 内存 (Memory): 通过原子变量和内存序(Memory Order)来控制内存在不同线程间的视图一致性。
💡 总结与建议
- 什么时候用?
- 确实只需要一个对象(如线程池、缓存、注册表、日志)。
- 对象需要被多处共享访问。
- 什么时候慎用?
- 不要滥用单例当全局变量。如果只是为了传参方便,不要用单例。
- 单例会让模块间的依赖关系变得隐晦(函数签名看不出它用了单例,只有读代码才知道),这会增加维护成本。
浙公网安备 33010602011771号