【C++第二十八章】单例模式

前言 🚀

单例模式表面上只是"一个类只能有一个对象",但真正理解这部分时,关键并不在于背下"饿汉""懒汉"两个名字,而在于想清楚:为什么某些类必须全局只有一份实例,这份实例为什么不能让外部随便创建、拷贝和销毁,以及在多线程和程序退出阶段又会引出哪些问题。

很多配置中心、日志系统、字典管理器、任务调度入口,都带有很强的"全局唯一"色彩。若这类对象被随意拷贝出多份,状态就会分裂;若创建时机不受控,又可能引入启动顺序问题、线程安全问题或释放时机问题。所以,单例模式真正要解决的,不是"写一个全局变量",而是把唯一实例、访问入口、创建时机、释放策略统一约束起来。

顺着这条主线往下看,饿汉模式和懒汉模式的区别就会非常清楚:前者把"唯一对象"提前准备好,换取实现简单;后者把"唯一对象"延后到真正使用时再创建,换取更灵活的时机控制。但一旦延后创建,就必须继续面对线程安全和析构回收这些现实问题。


一. 单例模式到底在约束什么 🧠

单例模式的核心约束只有一句话:

一个类在系统中只允许存在一个实例,并且要提供一个全局可访问入口。

1.1 为什么不能直接用普通对象替代

因为普通类默认具备这些能力:

  • 外部可以随便构造对象
  • 外部可以继续拷贝对象
  • 对象数量没有天然上限
  • 生命周期也未被统一管理

而单例类要的恰恰相反,是:

  • 只能有一份对象
  • 外部不能自行创建第二份
  • 外部不能拷贝出副本
  • 只能通过统一入口拿到实例

1.2 所以单例类通常会做哪些限制

  • 构造函数私有
  • 拷贝构造禁用
  • 赋值运算符重载禁用
  • 通过静态成员函数返回唯一实例

二. 为什么构造函数要私有,拷贝也要禁用 🔍

2.1 构造函数私有化的意义

如果构造函数仍然是公有的,那么外部就可以直接写:

cpp 复制代码
A obj;
A* ptr = new A;

这样"只能有一个对象"的前提立刻失效。所以单例类首先要做的,就是把构造函数收起来,不让类外随便创建对象。

2.2 为什么还必须禁用拷贝和赋值

即使只允许类内部构造出那一份实例,如果拷贝构造和赋值运算符还开放着,那么外部依然可以通过已有实例继续复制出新对象。

cpp 复制代码
A(const A&) = delete;
A& operator=(const A&) = delete;

这样做的本质不是"写法严格",而是为了彻底堵住"第二份实例"的所有旁路入口。

💡 避坑指南:
单例类不是只把构造函数私有就够了。

如果拷贝和赋值没禁掉,唯一性仍然可能被破坏。


三. 饿汉模式:为什么说它简单,但创建时机太早 🧱

3.1 核心思路

饿汉模式的做法是:

在程序启动阶段,就把唯一实例直接创建好。

典型写法如下:

cpp 复制代码
class A
{
public:
    static A* GetInstance()
    {
        return &_inst;
    }

    void Add(const string& key, const string& value)
    {
        _dict[key] = value;
    }

    void Print()
    {
        for (auto& kv : _dict)
        {
            cout << kv.first << ":" << kv.second << endl;
        }
        cout << endl;
    }

private:
    A()
    {}
    A(const A&) = delete;
    A& operator=(const A&) = delete;

private:
    map<string, string> _dict;
    static A _inst;
};

A A::_inst;

3.2 它为什么实现简单

因为唯一对象在静态存储区里直接准备好了,后续 GetInstance() 只需要返回地址即可,不涉及判空、不涉及动态分配,也不涉及第一次调用时再初始化。

3.3 它的优点

  • 实现直接
  • 访问逻辑简单
  • 不需要手动考虑析构释放
  • 在很多编译环境下天然避开了第一次创建时的并发竞争问题

3.4 它的缺点

问题也同样明显:

  1. 创建太早

    程序一启动就构造,即使后面根本没用到,也已经付出了初始化成本。

  2. 可能拖慢启动速度

    若单例对象本身初始化很重,那么进程启动阶段就会更慢。

  3. 多个单例之间的初始化顺序不易控制

    若某个单例依赖另一个单例,而二者都采用饿汉模式,初始化先后顺序就可能成为隐患。

