单例模式(Singleton):饿汉式与懒汉式实现,线程安全分析
单例模式,说白了就是一个类只能有一个实例。你想想看,有些资源天生就该是唯一的——比如配置文件管理器、日志系统、线程池。如果搞出多个实例,轻则资源浪费,重则数据错乱。
我刚开始写C++那会儿,觉得单例模式太简单了,不就是把构造函数藏起来嘛。直到有一次线上服务莫名其妙地崩溃,排查了三天才发现是单例的线程安全问题。嗯,从那以后我再也不敢小看这个模式了。
饿汉式:简单粗暴,但有点浪费
饿汉式的思路很简单:程序启动时就创建好实例。就像你饿得不行,饭还没上桌就先啃馒头。
class Singleton {
public:
static Singleton* getInstance() {
return &instance_;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton instance_;
};
// 在cpp文件中定义
Singleton Singleton::instance_;
这个实现有什么好处?线程安全。因为实例在main函数执行前就初始化好了,这时候还是单线程环境,根本不存在竞争条件。
但缺点也很明显:启动慢。如果这个单例初始化很重(比如要加载配置文件、建立数据库连接),那程序启动就会卡住。而且,如果程序从头到尾都没用过这个单例,那资源就白白浪费了。
懒汉式:需要时才创建
懒汉式就聪明多了:只有第一次调用getInstance时才创建实例。就像你饿到不行了才去找吃的。
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了一个对象。结果呢?一个线程用A实例,另一个线程用B实例,单例就不"单"了。
核心问题:懒汉式需要解决两个问题——1) 保证只创建一个实例;2) 保证获取实例的操作是原子的。
线程安全方案一:加锁
最简单的办法就是加锁。每次调用getInstance都上锁,保证只有一个线程能进入。
#include <mutex>
class Singleton {
public:
static Singleton* getInstance() {
std::lock_guard<std::mutex> lock(mutex_);
if (instance_ == nullptr) {
instance_ = new Singleton();
}
return instance_;
}
private:
static std::mutex mutex_;
static Singleton* instance_;
};
这个方案能保证线程安全,但性能很差。每次调用都要加锁解锁,即使实例已经创建好了。在高并发场景下,这个锁会成为性能瓶颈。
线程安全方案二:双检锁(DCLP)
双检锁的思路是:先检查一次,如果实例已经存在就直接返回,不用加锁。只有实例不存在时才加锁创建。这样大部分调用都不需要锁,性能好很多。
#include <mutex>
class Singleton {
public:
static Singleton* getInstance() {
if (instance_ == nullptr) { // 第一次检查
std::lock_guard<std::mutex> lock(mutex_);
if (instance_ == nullptr) { // 第二次检查
instance_ = new Singleton();
}
}
return instance_;
}
private:
static std::mutex mutex_;
static Singleton* instance_;
};
等等,你以为这样就完美了?没那么简单。这里有个大坑——内存屏障(Memory Barrier)问题。
为什么会这样?因为CPU和编译器会重排指令。new Singleton()这行代码,实际上做了三件事:
- 分配内存
- 调用构造函数
- 将地址赋值给instance_
如果CPU把第3步重排到第2步之前,那另一个线程在第一次检查时发现instance_不为null,就直接返回了。但这时候对象还没构造完成!访问这个半成品对象,后果就是崩溃。
解决方案:C++11 magic static
C++11引入了一个神器——magic static。它保证:函数内部的静态变量初始化是线程安全的,而且只初始化一次。
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance_;
return instance_;
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
这个实现有多优雅?零行锁代码,线程安全,延迟初始化,性能最优。C++标准保证,当第一个线程进入getInstance时,其他线程会等待初始化完成。而且编译器会处理好内存屏障的问题。
我的建议:在C++11及以后的版本中,直接用magic static实现单例。这是最安全、最简洁、性能最好的方式。除非你还在用C++03,否则别折腾双检锁了。
各种实现对比
| 实现方式 | 线程安全 | 延迟初始化 | 性能 | 推荐度 |
|---|---|---|---|---|
| 饿汉式 | 是 | 否 | 高 | ⭐⭐⭐ |
| 懒汉式(不加锁) | 否 | 是 | 高 | ⭐ |
| 懒汉式(加锁) | 是 | 是 | 低 | ⭐⭐ |
| 双检锁(DCLP) | 需内存屏障 | 是 | 高 | ⭐⭐ |
| magic static | 是 | 是 | 高 | ⭐⭐⭐⭐⭐ |
知识体系图
避坑指南
我曾经踩过的坑,分享给你:
- 不要手写双检锁——除非你精通内存模型,否则大概率会写错。用magic static它不香吗?
- 注意单例的析构顺序——如果单例A依赖单例B,而B先被析构了,A访问B就会崩溃。可以用智能指针管理生命周期。
- 单例不是银弹——滥用单例会变成全局变量,导致代码耦合严重。我见过一个项目用了20多个单例,改一个功能要改十几个文件。
- 测试友好性——单例很难mock,不利于单元测试。如果可能,用依赖注入替代单例。
嗯,单例模式就讲到这里。记住一句话:能用magic static就别折腾别的。C++11已经帮我们解决了所有痛点,何必自己造轮子呢?
公众号:蓝海资料掘金营,微信deep3321