单例模式

1. 单例模式

代码示例

#include <iostream>
#include <string>
#include <mutex>
using namespace std;

class ConfigManager {
public:
    // 【核心获取接口】
    // C++11 保证:局部静态变量的初始化是线程安全的。
    // 这被称为 "Meyers' Singleton"
    static ConfigManager& GetInstance() {
        static ConfigManager instance; // 唯一实例,懒加载 (Lazy Initialization)
        return instance;
    }

    // 业务方法:设置配置
    void SetConfig(const string& key, const string& value) {
        // 模拟线程锁(虽然这里只是示例,但在真实单例中通常需要锁业务数据)
        // lock_guard<mutex> lock(mtx); 
        this->configData = key + "=" + value;
        cout << "[单例] 配置已更新: " << configData << " (地址: " << this << ")" << endl;
    }

    // 业务方法:读取配置
    string GetConfig() {
        return configData;
    }

    // 【关键点 1】删除拷贝构造函数
    // 防止代码中出现: ConfigManager c = ConfigManager::GetInstance();
    ConfigManager(const ConfigManager&) = delete;

    // 【关键点 2】删除赋值运算符
    // 防止代码中出现: c1 = c2;
    ConfigManager& operator=(const ConfigManager&) = delete;

private:
    // 【关键点 3】构造函数私有化
    // 禁止外部直接 new ConfigManager() 或 ConfigManager c;
    ConfigManager() {
        cout << ">>> 单例对象被创建 (只执行一次) <<<" << endl;
        configData = "default";
    }

    // 【关键点 4】析构函数私有化 (可选,通常设为私有或默认)
    // 程序结束时系统会自动回收 static 变量
    ~ConfigManager() {
        cout << ">>> 单例对象被销毁 <<<" << endl;
    }

    string configData;
};

// -----------------------------------------------------------

void TestThreadA() {
    // 无论哪里调用,获取到的都是同一个引用
    ConfigManager& mgr = ConfigManager::GetInstance();
    mgr.SetConfig("Theme", "Dark");
}

void TestThreadB() {
    ConfigManager& mgr = ConfigManager::GetInstance();
    cout << "线程B 读取配置: " << mgr.GetConfig() << endl;
}

int main() {
    cout << "=== 程序启动 ===" << endl;

    // 第一次调用 GetInstance,触发构造函数
    TestThreadA();

    // 第二次调用,直接返回已存在的实例,不触发构造
    TestThreadB();
    
    // 试图创建新对象会报错:
    // ConfigManager c; // 编译错误:构造函数是私有的

    cout << "=== 程序结束 ===" << endl;
    return 0;
}

1. 定义

  • 原文: 保证一个类只有一个实例,并提供一个访问它的全局访问点。
  • 解读:
    • “只有一个实例”:系统里只能有一个“皇帝”。比如系统配置、日志管理器、显卡驱动对象。如果不止一个,可能会导致资源冲突(比如两个对象同时写一个日志文件)。
    • “全局访问点”GetInstance() 就是那个“皇宫大门”。你不需要在各个函数之间传递这个对象,任何地方只要调用这个静态方法就能找到它。

2. 解决的问题 (Stable vs. Changing)

单例模式比较特殊,它主要控制的是对象的生命周期

  • 稳定点 (Stable):实例的唯一性
    • 无论系统怎么运行,这个类在内存中永远只有一份。这个规则是绝对锁死的。
    • 访问方式 (GetInstance) 也是稳定的。
  • 变化点 (Changing):实例的内部状态
    • 虽然人只有一个,但他衣服可以换(配置数据可以变)。
    • 调用者的位置是变化的(可以在 Main 里调,也可以在子线程调)。

3. 代码结构 (Code Structure)