💡 避坑指南:
饿汉模式最大的问题不是"占内存",而是"创建时机不可延后,也不容易精细控制依赖顺序"。


四. 懒汉模式:为什么说它更灵活,但会引出线程安全问题 💻

4.1 核心思路

懒汉模式的策略正好相反:

对象不提前创建,等第一次真正需要时再创建。

典型写法如下:

cpp 复制代码
class B
{
public:
    static B* GetInstance()
    {
        if (_inst == nullptr)
        {
            _inst = new B;
        }
        return _inst;
    }

    void Add(const string& key, const string& value)
    {
        _dict[key] = value;
    }

    void Print()
    {
        for (auto& kv : _dict)
        {
            cout << kv.first << ":" << kv.second << endl;
        }
        cout << endl;
    }

private:
    B()
    {}
    B(const B&) = delete;
    B& operator=(const B&) = delete;

private:
    map<string, string> _dict;
    static B* _inst;
};

B* B::_inst = nullptr;

4.2 它为什么没有饿汉模式的时机缺点

因为只在真正调用 GetInstance() 时才创建对象,所以:

  • 没被用到就不创建
  • 启动阶段没有额外初始化开销
  • 某些依赖关系可以延后到真正使用时再建立

4.3 但它为什么立刻会遇到线程安全风险

因为下面这段逻辑:

cpp 复制代码
if (_inst == nullptr)
{
    _inst = new B;
}

在单线程下没问题,但若两个线程同时第一次进入这里,可能都会看到 _inst == nullptr,于是各自 new 一次,最终产生两份对象。

4.4 所以懒汉模式最核心的现实问题是什么

第一次创建阶段必须解决并发竞争。


五. 懒汉模式的线程安全为什么不能靠"运气没撞上"来理解 ⚠️

单线程调试时,很多人会误以为懒汉模式已经"能跑就行"。但真正的问题并不在于你本次运行有没有撞上,而在于:

只要存在两个线程同时首次访问实例入口,这段代码理论上就不安全。

5.1 出错的本质过程

  1. 线程 1 进入 GetInstance()
  2. 线程 2 也进入 GetInstance()
  3. 二者都读到 _inst == nullptr
  4. 二者分别执行 new B
  5. 最终出现多个实例

5.2 这说明什么

说明"单例唯一性"不只是接口设计问题,还和并发下的初始化原子性强相关。只要是懒加载,就几乎绕不开这一层考虑。


六. 为什么很多示例里说"懒汉模式一般不用手动释放" 🔍

若懒汉模式里的单例是通过 new 创建的,那么理论上最终是应该释放的。但在很多教学示例里,经常会看到"不释放也行"的说法。

6.1 这种说法的现实背景

因为进程结束后,操作系统会回收该进程占用的地址空间和相关资源,从"进程生命周期最终结束"的角度看,这块内存不会永久泄漏到系统范围之外。

6.2 但这不等于"析构逻辑就不重要"

若单例类的析构函数里本身还承担其他职责,例如:

  • 刷新缓存到文件
  • 保存配置
  • 关闭日志
  • 归档统计信息

那么"不手动释放"就意味着这些析构逻辑根本不会执行。

6.3 所以问题的关键不在于"内存要不要还给系统"

而在于:

这个对象销毁时是否还有额外业务收尾动作必须发生。


七. 如果析构里要做收尾工作,为什么还要额外套一层 gc 🧩

7.1 问题来源

懒汉模式里单例对象通常是:

cpp 复制代码
_inst = new B;

创建出来的。若没有后续 delete,析构函数就不会触发。

7.2 一种常见处理思路

额外定义一个内部清理类,在它的析构里统一调用删除实例的逻辑。这样既能保留单例的全局访问方式,又能让程序结束时自动触发回收。

典型思路可以写成:

cpp 复制代码
class gc
{
public:
    ~gc()
    {
        DelInstance();
    }
};

static gc _gc;

