单例模式 - 巴基速递 📅 发布时间:2026/8/31 6:59:52 👁 浏览次数: 1. 巴基的订单在海贼世界中巴基速递是巴基依靠手下强大的越狱犯兵力组建的集团海贼派遣公司它的主要业务是向世界有需要的地方输送雇佣兵其实是不干好事儿。自从从特拉法尔加罗和路飞同盟击败了堂吉诃德家族 战争的市场对雇佣兵的依赖越来越大。订单便源源不断的来了。此时我们来分析一个问题巴基是怎么接单并且派单的呢简单来说巴基肯定是有一个账本用于记录下单者信息下单者的需求以及下单的时间然后根据下单的先后顺序选择合适的人手进行派单。从程序猿的视角可以这样认为这个账本其实就相当于一个任务队列有一定的容量可以存储任务按照下单的先后顺序存储并处理任务 – 典型的队列特性先进先出对于巴基来说把所有的订单全部记录到一个账本上就够了如果将其平移到项目中也就意味着应用程序在运行过程中存储任务的任务队列一个足矣弄太多反而冗余不太好处理了。在一个项目中全局范围内某个类的实例有且仅有一个通过这个唯一实例向其他模块提供数据的全局访问这种模式就叫单例模式。单例模式的典型应用就是任务队列。2. 独生子女如果使用单例模式首先要保证这个类的实例有且仅有一个也就是说这个对象是独生子女如果我们实施计划生育只生一个孩子不需要也不能给再他增加兄弟姐妹。因此就必须采取一系列的防护措施。对于类来说以上描述同样适用。涉及一个类多对象操作的函数有以下几个构造函数创建一个新的对象拷贝构造函数根据已有对象拷贝出一个新的对象拷贝赋值操作符重载函数两个对象之间的赋值为了把一个类可以实例化多个对象的路堵死可以做如下处理1.构造函数私有化在类内部只调用一次这个是可控的。由于使用者在类外部不能使用构造函数所以在类内部创建的这个唯一的对象必须是静态的这样就可以通过类名来访问了为了不破坏类的封装我们都会把这个静态对象的访问权限设置为私有的。在类中只有它的静态成员函数才能访问其静态成员变量所以可以给这个单例类提供一个静态函数用于得到这个静态的单例对象。2.拷贝构造函数私有化或者禁用使用 delete3.拷贝赋值操作符重载函数私有化或者禁用从单例的语义上讲这个函数已经毫无意义所以在类中不再提供这样一个函数故将它也一并处理一下。由于单例模式就是给类创建一个唯一的实例对象所以它的UML类图是很简单的因此定义一个单例模式的类的示例代码如下// 定义一个单例模式的类 class Singleton { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 Singleton(const Singleton obj) delete; Singleton operator(const Singleton obj) delete; static Singleton* getInstance(); private: Singleton() default; static Singleton* m_obj; };在实现一个单例模式的类的时候有两种处理模式·饿汉模式·懒汉模式3. 饿汉模式饿汉模式就是在类加载的时候立刻进行实例化这样就得到了一个唯一的可用对象。关于这个饿汉模式的类的定义如下:// 饿汉模式 class TaskQueue { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 TaskQueue(const TaskQueue obj) delete; TaskQueue operator(const TaskQueue obj) delete; static TaskQueue* getInstance() { return m_taskQ; } private: TaskQueue() default; static TaskQueue* m_taskQ; }; // 静态成员初始化放到类外部处理 TaskQueue* TaskQueue::m_taskQ new TaskQueue; int main() { TaskQueue* obj TaskQueue::getInstance(); }在第17行定义这个单例类的时候就把这个静态的单例对象创建出来了。当使用者通过getInstance()获取这个单例对象的时候它已经被准备好了。注意事项类的静态成员变量在使用之前必须在类的外部进行初始化才能使用。4. 懒汉模式懒汉模式是在类加载的时候不去创建这个唯一的实例而是在需要使用的时候再进行实例化。4.1 懒汉模式类的定义// 懒汉模式 class TaskQueue { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 TaskQueue(const TaskQueue obj) delete; TaskQueue operator(const TaskQueue obj) delete; static TaskQueue* getInstance() { if(m_taskQ nullptr) { m_taskQ new TaskQueue; } return m_taskQ; } private: TaskQueue() default; static TaskQueue* m_taskQ; }; TaskQueue* TaskQueue::m_taskQ nullptr;在调用getInstance()函数获取单例对象的时候如果在单线程情况下是没有什么问题的如果是多个线程调用这个函数去访问单例对象就有问题了。假设有三个线程同时执行了getInstance()函数在这个函数内部每个线程都会new出一个实例对象。此时这个任务队列类的实例对象不是一个而是3个很显然这与单例模式的定义是相悖的。4.2 线程安全问题双重检查锁定对于饿汉模式是没有线程安全问题的在这种模式下访问单例对象的时候这个对象已经被创建出来了。要解决懒汉模式的线程安全问题最常用的解决方案就是使用互斥锁。可以将创建单例对象的代码使用互斥锁锁住处理代码如下class TaskQueue { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 TaskQueue(const TaskQueue obj) delete; TaskQueue operator(const TaskQueue obj) delete; static TaskQueue* getInstance() { m_mutex.lock(); if (m_taskQ nullptr) { m_taskQ new TaskQueue; } m_mutex.unlock(); return m_taskQ; } private: TaskQueue() default; static TaskQueue* m_taskQ; static mutex m_mutex; }; TaskQueue* TaskQueue::m_taskQ nullptr; mutex TaskQueue::m_mutex;在上面代码的10~13 行这个代码块被互斥锁锁住了也就意味着不论有多少个线程同时执行这个代码块的线程只能是一个相当于是严重限行了在重负载情况下可能导致响应缓慢。我们可以将代码再优化一下class TaskQueue { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 TaskQueue(const TaskQueue obj) delete; TaskQueue operator(const TaskQueue obj) delete; static TaskQueue* getInstance() { if (m_taskQ nullptr) { m_mutex.lock(); if (m_taskQ nullptr) { m_taskQ new TaskQueue; } m_mutex.unlock(); } return m_taskQ; } private: TaskQueue() default; static TaskQueue* m_taskQ; static mutex m_mutex; }; TaskQueue* TaskQueue::m_taskQ nullptr; mutex TaskQueue::m_mutex;改进的思路就是在加锁、解锁的代码块外层有添加了一个if判断第9行这样当任务队列的实例被创建出来之后访问这个对象的线程就不会再执行加锁和解锁操作了只要有了单例类的实例对象限行就解除了对于第一次创建单例对象的时候线程之间还是具有竞争关系被互斥锁阻塞。上面这种通过两个嵌套的 if 来判断单例对象是否为空的操作就叫做双重检查锁定。双重检查锁定的问题假设有两个线程A、B当线程A 执行到第 8 行时在线程A中 TaskQueue 实例对象 被创建并赋值给 m_taskQ。static TaskQueue* getInstance() { if (m_taskQ nullptr) { m_mutex.lock(); if (m_taskQ nullptr) { m_taskQ new TaskQueue; } m_mutex.unlock(); } return m_taskQ; }但是实际上 m_taskQ new TaskQueue; 在执行过程中对应的机器指令可能会被重新排序。正常过程如下第一步分配内存用于保存 TaskQueue 对象。第二步在分配的内存中构造一个 TaskQueue 对象初始化内存。第三步使用 m_taskQ 指针指向分配的内存。但是被重新排序以后执行顺序可能会变成这样第一步分配内存用于保存 TaskQueue 对象。第二步使用 m_taskQ 指针指向分配的内存。第三步在分配的内存中构造一个 TaskQueue 对象初始化内存。这样重排序并不影响单线程的执行结果但是在多线程中就会出问题。如果线程A按照第二种顺序执行机器指令执行完前两步之后失去CPU时间片被挂起了此时线程B在第3行处进行指针判断的时候m_taskQ 指针是不为空的但这个指针指向的内存却没有被初始化最后线程 B 使用了一个没有被初始化的队列对象就出问题了出现这种情况是概率问题需要反复的大量测试问题才可能会出现。在C11中引入了原子变量atomic通过原子变量可以实现一种更安全的懒汉模式的单例代码如下class TaskQueue { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 TaskQueue(const TaskQueue obj) delete; TaskQueue operator(const TaskQueue obj) delete; static TaskQueue* getInstance() { TaskQueue* queue m_taskQ.load(); if (queue nullptr) { // m_mutex.lock(); // 加锁: 方式1 lock_guardmutex locker(m_mutex); // 加锁: 方式2 queue m_taskQ.load(); if (queue nullptr) { queue new TaskQueue; m_taskQ.store(queue); } // m_mutex.unlock(); } return queue; } void print() { cout hello, world!!! endl; } private: TaskQueue() default; static atomicTaskQueue* m_taskQ; static mutex m_mutex; }; atomicTaskQueue* TaskQueue::m_taskQ; mutex TaskQueue::m_mutex; int main() { TaskQueue* queue TaskQueue::getInstance(); queue-print(); return 0; }上面代码中使用原子变量atomic的store() 方法来存储单例对象使用load() 方法来加载单例对象。在原子变量中这两个函数在处理指令的时候默认的原子顺序是memory_order_seq_cst顺序原子操作 - sequentially consistent使用顺序约束原子操作库整个函数执行都将保证顺序执行并且不会出现数据竞态data races不足之处就是使用这种方法实现的懒汉模式的单例执行效率更低一些。静态局部对象在实现懒汉模式的单例的时候相较于双重检查锁定模式有一种更简单的实现方法并且不会出现线程安全问题那就是使用静态局部局部对象对应的代码实现如下class TaskQueue { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 TaskQueue(const TaskQueue obj) delete; TaskQueue operator(const TaskQueue obj) delete; static TaskQueue* getInstance() { static TaskQueue taskQ; return taskQ; } void print() { cout hello, world!!! endl; } private: TaskQueue() default; }; int main() { TaskQueue* queue TaskQueue::getInstance(); queue-print(); return 0; }在程序的第 9、10 行定义了一个静态局部队列对象并且将这个对象作为了唯一的单例实例。使用这种方式之所以是线程安全的是因为在C11标准中有如下规定并且这个操作是在编译时由编译器保证的如果指令逻辑进入一个未被初始化的声明变量所有并发执行应当等待该变量完成初始化。最后总结一下懒汉模式和饿汉模式的区别懒汉模式的缺点是在创建实例对象的时候有安全问题但这样可以减少内存的浪费如果用不到就不去申请内存了。饿汉模式则相反在我们不需要这个实例对象的时候它已经被创建出来占用了一块内存。对于现在的计算机而言内存容量都是足够大的这个缺陷可以被无视。5. 替巴基写一个任务队列作为程序猿的我们如果想给巴基的账本升级成一个应用程序首要任务就是设计一个单例模式的任务队列那么就需要赋予这个类一些属性和方法1.属性存储任务的容器这个容器可以选择使用STL中的队列queue)互斥锁多线程访问的时候用于保护任务队列中的数据2.方法主要是对任务队列中的任务进行操作任务队列中任务是否为空往任务队列中添加一个任务从任务队列中取出一个任务从任务队列中删除一个任务根据分析就可以把这个饿汉模式的任务队列的单例类定义出来了#include iostream #include queue #include mutex #include thread using namespace std; class TaskQueue { public: // delete 代表函数禁用, 也可以将其访问权限设置为私有 TaskQueue(const TaskQueue obj) delete; TaskQueue operator(const TaskQueue obj) delete; static TaskQueue* getInstance() { return m_obj; } // 任务队列是否为空 bool isEmpty() { lock_guardmutex locker(m_mutex); bool flag m_taskQ.empty(); return flag; } // 添加任务 void addTask(int data) { lock_guardmutex locker(m_mutex); m_taskQ.push(data); } // 取出一个任务 int takeTask() { lock_guardmutex locker(m_mutex); if (!m_taskQ.empty()) { return m_taskQ.front(); } return -1; } // 删除一个任务 bool popTask() { lock_guardmutex locker(m_mutex); if (!m_taskQ.empty()) { m_taskQ.pop(); return true; } return false; } private: TaskQueue() default; static TaskQueue m_obj; queueint m_taskQ; mutex m_mutex; }; TaskQueue TaskQueue::m_obj; int main() { thread t1([]() { TaskQueue* taskQ TaskQueue::getInstance(); for (int i 0; i 100; i) { taskQ-addTask(i 100); cout push task: i 100 , threadID: this_thread::get_id() endl; this_thread::sleep_for(chrono::milliseconds(500)); } }); thread t2([]() { TaskQueue* taskQ TaskQueue::getInstance(); this_thread::sleep_for(chrono::milliseconds(100)); while (!taskQ-isEmpty()) { int data taskQ-takeTask(); cout ---take task: data , threadID: this_thread::get_id() endl; taskQ-popTask(); this_thread::sleep_for(chrono::seconds(1)); } }); t1.join(); t2.join(); }在上面的程序中有以下几点需要说明一下正常情况下任务队列中的任务应该是一个函数指针这个指针指向的函数中有需要执行的任务动作此处进行了简化用一个整形数代替了任务队列中的任务。任务队列中的互斥锁保护的是单例对象的中的数据也就是任务队列中的数据上面所说的线程安全指的是在创建单例对象的时候要保证这个对象只被创建一次和此处完全是两码事儿需要区别看待。关于lock_guard的使用不懂的可以跳转到这里。在main()函数中创建了两个子线程关于chrono库的使用不懂的可以跳转到这里。关于thread类的使用不懂的可以跳转到这里。关于this_thread命名空间的使用不懂的可以跳转到这里。t1线程的处理动作是往任务队列中添加任务t2线程的处理动作是从任务队列中取任务为了保证能够取出所有的任务此处需要让t2线程的执行晚并且慢一些。笔记所谓的单例模式的核心就是单单的意思是唯一唯一指的是对象对象属于类也就意味着这个类只能new出一个唯一的实例对象如果满足这个需求这个类就叫做单例类对应的模式就叫做单例模式队列是一种数据结构先进先出任务队列在项目里边肯定是以单例存在的如何创建一个单例模式的类构造函数的方式有三种默认的无参构造函数、拷贝构造函数、移动构造函数移动构造函数相当于做了一个资源的转移通过移动构造函数不能够创建出新的对象实例最终这个对象有且仅有一个因此需要对空的默认构造函数和拷贝构造函数进行相应的操作处理方式有两种一种是把函数设置成私有的设置成私有后不能够在类的外边调用该函数第二种就是显式地把这两个函数删除通过delete通过类名去访问类内部的属性和方法这个属性和方法肯定是静态的如果不是静态的一般都是通过对象来调用的静态成员函数只能操作静态成员变量给静态指针做初始化关于类里边的静态成员变量我们在使用的时候有一个限定条件就是必须在类的外部给它做初始化只有静态的成员函数和成员变量才属于类静态的成员变量是不能够在类的内部进行初始化代码1.#include iostream using namespace std; //定义一个单例模式的类这个类就是任务队列 class TaskQueue { public: //TaskQueue() delete; TaskQueue(const TaskQueue t) delete; TaskQueue operator(const TaskQueue t) delete; static TaskQueue* getInstance() { return m_taskQ; } void print() { cout 我是单例单例对象的一个成员函数... endl; } private: //default就是使用这两个构造函数的默认行为 TaskQueue() default; /*TaskQueue(const TaskQueue t) default; TaskQueue operator(const TaskQueue t) default;*/ //只能通过类名访问静态属性或方法 static TaskQueue* m_taskQ; }; TaskQueue* TaskQueue::m_taskQ new TaskQueue; int main() { TaskQueue* taskQ TaskQueue::getInstance(); taskQ-print(); return 0; }/** * * 文件名 Singleton.cpp * 功能描述 使用饿汉式单例模式实现一个任务队列管理类 * 核心考点 单例模式、静态成员、delete、default、线程安全 * 设计模式 单例模式Singleton Pattern * */ #include iostream // 包含输入输出流库用于cout输出 using namespace std; // 使用标准命名空间简化代码书写面试注意大型项目慎用 /** * 【单例类】TaskQueue * 作用确保全局只有一个TaskQueue实例提供统一的访问入口 * 面试核心单例模式的三要素——①私有构造函数 ②静态成员指针 ③公有的静态获取函数 */ class TaskQueue { public: /** * 【删除拷贝构造函数】 * 作用禁止通过拷贝构造创建新对象防止破坏单例的唯一性 * 面试点delete是C11特性比将函数声明为private更明确、更优雅 * 为什么必须删除——如果允许拷贝单例就不再是单例了 */ TaskQueue(const TaskQueue t) delete; /** * 【删除赋值运算符重载】 * 作用禁止通过赋值操作复制对象进一步保证单例的唯一性 * 面试点拷贝构造和赋值运算符要成对删除 * 补充如果只删拷贝构造赋值操作仍可能破坏单例 */ TaskQueue operator(const TaskQueue t) delete; /** * 【静态成员函数】获取单例实例的唯一入口 * 返回值TaskQueue* 指向唯一实例的指针 * 面试核心静态函数属于类不依赖任何对象因此可以在没有实例时被调用 * 为什么是静态函数——因为还没有对象时就需要能获取实例 */ static TaskQueue* getInstance() { return m_taskQ; // 返回在类外初始化的静态指针 } /** * 【普通成员函数】测试用 * 作用验证获取到的单例对象可以正常使用 * 面试点单例对象可以像普通对象一样调用成员函数 */ void print() { cout 我是单例单例对象的一个成员函数... endl; } private: /** * 【私有默认构造函数】default * 作用让编译器生成默认构造函数但将其设为私有阻止外部直接new * 面试点1default告诉编译器显式生成默认版本只能用于特殊成员函数 * 面试点2因为没有提供任何参数所以无法从外部用 new TaskQueue() 创建 * * 为什么用 default 而不是自己写一个空的构造函数 * 答default更简洁语义更明确且编译器生成的版本可能更高效 * * 注意如果去掉这个私有构造编译器会报错因为类外初始化时无法访问 */ TaskQueue() default; /** * 警告下面这两行如果取消注释将破坏单例 * 因为default会生成可用的拷贝构造和赋值运算符允许复制对象 * 错误示例 * TaskQueue task1 *TaskQueue::getInstance(); // 拷贝构造 * TaskQueue task2; task2 *TaskQueue::getInstance(); // 赋值 * * 正确做法保持 delete彻底禁止拷贝 */ /*TaskQueue(const TaskQueue t) default; TaskQueue operator(const TaskQueue t) default;*/ /** * 【静态成员指针】指向唯一实例 * 作用存储单例对象的地址所有对象共享同一个指针 * 面试核心静态成员属于类不属于任何对象在程序启动时分配内存 * 访问方式只能通过类名访问ClassName::member因为静态成员没有this指针 * * 注意这里只是声明必须在类外定义和初始化 */ static TaskQueue* m_taskQ; }; /** * 【静态成员变量定义与初始化】 * 位置类外全局作用域 * 作用分配存储空间并初始化唯一的单例对象 * 面试必问为什么静态成员变量必须在类外初始化 * 答类内只是声明告诉编译器有这个变量类外定义是真正分配存储空间 * 执行时机在main函数执行之前程序启动时完成初始化 * * 注意使用 new 在堆区创建对象由操作系统在程序退出时回收 * 这就是饿汉式单例——不管用不用程序启动时就创建好 */ TaskQueue* TaskQueue::m_taskQ new TaskQueue; /** * 【主函数】测试入口 * 演示如何获取并使用单例对象 */ int main() { /** * 获取单例实例 * 通过类名直接调用静态函数无需创建任何对象 * 返回值指向唯一实例的指针 */ TaskQueue* taskQ TaskQueue::getInstance(); /** * 使用单例对象的成员函数 * 验证获取到的对象可以正常工作 */ taskQ-print(); return 0; // 程序正常退出 }懒汉模式和饿汉模式懒汉模式和饿汉模式的比较优势在于比较节省内存空间饿汉模式在不需要这个实例对象的时候就把这个实例对象创建出来了创建出实例对象就占用一块内存在多线程的场景下饿汉模式是没有安全问题的所谓的多线程安全就是多线程可以同时访问这个单例对象在多线程下懒汉模式是有线程安全问题的怎么解决加互斥锁通过互斥锁把这个线程阻塞让多个线程依次访问这个单例对象就可以避免在懒汉模式下多个线程同时访问这个单例对象创建出多个类的实力问题但是这样效率比较低。多线程情况下推荐使用饿汉模式