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 强烈推荐

单例模式不是银弹。我见过有人把业务逻辑全塞进单例,导致代码耦合严重、难以测试。记住:单例适合管理「全局唯一资源」,不适合做「全局数据容器」

另外,单例的析构顺序也是个坑。两个单例互相依赖时,析构顺序不确定,可能导致崩溃。我的经验是:尽量让单例之间没有依赖关系。如果实在有,可以用「按需创建」的方式,而不是依赖静态初始化顺序。

单例模式实现方案对比 饿汉式 程序启动时创建 懒汉式 首次调用时创建 C++11 Magic Static 编译器保证线程安全 ✅ 线程安全 ❌ 不能延迟加载 ❌ 启动慢 ✅ 延迟加载 ❌ 线程不安全 ❌ 需加锁 ✅ 线程安全 ✅ 延迟加载 ✅ 性能高 推荐方案 C++11 及以上 → Magic Static C++11 以下 → 饿汉式(如果允许)或 DCLP(需谨慎)

最后说一句:单例模式虽然简单,但用好它需要理解背后的线程安全、内存模型、生命周期管理。我个人觉得,Magic Static 是 C++11 给开发者最好的礼物之一——它让单例实现变得优雅且安全。如果你还在用老式的手写 DCLP,不妨试试这个新方案。

避坑指南:我曾经接手过一个遗留项目,里面用了十几个单例,互相之间有复杂的依赖关系。每次启动时崩溃位置都不一样,查了三天才发现是初始化顺序问题。后来我重构了代码,把有依赖关系的单例合并,或者改用依赖注入——世界清净了。记住:单例虽好,不要贪多