7.3 这种设计真正想达到什么效果

  • 平时仍然通过 GetInstance() 显式访问单例
  • 程序退出时,借助静态对象 _gc 的析构自动做收尾
  • 让"可以手动显示调用"和"可以自动处理释放"这两条路同时成立

7.4 为什么这本质上还是 RAII 思想

虽然这里包的是一个"清理器对象",但背后依然是在利用对象生命周期:让静态对象退出时自动执行析构,从而触发回收逻辑。

💡 避坑指南:
单例析构是否需要显式设计,不取决于"内存会不会被系统回收",而取决于析构里是否还有业务收尾责任。


八. 饿汉和懒汉到底该怎么比较 🗺️

方案 创建时机 优点 缺点
饿汉模式 程序启动阶段 简单、直接、访问轻量 启动早、初始化成本前置、依赖顺序不易控
懒汉模式 第一次使用时 时机灵活、没用到就不创建 线程安全要额外处理、释放策略更麻烦

8.1 什么时候更偏向饿汉

  • 单例对象很轻
  • 一定会被使用
  • 不想额外处理首次初始化竞争
  • 更看重实现简单和稳定性

8.2 什么时候更偏向懒汉

  • 单例初始化开销较大
  • 可能根本不会被用到
  • 创建时机需要尽量延后
  • 可以接受额外处理并发与释放策略

九. 这一章最该建立起来的设计意识 📌

单例模式看起来只是一个"全局对象写法",但真正值得建立起来的,其实是这几层设计意识:

  1. 唯一性不是口头保证,而是靠接口约束实现的

    构造、拷贝、赋值都要配合限制。

  2. 访问入口和创建时机是两件事

    都叫 GetInstance(),不代表底层创建策略相同。

  3. 懒加载一定要继续考虑并发

    只要是"第一次创建",就天然可能有竞争。

  4. 对象释放不仅是内存问题,更是业务收尾问题

    若析构有职责,就必须认真设计释放路径。


总结 📝

单例模式真正要解决的,不只是"这个类只有一个对象",而是:如何让这个唯一对象的创建、访问、复制限制和销毁策略都统一收口到一个受控设计里。

顺着这条主线回头看整章内容,逻辑其实非常统一:

  • 构造函数私有化,是为了阻止外部随便创建实例
  • 拷贝和赋值禁用,是为了防止复制出第二份对象
  • GetInstance() 是统一访问入口
  • 饿汉模式把实例提前准备好,换来实现简单
  • 懒汉模式把实例延后到真正使用时再创建,换来时机灵活
  • 但懒汉随之引入线程安全和释放时机问题
  • 若析构里承担文件写回等职责,还必须继续设计清理机制

所以,这一章最值得记住的一句话可以压缩成:

单例模式的本质,不是"写一个静态变量",而是"为全局唯一对象建立一套受控的创建与访问规则"。

当这条认识真正建立起来之后,后面再看线程安全单例、配置中心、日志器、对象池入口甚至工厂注册表时,就不会把它们看成零散技巧,而会自然落到同一套"全局唯一资源如何被安全管理"的设计主线上。

相关推荐
玖釉-2 小时前
C++ 硬核剖析:if 语句中的“双竖杠” || 到底怎么运行的?
开发语言·c++
m0_716765232 小时前
数据结构三要素、时间复杂度计算详解
开发语言·数据结构·c++·经验分享·笔记·算法·visual studio
And_Ii2 小时前
3740. 三个相等元素之间的最小距离 I
c++·算法
biter down2 小时前
C++11 可变参数模板
开发语言·c++
YYYing.3 小时前
【Linux/C++网络篇(一) 】网络编程入门:一文搞懂 TCP/UDP 编程模型与 Socket 网络编程
linux·网络·c++·tcp/ip·ubuntu·udp
bkspiderx3 小时前
libwebsockets 详解:介绍、交叉编译与使用指南
c++·websocket·libwebsockets
Mr YiRan3 小时前
JNI技术之手写JNIEnv与静态缓存与native异常
java·c++
飞翔的SA3 小时前
全程 Python:无需离开 Python 即可实现光速级 CUDA 加速,无需c++支持
开发语言·c++·python·nvidia·cuda
SccTsAxR3 小时前
算法进阶:贪心策略证明全攻略与二进制倍增思想深度解析
c++·经验分享·笔记·算法