7. 单例模式:模式动机与定义、饿汉式与懒汉式实现、线程安全实现
单例模式,说白了就是保证一个类只有一个实例。你想想看,在系统里有些东西是全局唯一的——比如配置文件管理器、日志记录器、线程池。如果搞出多个实例,轻则资源浪费,重则数据错乱。
我记得刚入行那会儿,有个同事写了个日志类,每次调用都 new 一个对象。结果线上日志文件被多个实例同时写,内容全混在一起。嗯,这就是典型的「该单例却没单例」的坑。
7.1 模式动机与定义
单例模式的动机其实很朴素:控制实例数量,提供全局访问点。我个人的理解是——它解决了两件事:一是「只能有一个」,二是「随时能找到」。
定义上,单例模式要求:
- 构造函数私有化,外部不能 new
- 类内部维护一个静态实例指针
- 提供一个静态公有方法返回该实例
核心要点:单例不是「全局变量」的替代品,而是「受控的全局唯一对象」。我在项目中见过有人把所有配置都塞进单例,结果类膨胀到几千行——这是滥用。
7.2 饿汉式实现
饿汉式,顾名思义——「饿」到等不及,程序启动时就创建实例。实现很简单:
class Singleton {
public:
static Singleton& getInstance() {
return instance;
}
void doSomething() { /* ... */ }
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton instance; // 类加载时就初始化
};
// 在cpp文件中定义
Singleton Singleton::instance;
饿汉式的优点很明显:线程安全。因为实例在 main 函数执行前就创建好了,不存在多线程竞争问题。
但缺点也突出:启动慢,浪费资源。如果这个单例对象很大,或者初始化依赖某些运行时条件(比如读取配置文件),饿汉式就不太合适了。我在项目中遇到过一个问题——某个单例初始化时依赖数据库连接池,但数据库连接池本身也是单例,结果导致初始化顺序不可控。嗯,这里要注意:饿汉式不适合有依赖关系的场景。
7.3 懒汉式实现
懒汉式就是「懒」到用时才创建。第一次调用 getInstance 时才 new 对象:
class Singleton {
public:
static Singleton* getInstance() {
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton* instance;
};
Singleton* Singleton::instance = nullptr;
这个版本有个致命问题——线程不安全。两个线程同时进入 getInstance,都判断 instance 为 nullptr,就会 new 出两个对象。我曾经在压测时遇到过这种问题,日志里出现了两个不同的实例地址,排查了半天才发现是单例没加锁。
7.4 线程安全实现
解决线程安全问题,有几种主流方案。
7.4.1 加锁实现(性能差)
class Singleton {
public:
static Singleton* getInstance() {
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) {
instance = new Singleton();
}
return instance;
}
private:
static Singleton* instance;
static std::mutex mtx;
};
每次调用都加锁,性能开销大。其实大部分时候实例已经存在了,根本不需要锁。这就是「读多写少」场景下的典型性能浪费。
7.4.2 双重检查锁定(DCLP)
class Singleton {
public:
static Singleton* getInstance() {
if (instance == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) { // 第二次检查
instance = new Singleton();
}
}
return instance;
}
private:
static Singleton* instance;
static std::mutex mtx;
};
双重检查锁定的思路是:先判断再锁,锁完再判断。实例存在时直接返回,不加锁;只有第一次创建时才加锁。
注意:在 C++11 之前,DCLP 存在内存序问题。因为 instance = new Singleton() 不是原子操作——它包含三步:分配内存、调用构造函数、赋值指针。编译器或 CPU 可能重排指令,导致其他线程拿到未构造完成的对象。C++11 引入了 std::atomic 和内存序来解决这个问题,但实现起来比较复杂。我个人建议:除非你非常熟悉内存模型,否则别手写 DCLP。
7.4.3 C++11 Magic Static(推荐)
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // C++11 保证线程安全
return instance;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
这是 C++11 引入的特性:函数局部静态变量的初始化是线程安全的。编译器会自动加锁,而且只在第一次调用时初始化一次。代码简洁、性能好、无内存泄漏问题。
我的建议:现代 C++ 项目中,直接用 Magic Static 实现单例。我在近几年的项目中全部采用这种方式,从未出过问题。简单、可靠、可维护——这才是好代码。
7.5 优缺点总结
| 实现方式 | 线程安全 | 延迟加载 | 性能 | 推荐度 |
|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 高 | 适合简单对象 |
| 懒汉式(不加锁) | 否 | 是 | 高 | 不推荐 |
| 懒汉式(加锁) | 是 | 是 | 低 | 不推荐 |
| 双重检查锁定 | 是(需注意内存序) | 是 | 中 | 谨慎使用 |
| C++11 Magic Static | 是 | 是 | 高 | 强烈推荐 |
单例模式不是银弹。我见过有人把业务逻辑全塞进单例,导致代码耦合严重、难以测试。记住:单例适合管理「全局唯一资源」,不适合做「全局数据容器」。
另外,单例的析构顺序也是个坑。两个单例互相依赖时,析构顺序不确定,可能导致崩溃。我的经验是:尽量让单例之间没有依赖关系。如果实在有,可以用「按需创建」的方式,而不是依赖静态初始化顺序。
最后说一句:单例模式虽然简单,但用好它需要理解背后的线程安全、内存模型、生命周期管理。我个人觉得,Magic Static 是 C++11 给开发者最好的礼物之一——它让单例实现变得优雅且安全。如果你还在用老式的手写 DCLP,不妨试试这个新方案。
避坑指南:我曾经接手过一个遗留项目,里面用了十几个单例,互相之间有复杂的依赖关系。每次启动时崩溃位置都不一样,查了三天才发现是初始化顺序问题。后来我重构了代码,把有依赖关系的单例合并,或者改用依赖注入——世界清净了。记住:单例虽好,不要贪多。