C++ 实现单例的“四大金刚”:

  1. 私有构造函数 (Private Constructor):
    • 关门打狗。禁止外部使用 new 随意创建对象。
  2. 静态获取方法 (Static Public Method):
    • static ConfigManager& GetInstance()。这是外界唯一的入口。
    • 使用引用 (&) 返回,避免拷贝。
  3. 局部静态变量 (Local Static Variable):
    • static ConfigManager instance;
    • 懒加载 (Lazy Load): 只有第一次代码执行到这一行时,对象才会被创建。如果不调用,就不创建,节省内存。
    • 线程安全: C++11 标准强制规定 static 变量初始化必须是线程安全的(底层编译器加了锁)。
  4. 删除拷贝/赋值 (Delete Copy/Assign):
    • = delete。防止好不容易生成的唯一实例,被不知情的程序员通过 ConfigManager b = a; 给克隆了一份,破坏了唯一性。

4. 符合哪些设计原则?

  • 单一职责原则 (SRP) —— (注意:这里有争议)
    • 严格来说,单例模式违反了 SRP。因为它既负责了“业务逻辑”(存取配置),又负责了“管理自己的生命周期”(确保唯一)。
    • 但是,为了便利性和资源的唯一控制,我们通常接受这种“适度的违规”。
  • 迪米特法则 (LoD) —— (反面教材)
    • 单例模式通常会违反最少知道原则。因为全局都能访问它,导致系统各处都隐含地依赖了这个单例。这会让代码耦合度变高,单元测试变难(你很难 mock 一个静态单例)。
  • 受控访问原则
    • 这是单例最大的优点。它严格控制了谁能创建我,以及什么时候创建。

5. 如何扩展? (单例的局限性)

单例模式的扩展性较差,这通常是它的痛点:

  • 很难通过继承扩展:
    • 因为构造函数是私有的,子类无法调用父类构造函数。
    • 如果要支持多态单例(比如根据配置决定生成 FileLogger 还是 DbLogger 单例),通常需要将单例改为工厂模式 + 单例的注册表机制,比较复杂。
  • 如果需求变了:
    • 如果有一天需求变成“需要两个配置管理器(一个管前台,一个管后台)”,你需要修改 GetInstance 的代码,甚至把单例模式删掉。

2.单例模式的演变

第一阶段:原始时代(教科书式的懒汉写法)

这就是你图片中展示的代码。

  • 写法:先判断指针是否为空,为空则创建。

  • 代码核心

    static Singleton* GetInstance() {
        if (_instance == nullptr) {
            _instance = new Singleton();
        }
        return _instance;
    }
    
  • 评价

    • 致命弱点线程不安全。如果两个线程同时进入 if,会创建两个对象,甚至导致内存泄漏。
    • 优点:简单,符合直觉。单线程环境下没问题。

第二阶段:加锁时代(简单粗暴的线程安全)

为了解决线程冲突,最直接的办法就是加锁。

  • 写法:进入函数直接加锁。

  • 代码核心

    static Singleton* GetInstance() {
        std::lock_guard<std::mutex> lock(_mutex); // 自动加锁解锁
        if (_instance == nullptr) {
            _instance = new Singleton();
        }
        return _instance;
    }
    
  • 评价

    • 优点:绝对的线程安全。
    • 致命弱点性能极差。每次调用 GetInstance 都要加锁解锁,即使对象已经创建好了(此时本不需要锁),这在高并发下是巨大的瓶颈。

第三阶段:双重检查锁定 (DCLP) 时代(聪明反被聪明误)

为了解决性能问题,程序员想出了“双重检查锁定”(Double-Checked Locking Pattern)。

  • 写法:先判断是否为空(避免不必要的加锁),如果为空再加锁,加锁后判断一次(防止并发穿透)。

  • 代码核心

    static Singleton* GetInstance() {
        if (_instance == nullptr) { // 第一次检查
            std::lock_guard<std::mutex> lock(_mutex);
            if (_instance == nullptr) { // 第二次检查
                _instance = new Singleton();
            }
        }
        return _instance;
    }
    
  • 评价

    • 这是一个C++历史上的经典“坑”。
    • 在 C++11 之前:这种写法是错误的。原因在于 CPU 指令重排 (Instruction Reordering)
      • _instance = new Singleton(); 这行代码实际分为三步:
        1. 分配内存。
        2. 在内存上构造对象。
        3. _instance 指针指向这块内存。
      • 编译器或 CPU 可能会把步骤 2 和 3 颠倒。如果线程 A 执行了 1 和 3(指针非空了),但还没执行 2(对象还没造好),此时线程 B 进来判断指针非空,直接拿去用,就会导致程序崩溃
    • 在 C++11 之后:配合 std::atomic 和内存序(Memory Order),DCLP 才可以正确实现,但写起来非常麻烦且容易出错。

