单例模式
难度:⭐ | 高频指数:🔥🔥🔥
面试回答
常见问法
- 单例模式怎么实现?懒汉和饿汉有什么区别?
- 怎么实现线程安全的单例?
- C++11 的 static 局部变量为什么是线程安全的?
- 双重检查锁是什么?有什么问题?
- 单例模式为什么要禁止拷贝和赋值?
- 单例模式有什么缺点?
回答
单例模式保证一个类只有一个实例,并提供全局访问点。
两种经典实现:
- 饿汉式:程序启动时就创建实例,简单但可能浪费资源
- 懒汉式:第一次使用时才创建实例,需要考虑线程安全
C++11 推荐写法(Meyers’ Singleton):
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // C++11 保证线程安全
return instance;
}
// 禁止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default; // 私有构造函数
~Singleton() = default;
};
为什么这是最佳写法:
- C++11 标准保证 static 局部变量的初始化是线程安全的
- 懒加载(第一次调用时才构造)
- 代码简洁,不需要手动管理锁
- 禁止拷贝和赋值防止产生多个实例
追问
1. 为什么要禁止拷贝和赋值?
如果允许拷贝,用户可以通过 Singleton s = Singleton::getInstance() 创建新对象,违反”只有一个实例”的约束。所以必须 delete 拷贝构造函数和赋值运算符。
2. 饿汉式怎么写?
class Singleton {
public:
static Singleton& getInstance() {
return instance;
}
private:
Singleton() = default;
static Singleton instance; // 类外定义
};
Singleton Singleton::instance; // 程序启动时构造
饿汉式天然线程安全(在 main 之前初始化),但有静态初始化顺序问题(不同编译单元的静态变量初始化顺序不确定)。
3. 单例的缺点?
- 全局状态,增加耦合
- 难以单元测试(不好 mock)
- 隐藏依赖关系
- 生命周期不好控制(析构顺序问题)
原理展开
1. 双重检查锁(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;
};
为什么需要两次检查:
- 第一次检查:避免每次调用都加锁(性能优化)
- 第二次检查:防止多个线程同时通过第一次检查后重复创建
DCLP 的问题(C++11 之前):
instance = new Singleton() 不是原子操作,分三步:
- 分配内存
- 构造对象
- 将地址赋给 instance
编译器可能重排为 1→3→2。如果线程 A 执行了 1→3,线程 B 看到 instance 非空就直接使用,但对象还没构造完成。
C++11 的修复:
static std::atomic<Singleton*> instance;
// 或者直接用 static 局部变量(推荐)
2. C++11 static 局部变量的线程安全保证
C++11 标准 §6.7:
如果控制流在变量初始化期间并发进入声明,则并发执行应等待初始化完成。
编译器实现通常是:
- 用一个隐藏的 flag + mutex 保护初始化
- 第一个到达的线程执行初始化
- 其他线程等待初始化完成
// 编译器大致生成的代码(伪代码)
static Singleton& getInstance() {
static bool initialized = false;
static std::aligned_storage<sizeof(Singleton)> storage;
if (!initialized) { // 实际用原子操作 + 锁
lock();
if (!initialized) {
new (&storage) Singleton();
initialized = true;
}
unlock();
}
return *reinterpret_cast<Singleton*>(&storage);
}
3. 模板化单例
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) { /* ... */ }
};
Logger::getInstance().log("hello");
4. 单例的析构问题
多个单例之间可能有析构顺序依赖。如果 Singleton A 的析构函数使用了 Singleton B,但 B 先被析构了就会出问题。解决方案:避免单例间的析构依赖、使用 atexit 注册顺序。
5. 单例的替代方案
依赖注入:
// 不用单例,通过参数传入
class Service {
public:
Service(Logger& logger, Config& config)
: logger_(logger), config_(config) {}
private:
Logger& logger_;
Config& config_;
};
// 在 main 中创建并注入
int main() {
Logger logger;
Config config;
Service service(logger, config);
}
优点:
- 依赖关系显式
- 容易测试(可以注入 mock)
- 生命周期由调用者控制
6. 什么时候适合用单例
适合:
- 日志系统
- 配置管理
- 线程池
- 数据库连接池
- 硬件资源管理(如打印机)
不适合:
- 业务逻辑对象
- 需要多实例的场景
- 需要频繁单元测试的组件
7. 面试中的完整回答模板
1. 先写出 C++11 推荐写法(static 局部变量)
2. 解释为什么线程安全
3. 说明为什么要 delete 拷贝和赋值
4. 提到双重检查锁及其问题(加分)
5. 说明单例的缺点和替代方案(加分)
易错点
- 忘记禁止拷贝和赋值——这是面试必问的点。
- 不知道 C++11 static 局部变量是线程安全的——这是推荐写法的基础。
- 写双重检查锁时忘记 memory order 问题——C++11 之前的 DCLP 是有 bug 的。
- 说”饿汉式没有问题”——饿汉式有静态初始化顺序问题。
- 返回指针而不是引用——返回引用更安全,避免用户 delete。
- 忘记说单例的缺点——只说优点不说缺点,面试官会追问。
- 析构函数设为 private 但忘记 friend——如果需要正常析构,要注意访问权限。
记忆技巧
- 单例三要素:私有构造 + 静态方法 + 禁止拷贝
- C++11 最佳写法:static 局部变量,一行搞定线程安全
- DCLP 问题:new 不是原子的,可能指令重排
- 饿汉 vs 懒汉:饿汉启动就建,懒汉用时才建
- 单例缺点三连:全局耦合、难测试、隐藏依赖
面试速答版
单例模式保证一个类只有一个实例。C++11 推荐用 static 局部变量实现(Meyers’ Singleton),标准保证其初始化是线程安全的。必须 delete 拷贝构造和赋值运算符防止产生多个实例。双重检查锁在 C++11 之前有指令重排问题,现在用 atomic 或直接用 static 局部变量更好。饿汉式在程序启动时创建,天然线程安全但有静态初始化顺序问题。单例的缺点是全局状态增加耦合、难以单元测试、隐藏依赖关系,可以用依赖注入替代。