单例模式
什么是单例模式?
单例模式是一种创建型设计模式,它保证 一个类只有一个实例,并提供一个全局访问点。就像 一个国家只有一个总统 。
核心特点
- 唯一性 :内存中只能有一个对象。
- 全局访问 :任何地方都可以通过 GetInstance () 访问它。
- 私有构造 :构造函数必须私有 (private),防止外部 new 。
- 禁拷贝 :拷贝构造和赋值运算符必须禁用 (delete)。
现在我们要对下面这个很大的类做一个单例模式
class BigData {
public:
BigData() {
std::cout << " [BigData] 构造函数被调用 (加载100GB数据...)" << std::endl;
}
void process() {
std::cout << " [BigData] 正在处理数据..." << std::endl;
}
};
饿汉模式
-
原理 : "饿了就要吃" 。程序一启动( main 函数执行前),就立马把对象创建好。
class SingletonEager {
private:
static BigData data; // 静态成员对象
SingletonEager() {} // 私有构造函数,禁止外部创建
SingletonEager(const SingletonEager&) = delete;
SingletonEager& operator=(const SingletonEager&) = delete;public:
static BigData* GetInstance() {
return &data;
}
};// 在类外初始化静态成员
BigData SingletonEager::data; -
优点 :
- 天然线程安全 :C++ 保证静态变量在 main 之前初始化,此时还没多线程。
- 执行效率高 :获取实例时不需要加锁判断。
-
缺点 :
- 启动慢 :如果对象很大(加载 100G 数据),程序启动会卡很久。
- 浪费内存 :如果程序运行了一整天都没用到它,这 100G 内存就白占了。
懒汉模式 - 线程不安全
-
原理 : "懒得动,要用才去洗碗" 。第一次调用 GetInstance 时才创建。
-
问题 :多线程环境下,A 线程判断 inst == NULL 准备创建,还没创建完,B 线程也判断 inst == NULL ,于是两人都创建了一份。
class SingletonLazyUnsafe {
private:
static BigData* inst;
SingletonLazyUnsafe() {}
SingletonLazyUnsafe(const SingletonLazyUnsafe&) = delete;
SingletonLazyUnsafe& operator=(const SingletonLazyUnsafe&) = delete;
public:
static BigData* GetInstance() {
if (inst == NULL) {
inst = new BigData();
}
return inst;
}
};
BigData* SingletonLazyUnsafe::inst = NULL;
懒汉模式 - 线程安全版
这是经典的 生产级实现。
class SingletonLazySafe {
private:
// volatile: 防止编译器对代码进行过度优化(例如指令重排),
// 确保多线程下 inst 的可见性和有序性。
volatile static BigData* inst;
static std::mutex _mtx;
SingletonLazySafe() {}
SingletonLazySafe(const SingletonLazySafe&) = delete;
SingletonLazySafe& operator=(const SingletonLazySafe&) = delete;
public:
static BigData* GetInstance() {
// 第一重检查:如果已经创建了,直接返回,避免每次都加锁(性能关键!)
if (inst == NULL) {
_mtx.lock(); // 加锁
// 第二重检查:防止在加锁等待期间,别人已经创建了
if (inst == NULL) {
inst = new BigData();
}
_mtx.unlock(); // 解锁
}
return (BigData*)inst;
}
};
volatile BigData* SingletonLazySafe::inst = NULL;
std::mutex SingletonLazySafe::_mtx;
- 注意点 :
- 双重 if :外层 if 挡住 99% 的请求(避免锁竞争),内层 if 保证安全性。
- volatile :
volatile static T* inst;防止编译器优化指令重排(在某些老旧编译器或特定硬件上, new 操作可能被乱序,导致返回未完全构造的对象)。
Meyers' Singleton
如果你用的是 C++11 及以上,这是 最推荐 的写法。
class SingletonMeyers {
private:
SingletonMeyers() {}
public:
static BigData& GetInstance() {
// C++11 规定:局部静态变量的初始化是线程安全的
static BigData instance;
return instance;
}
};
- 原理 :C++11 标准明确规定: 局部静态变量的初始化是线程安全的。编译器会自动加锁保护初始化过程。
- 优点 :代码极少,既是懒汉(第一次调用才初始化),又是线程安全的,还没指针管理的麻烦。
总结建议
| 模式 | 启动速度 | 运行时性能 | 线程安全 | 推荐指数 |
|---|---|---|---|---|
| 饿汉 | 慢 | 快 (无锁) | 是 | ⭐⭐ (仅限小对象) |
| 懒汉 (不安全) | 快 | 快 | 否 | ❌ (禁止使用) |
| 懒汉 (DCL) | 快 | 中 (首次加锁) | 是 | ⭐⭐⭐ (旧标准 / 复杂控制) |
| Meyers (局部静态) | 快 | 快 | 是 | ⭐⭐⭐⭐⭐ (C++11 首选) |
关于volatile
你可能以为 inst = new T(); 是一个原子操作(要么做完,要么没做),但实际上,编译器会把它拆成三步独立的指令:
// 伪代码:new T() 的实际执行步骤
1. 分配内存:给 T 类型的对象申请一块内存空间(比如 malloc );
2. 初始化对象:调用 T 的构造函数,给这块内存赋值(比如初始化成员变量);
3. 指针赋值:把 inst 指针指向刚分配的内存地址。
正常情况下,CPU 按「1→2→3」执行,inst 只有在对象完全构造好后才会非 NULL,这没问题。
但是为了提升执行效率,编译器(或 CPU)会对没有数据依赖的指令做「指令重排」(这就是 "过度优化" 的核心)。
对于上面的三步,编译器会认为:"步骤 2(初始化对象)和步骤 3(指针赋值)没有直接依赖",于是可能把顺序改成:
1. 分配内存 → 3. 指针赋值 → 2. 初始化对象
这个重排对单线程完全无害,但对「多线程的 DCL 场景」是致命的!
假设现在有线程 A 和线程 B,执行流程如下:
- 线程 A 执行
inst = new T(),被重排为「1→3→2」:- 步骤 1:分配了内存;
- 步骤 3:inst 指针已经指向这块内存(此时
inst != NULL); - 步骤 2:还没执行(对象还没初始化,是 "半成品")。
- 线程 B 此时走到 DCL 的「第一重检查」:
if (inst == NULL),发现inst != NULL,直接返回这个指针; - 线程 B 拿到
inst后,试图调用对象的方法 / 访问成员变量 ------ 但对象还没完成构造,结果就是程序崩溃、数据错乱、逻辑异常(比如访问未初始化的成员变量)。
volatile 关键字的核心作用,就是给编译器 / CPU 下 "禁令",针对被修饰的变量(比如 volatile static T* inst):
- 禁止指令重排 :编译器 / CPU 不能对涉及
volatile变量的指令做重排 ------ 也就是说,inst = new T()的三步必须严格按「1→2→3」执行,inst只有在对象完全构造后才会非 NULL; - 禁止缓存优化 :保证每次读写
inst都是直接操作内存,而不是缓存到 CP
死锁
死锁是指多个执行流(进程 / 线程)在执行过程中,因争夺资源而造成的一种互相等待的现象。 如果没有外力干预,它们都将无法推进下去,程序就像 "卡死" 了一样。
形象比喻:两个人过独木桥,A 在桥头占着位置等 B 让路,B 在对面占着位置等 A 让路,结果谁也过不去。
死锁发生的四个必要条件
这四个条件缺一不可,只要破坏其中任意一个,死锁就不会发生。
-
互斥条件资源是独占的,同一时刻只能被一个线程使用(如互斥锁)。这是锁的特性,通常无法破坏。
-
请求与保持条件吃着碗里的,看着锅里的。线程已经持有了锁 A,在不释放 A 的情况下,去申请锁 B。
-
不剥夺条件线程持有的资源,在未用完之前,不能被其他线程强行抢走。只能由它自己主动释放。
-
循环等待条件A 等 B,B 等 C,...,Z 等 A。形成了一个闭环。
场景一:死锁现场
void deadlock_routine_A() {
std::lock_guard<std::mutex> lock1(mtx1);
std::cout << "[线程A] 获取了 mtx1,正在处理..." << std::endl;
// 模拟处理耗时,确保线程B有机会获取 mtx2,形成死锁条件
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "[线程A] 尝试获取 mtx2..." << std::endl;
std::lock_guard<std::mutex> lock2(mtx2); // 在这里阻塞,等待 mtx2
std::cout << "[线程A] 成功获取 mtx2,执行完毕。" << std::endl;
}
void deadlock_routine_B() {
std::lock_guard<std::mutex> lock2(mtx2);
std::cout << "[线程B] 获取了 mtx2,正在处理..." << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "[线程B] 尝试获取 mtx1..." << std::endl;
std::lock_guard<std::mutex> lock1(mtx1); // 在这里阻塞,等待 mtx1
std::cout << "[线程B] 成功获取 mtx1,执行完毕。" << std::endl;
}
结果:
[线程A] 获取了 mtx1...
[线程B] 获取了 mtx2...
[线程B] 尝试获取 mtx1... (等待)
[线程A] 尝试获取 mtx2... (等待)
(程序永久卡死)
这就构成了典型的环路等待:A -> mtx2 -> B -> mtx1 -> A。
破解法一
统一加锁顺序
void safe_routine_ordered1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::cout << "[SafeThread1] 获取了 mtx1" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock2(mtx2);
std::cout << "[SafeThread1] 获取了 mtx2" << std::endl;
}
void safe_routine_ordered2() {
std::lock_guard<std::mutex> lock1(mtx1);
std::cout << "[SafeThread2] 获取了 mtx1" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock2(mtx2);
std::cout << "[SafeThread2] 获取了 mtx2" << std::endl;
}
破解法二
使用 std::lock (C++标准库算法)
void safe_routine_std_lock() {
// defer_lock 表示初始化时不立即加锁
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
std::cout << "[StdLockThread] 尝试同时获取 mtx1 和 mtx2..." << std::endl;
// 原子性地锁定两个锁(避免死锁的核心)
std::lock(lock1, lock2);
std::cout << "[StdLockThread] 成功获取双锁!" << std::endl;
// 退出作用域时自动解锁
}
原理:std::lock 内部使用了一种死锁避免算法(通常是 Try-and-Backoff 机制):
- 尝试锁住 lock1。
- 尝试锁住 lock2。
- 如果锁住 lock2 失败(被别人占了),它会主动释放 lock1(破坏请求与保持条件)。
- 等待一小会儿,然后重试,直到同时拿到两把锁。
破解法三
**使用超时锁 (破坏不剥夺)**使用 try_lock_for。"我尝试等 1 秒,如果拿不到锁 B,我就把自己手里的锁 A 释放掉,过会再来。"
在实际开发中,策略 1 (固定顺序) 和 策略 2 (std::lock) 是最有效的手段。