单例模式
- 单例模式的概念与定义
- 单例模式的分类
- 线程安全问题
- 案例程序—创建一个单例任务队列
- 单例模式总结
- 🚩面试篇
单例模式的概念与定义
单例模式在创建型模式中用的非常多
单例模式以其简洁的概念、密集的使用频次和重要的使用场景、加上简洁的编码实现,成为了众多设计模式中考察频次最多的一个
因为在一个项目中,全局范围内,某个类的实例有且仅有一个,通过这个唯一实例向其他模块提供数据的全局访问,这种模式就叫单例模式。单例模式的典型应用就是任务队列、全局信号总线管理器、堆区管理器等。
如果使用单例模式,首先要保证这个类的实例有且仅有一个,因此,就必须采取一系列的防护措施。对于类来说以上描述同样适用。涉及一个类多对象操作的函数有以下几个:
- 构造函数: 创建一个新的对象
- 拷贝构造函数: 根据已有对象拷贝出一个新的对象
- 拷贝赋值操作符重载函数: 两个对象之间的赋值
为了把一个类可以实例化多个对象的路堵死,可以做如下处理:
- 构造函数私有化,在类内部只调用一次,这个是可控的
- 拷贝构造函数私有化或者禁用(使用 =
delete
) - 拷贝赋值操作符重载函数私有化或者禁用
应该私有化或者删除后,无法在类外创建实例了,由于使用者在类外部不能使用构造函数,只能通过类名来得到,那内部的为类的内部静态对象。
-
在类中定义静态成员,即属于类的静态实例对象
private:static Singleton* m_obj; // 单例对象
-
由于私有的静态成员变量,只能通过公共的静态方法获得,给这个单例类提供一个静态函数用于得到这个静态的单例对象:
public:static Singleton* getInstance(){return m_obj;}
-
使用的时候,静态变量必须在类外部对其进行初始化
Singleton* Singleton::m_obj = new Singleton;
-
main函数创建实例对象
int main() {// 创建对象Singleton* obj1 = Singleton::getInstance();obj1->printf();return 0; }
其单例模式UML图与案例程序如下:

class Singleton
{
public:static Singleton* getInstance(){return m_obj;}void printf(){cout << "hello world" << endl;}// 拷贝赋值操作符重载函数函数,也可以使用私有化Singleton& operator=(const Singleton& obj) = delete;
protected:private:// 构造函数私有化Singleton() = default; // 拷贝构造函数私有化Singleton(const Singleton& obj) = default;static Singleton* m_obj; // 单例对象
};
// 初始化静态成员变量
Singleton* Singleton::m_obj = new Singleton;
单例模式的分类
单例模式又分为饿汉式模式与懒汉式模式: 根据对类的静态成员变量的初始化是否为空进行分类
-
饿汉模式就是在类加载的时候立刻进行实例化,这样就得到了一个唯一的可用对象。关于这个饿汉模式的类的定义如下:
// 饿汉模式 -- 定义类的时候创建单例对象 class Singleton { public:// = delete 代表函数禁用, 也可以将其访问权限设置为私有Singleton(const Singleton& obj) = delete;Singleton& operator=(const Singleton& obj) = delete;static Singleton* getInstance(){return m_obj;}private:Singleton() = default; // 构造函数私有化,饿汉式构造函数不能删除,必须私有并默认static Singleton* m_obj; // 单例对象 }; // 初始化静态成员变量 Singleton* Singleton::m_obj = new Singleton;// 定义一个单例模式的实例对象 int main() {// 创建对象Singleton* obj1 = Singleton::getInstance();return 0; }
-
懒汉模式在类加载的时候不去创建这个唯一的实例,而是在需要使用的时候再进行实例化,相比饿汉式,节省内存空间
// 懒汉模式 -- 什么时候使用这个单例对象, 在使用的时候再去创建对应的实例 // class Singleton { public:Singleton(const Singleton& obj) = delete;Singleton& operator=(const Singleton& obj) = delete;static Singleton* getInstance(){if(m_obj == nullptr){m_obj = new Singleton();}return m_obj;}private:Singleton() = default;static Singleton* m_obj; // 单例对象 }; // 初始化静态成员变量 Singleton* Singleton::m_obj = nullptr;
线程安全问题
- 对于饿汉模式是没有线程安全问题的,在这种模式下多线程访问单例对象(getInstance)的时候,这个对象已经被创建出来了,只做读取
- 对于懒汉模式的线程安全问题,最常用的解决方案就是使用互斥锁。可以将创建单例对象的代码使用互斥锁锁住,处理代码如下:
class Singleton
{
public:Singleton(const Singleton& obj) = delete;Singleton& operator=(const Singleton& obj) = delete;static Singleton* getInstance(){m_mutex.lock(); // 加锁if(m_obj == nullptr){m_obj = new Singleton();}m_mutex.unlock();return m_obj;}void printf(){cout << "hello world" << endl;}protected:private:Singleton() = default;static Singleton* m_obj; static mutex m_mutex; // 定义一把互斥锁
};
// 初始化静态成员变量
Singleton* Singleton::m_obj = nullptr;
mutex Singleton::m_mutex;
分析以上代码,发现 getInstance
方法因为加了互斥锁,对于每个线程而言,降低了效率,多个线程无法同时执行,会被阻塞。
改进:双重检查锁定
static Singleton* getInstance()
{if(m_obj == nullptr){m_mutex.lock(); // 加锁if(m_obj == nullptr){m_obj = new Singleton();}m_mutex.unlock();}return m_obj;
}
但是实际上 m_taskQ = new TaskQueue;
在执行过程中对应的机器指令可能会被重新排序。正常过程如下:
-
第一步:分配内存用于保存 TaskQueue 对象。
-
第二步:在分配的内存中构造一个 TaskQueue 对象(初始化内存)
-
第三步:使用 m_taskQ 指针指向分配的内存。
但是被重新排序以后执行顺序可能会变成这样:
-
第一步:分配内存用于保存 TaskQueue 对象。
-
第二步:使用 m_taskQ 指针指向分配的内存。
-
第三步:在分配的内存中构造一个 TaskQueue 对象(初始化内存)。
这样重排序并不影响单线程的执行结果,但是在多线程中就会出问题。如果线程A按照第二种顺序执行机器指令,执行完前两步之后失去CPU时间片被挂起了,此时线程B在第3行处进行指针判断的时候m_taskQ
指针是不为空的,但这个指针指向的内存却没有被初始化,最后线程 B 使用了一个没有被初始化的队列对象就出问题了(概率问题)
在C++11中引入了原子变量atomic(在底层控制了机器指令的执行顺序),通过原子变量可以实现一种更安全的懒汉模式的单例,代码如下:
class Singleton
{
public:Singleton(const Singleton& obj) = delete;Singleton& operator=(const Singleton& obj) = delete;// 使用原子变量static Singleton* getInstance(){Singleton* obj = m_obj.load(); // 读取原子变量的值 if(obj == nullptr){m_mutex.lock(); // 加锁obj = m_obj.load(); // 读取原子变量的值if(obj == nullptr){obj = new Singleton();m_obj.store(obj); // 保存到原子变量}m_mutex.unlock();}return obj;}private:Singleton() = default;static atomic<Singleton*> m_obj; // 单例对象,原子变量管理static mutex m_mutex;
};// 初始化静态成员变量
atomic<Singleton*> Singleton::m_obj;
mutex Singleton::m_mutex;
上面代码中使用原子变量 atomic
的 store()
方法来存储单例对象,使用 load()
方法来加载单例对象。在原子变量中这两个函数在处理指令的时候默认的原子顺序是memory_order_seq_cst
(顺序原子操作 - sequentially consistent
),使用顺序约束原子操作库,整个函数执行都将保证顺序执行,并且不会出现数据竞态(data races),不足之处就是使用这种方法实现的懒汉模式的单例执行效率更低一些。
方法2: 使用静态的局部对象解决线程安全问题 ---->>>> 编译器支持C++11
class Singleton
{
public:Singleton(const Singleton& obj) = delete;Singleton& operator=(const Singleton& obj) = delete;// 使用原子变量static Singleton* getInstance(){static Singleton obj;return &obj;}private:Singleton() = default;
};
定义了一个静态局部队列对象,并且将这个对象作为了唯一的单例实例。使用这种方式之所以是线程安全的,是因为在C++11标准中有如下规定,并且这个操作是在编译时由编译器保证的:如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。
总结: 懒汉模式的缺点是在创建实例对象的时候有安全问题,但这样可以减少内存的浪费(如果用不到就不去申请内存了)。饿汉模式则相反,在我们不需要这个实例对象的时候,它已经被创建出来,占用了一块内存。对于现在的计算机而言,内存容量都是足够大的,这个缺陷可以被无视。
案例程序—创建一个单例任务队列
#include <iostream>
#include <queue>
#include <mutex>
#include <thread>using namespace std;// 饿汉模式
class TaskQueue
{
public:TaskQueue(const TaskQueue& queue) = delete; // 删除拷贝构造函数TaskQueue& operator=(const TaskQueue& queue) = delete; // 删除拷贝赋值操作符重载函数// 公有的获取实例对象static TaskQueue* getInstance() {return taskQueue;}// 添加任务void addTask(int task){lock_guard<mutex> lock(m_mutex);m_que.push(task);}// 删除任务bool popTask() {lock_guard<mutex> lock(m_mutex);if(m_que.empty())return false;m_que.pop();return true;}// 获取任务int getTask() {lock_guard<mutex> locker(m_mutex);if(m_que.empty())return -1;int task = m_que.front();return task;}// 判断任务队列是否为空bool isEmpty() {lock_guard<mutex> lock(m_mutex);bool flag = m_que.empty();return flag;}private:TaskQueue() = default; // 构造函数私有化static TaskQueue* taskQueue; // 单例对象// 定义任务队列queue<int> m_que;mutex m_mutex;
};
// 类外部初始化类的静态成员变量
TaskQueue* TaskQueue::taskQueue = new TaskQueue();void func1(TaskQueue* taskQueue)
{// 生产者for (int i = 0; i < 20; i++) {taskQueue->addTask(i + 100);cout << "+++ push data: " << i + 100 << ", threadID: " << this_thread::get_id() << endl;this_thread::sleep_for(chrono::milliseconds(100)); // 休眠一定的时间长度}}void func2(TaskQueue* taskQueue)
{// 消费者this_thread::sleep_for(chrono::milliseconds(500)); while (!taskQueue->isEmpty()){int task = taskQueue->getTask();taskQueue->popTask();cout << "--- get data: " << task << ", threadID: " << this_thread::get_id() << endl;this_thread::sleep_for(chrono::milliseconds(500)); }
}int main()
{TaskQueue* taskQueue = TaskQueue::getInstance();thread thread1(func1, taskQueue); // 生产者线程thread thread2(func2, taskQueue); // 消费者线程thread1.join();thread2.join();return 0;
}
单例模式总结
优点:
- 单例模式提供了严格的对唯一实例的创建和访问
- 单例模式的实现可以节省系统资源
缺点 :
- 如果某个实例负责多重职责但又必须实例唯一,那单例类的职责过多,这违背了单一职责原则
- 多线程下需要考虑线程安全机制
- 单例模式没有抽象层,不方便扩展
适用环境 :
- 系统只需要一个实例对象
- 某个实例只允许有一个访问接口
🚩面试篇
单例模式: 确保一个类只有一个实例,并提供一个全局访问点来访问这个唯一实例。
(1)构造函数的设计(为什么私有?除了私有还可以怎么实现(进阶)?)
- 防止外部实例化, 私有构造函数确保只有类自身能够创建实例,外部代码无法通过new关键字创建新对象,防止无意中创建多个实例,破坏单例约束。
- 采用奇异递归模板模式
-
创建基类模板,并将基类构造函数设置为保护类型,防止直接实例化
-
派生类的构造函数可以是公共或保护
-
声明基类为友元,允许基类访问派生类的构造函数
-
(2)对外接口的设计(为什么这么设计?)
- 提供一个全局访问点来访问这个实例,使用static确保这个访问是类级别的访问
(3)单例对象的设计(为什么是static?如何初始化?如何销毁?(进阶))
-
全访问点,利用static保证该访问数据类级别,由于静态成员属性只能访问静态成员变量,因此需要将成员变量的实例也设置为static
-
私有化构造函数,删除拷贝构造函数与拷贝赋值操作符重载函数,并且在类外对类的静态成员变量进行初始化
-
设置实例对象为静态局部变量,自动销毁,或者手动delete删除,或者利用智能指针管理
(4)懒汉模式和恶汉模式的实现(判空!!!加锁!!!),并且要能说明原因(为什么判空两次?)
- 恶汉模式无线程安全问题,因为一开始就创建了实例对象,多线程获取对象的时候只做返回静态成员变量
- 懒汉模式在使用的时候才创建实例对象,在多线程中存在线程安全问题,多个线程第一个进入创建实例,存在创建了多个实例的隐患,因此需要加互斥锁,保证当前只有一个线程在创建实例对象。
- 这把锁又影响了效率,这样一来只有一个线程运行,其他线程阻塞,因此需要2次判空
- 第一次判空,在大多数情况下,实例已经存在,直接返回,避免了不必要的互斥开销
- 同步互斥块:确保只有一个线程能进入创建实例的代码段
- 第二次判空:防止多个线程同时通过第一次判空后,在互斥块内重复创建实例
(5)对于C++编码者,需尤其注意C++11以后的单例模式的实现(为什么这么简化?怎么保证的(进阶))
-
静态局部变量方式
-
在C++11标准中有如下规定,并且这个操作是在编译时由编译器保证的:如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。
-
双重检查锁的问题所在: 一条语句的机器指令分为3步
- 分配内存空间用于保存对象
- 在分配的内存空间上构建一个单例对象
- 使用单例对象的指针指向内存空间
该顺序可能被重排变成1,3,2,可以使用原子操作来解决,或者使用C++11以上的静态局部变量解决