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() 都要加锁。实际上只有第一次创建时需要同步,后续的读操作完全不需要锁。这会带来不必要的性能开销。

注意:在高并发场景下,这种写法会成为性能瓶颈。我曾经在一个游戏服务器里看到这种写法,压测时发现 GetInstance 占了 15% 的 CPU 时间。后来改成了双检锁。

方法三:双检锁(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_acquirememory_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;
};

这是我最推荐的方式。代码简洁,性能好,而且由编译器保证线程安全。你不需要写任何锁,也不需要操心内存序。

核心要点:C++11 的 Magic Static 是懒汉式,但初始化由编译器加锁。后续访问直接返回引用,没有锁开销。这是目前最优雅的线程安全单例实现。

不过有个小坑:如果单例的析构函数依赖其他全局对象,可能会出现析构顺序问题。但大多数场景下,这都不是事儿。

方法五: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(C++11) pthread_once(POSIX) ✅ 推荐:饿汉式(轻量)或 Magic Static(懒加载) 双检锁需配合 std::atomic 和内存序,否则有指令重排风险

总结

面试时,我建议你从饿汉式讲起,然后引出懒汉式的线程安全问题。接着展示 Magic Static 的写法,并解释为什么它是最佳实践。如果面试官追问底层原理,再展开双检锁和内存序。

记住一句话:能用 Magic Static 就别自己加锁。C++11 已经帮你搞定了最麻烦的部分,咱们没必要重复造轮子。

面试加分项:提到 C++11 的 call_once 也可以实现类似效果。如果你能说出 std::call_oncepthread_once 的异同,面试官会对你刮目相看。

专注资料整理