第四阶段:Meyers' Singleton(现代 C++ 的最佳实践)

这是由《Effective C++》作者 Scott Meyers 提出的写法。它利用了 C++11 的新特性:静态局部变量的初始化是线程安全的

  • 写法:去掉指针,去掉 new,直接在函数内部定义一个 static 对象。

  • 代码核心

    class Singleton {
    public:
        static Singleton& GetInstance() {
            static Singleton instance; // 局部静态变量
            return instance;
        }
        // ... 禁用拷贝和移动 ...
    private:
        Singleton() {} 
    };
    
  • 原理:C++11 标准规定,如果多个线程同时试图初始化同一个静态局部变量,并发是会被自动控制的(编译器会自动加锁)。

  • 评价

    • 极其优雅:代码最少。
    • 线程安全:标准保证。
    • 懒加载:第一次调用函数时才初始化。
    • 自动内存管理:程序结束时自动析构,没有内存泄漏风险。
    • 这是目前面试和工程中的首选写法。

补充知识

1. C++98 时代:多核时代的“蛮荒期”

在 C++11 标准发布之前,C++ 标准库本身并没有包含多线程支持。

  • 依赖原生 API (pthread):
    • 开发者必须依赖操作系统提供的底层 API,例如 Linux 下的 pthread 库(pthread_create, pthread_join 等)。
    • 这导致代码的可移植性较差(Windows 和 Linux 的 API 不同)。
  • 面临的严峻挑战(优化与一致性):
    • 编译器与 CPU 重排 (Reordering): 图片中特别指出了“编译器重排”和“CPU重排”。为了优化性能,编译器和 CPU 可能会打乱指令的执行顺序。
    • 违反顺序一致性: 在单线程中,这种重排通常是安全的;但在多线程中,这会导致严重的逻辑错误。
    • 可见性问题 (Visibility): 一个线程修改了变量,另一个线程可能无法立即看到(因为数据可能还停留在 CPU 缓存中,未同步到主存)。
    • 执行序问题: 代码的执行顺序无法得到保证。

2. C++11 时代:标准化的并发支持

C++11 引入了全新的内存模型和标准线程库,正式将多线程编程纳入标准。

  • 标准库工具:
    • 线程 (std::thread): 跨平台的线程创建与管理。
    • 互斥锁 (std::mutex): 用于保护临界区,防止数据竞争。
    • 原子操作 (std::atomic): 提供了不需要加锁的线程安全操作,是实现无锁编程的基础。
    • 内存栅栏 (Memory Barrier): 用于控制指令重排,强制保证内存操作的顺序,解决可见性和执行序问题。
  • 关于 Volatile:
    • 图片中提到了 volatile java。这是一个对比:Java 中的 volatile 关键字保证了变量的可见性和有序性。
    • 注意: 在 C++ 中,volatile 不保证原子性或线程同步(它只用于防止编译器优化读取)。C++11 中解决线程同步和可见性问题的正确工具是 std::atomicstd::mutex

3. 同步原语 (Synchronization Primitives)

图片右下角的框图总结了 C++11 并发编程的核心基石:

  • 原子变量 (Atomic Variable): 最底层的同步机制。
  • 内存 (Memory): 通过原子变量和内存序(Memory Order)来控制内存在不同线程间的视图一致性。

💡 总结与建议

  • 什么时候用?
    • 确实只需要一个对象(如线程池、缓存、注册表、日志)。
    • 对象需要被多处共享访问。
  • 什么时候慎用?
    • 不要滥用单例当全局变量。如果只是为了传参方便,不要用单例。
    • 单例会让模块间的依赖关系变得隐晦(函数签名看不出它用了单例,只有读代码才知道),这会增加维护成本。
posted @ 2025-12-20 20:42  belief73  阅读(2)  评论(0)    收藏  举报