36. 设计模式面试题1:实现一个线程安全的单例模式(多种方法)
单例模式,说白了就是保证一个类只有一个实例。这玩意儿在面试里几乎是必考题,而且面试官特别喜欢追问「线程安全怎么保证?」。我个人习惯把单例的实现分成几大类,每一类都有它的适用场景和坑。
今天咱们就把线程安全的单例模式彻底聊透。我会从最基础的写法开始,一步步带你看到各种方案的优缺点。
为什么单例需要线程安全?
你想想看,如果两个线程同时调用 GetInstance(),而实例还没创建,它们可能各自 new 出一个对象来。这就违背了单例的初衷。更糟糕的是,如果对象内部有共享资源,还会引发数据竞争。
我在项目中遇到过一个问题:一个日志模块用了最简单的懒汉式单例,上线后偶尔出现日志丢失。查了半天,就是多线程同时初始化导致的。从那以后,我对单例的线程安全就特别敏感。
方法一:饿汉式(Eager Singleton)
饿汉式是最简单的方案。实例在程序启动时就创建好了,不存在多线程竞争的问题。
class Singleton {
public:
static Singleton& GetInstance() {
return instance_;
}
// 禁止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
~Singleton() = default;
static Singleton instance_;
};
// 在类外初始化,程序启动时完成
Singleton Singleton::instance_;
优点:实现简单,天生线程安全。
缺点:如果单例对象很大,或者初始化依赖某些运行时条件(比如配置文件),饿汉式就不太合适了。它会拖慢程序启动速度。
方法二:懒汉式 + 互斥锁(Mutex)
懒汉式是等到第一次使用时才创建实例。这需要加锁来保证线程安全。
#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 Singleton* instance_;
static std::mutex mutex_;
};
Singleton* Singleton::instance_ = nullptr;
std::mutex Singleton::mutex_;
这个写法有个问题:每次调用 GetInstance() 都要加锁。实际上只有第一次创建时需要同步,后续的读操作完全不需要锁。这会带来不必要的性能开销。
方法三:双检锁(Double-Checked Locking)
双检锁的思路是:先检查实例是否为空,如果为空才加锁,加锁后再检查一次。这样只有第一次需要同步。
#include <mutex>
#include <atomic>
class Singleton {
public:
static Singleton* GetInstance() {
Singleton* tmp = instance_.load(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(mutex_);
tmp = instance_.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton();
instance_.store(tmp, std::memory_order_release);
}
}
return tmp;
}
// ...
private:
static std::atomic<Singleton*> instance_;
static std::mutex mutex_;
};
std::atomic<Singleton*> Singleton::instance_{nullptr};
std::mutex Singleton::mutex_;
这里用了 std::atomic 和内存序来控制可见性。为什么要用 memory_order_acquire 和 memory_order_release?因为要保证 new Singleton() 的写操作对后续的读线程可见。
嗯,这里要注意:如果不加内存序,编译器或 CPU 可能重排指令,导致其他线程看到一个未完全初始化的对象。这就是经典的「双检锁失效」问题。
方法四:C++11 的 Magic Static
C++11 标准规定:局部静态变量的初始化是线程安全的。编译器会自动加锁,而且只加一次锁。
class Singleton {
public:
static Singleton& GetInstance() {
static Singleton instance;
return instance;
}
// ...
private:
Singleton() = default;
~Singleton() = default;
};
这是我最推荐的方式。代码简洁,性能好,而且由编译器保证线程安全。你不需要写任何锁,也不需要操心内存序。
不过有个小坑:如果单例的析构函数依赖其他全局对象,可能会出现析构顺序问题。但大多数场景下,这都不是事儿。
方法五:pthread_once(POSIX 平台)
如果你在 Linux 下开发,也可以用 pthread_once。它保证某个函数只被执行一次。
#include <pthread.h>
class Singleton {
public:
static Singleton& GetInstance() {
pthread_once(&once_, &Singleton::Init);
return *instance_;
}
// ...
private:
static void Init() {
instance_ = new Singleton();
}
static pthread_once_t once_;
static Singleton* instance_;
};
pthread_once_t Singleton::once_ = PTHREAD_ONCE_INIT;
Singleton* Singleton::instance_ = nullptr;
这个方案在性能上跟 Magic Static 差不多,但可移植性差一些。Windows 上可以用 InitOnceExecuteOnce 实现类似效果。
各方案对比
| 方案 | 线程安全 | 性能 | 代码复杂度 | 推荐场景 |
|---|---|---|---|---|
| 饿汉式 | ✅ 天生安全 | 高 | 低 | 轻量对象,不依赖运行时 |
| 懒汉式 + 互斥锁 | ✅ 安全 | 低(每次加锁) | 中 | 不推荐,除非极低频访问 |
| 双检锁 | ✅ 安全(需注意内存序) | 高 | 高 | 对性能有极致要求 |
| Magic Static | ✅ 安全(C++11 保证) | 高 | 极低 | ✅ 首选方案 |
| pthread_once | ✅ 安全 | 高 | 中 | POSIX 平台,遗留代码 |
避坑指南
我曾经在代码评审里看到有人这么写双检锁:
// ❌ 错误示例
if (instance_ == nullptr) {
lock_guard lock(mutex_);
if (instance_ == nullptr) {
instance_ = new Singleton(); // 可能被重排!
}
}
这个写法在 C++03 下是错的。因为 new Singleton() 不是原子操作,它包含三步:分配内存、调用构造函数、赋值给指针。CPU 可能把「赋值」提前到「构造函数」之前,导致另一个线程拿到一个半成品对象。
所以要么用 std::atomic 加内存序,要么直接用 C++11 的 Magic Static。别自己手写双检锁,除非你真的理解内存模型。
知识体系图
下面这张图总结了线程安全单例的几种实现路径和选择依据:
总结
面试时,我建议你从饿汉式讲起,然后引出懒汉式的线程安全问题。接着展示 Magic Static 的写法,并解释为什么它是最佳实践。如果面试官追问底层原理,再展开双检锁和内存序。
记住一句话:能用 Magic Static 就别自己加锁。C++11 已经帮你搞定了最麻烦的部分,咱们没必要重复造轮子。
call_once 也可以实现类似效果。如果你能说出 std::call_once 和 pthread_once 的异同,面试官会对你刮目相看。