文章目录
单例模式
在软件工程中,单例模式是一种软件设计模式,它将类的实例化限制为一个对象。当需要一个对象来协调整个系统的操作时,这很有用。确保某一个类只有一个实例,并提供一个全局的访问点来访问这个实例。
对于一个软件系统的某些类而言,我们无须创建多个实例。为了节约系统资源,有时需要确保系统中某个类只有唯一一个实例,当这个唯一实例创建成功之后,我们无法再创建一个同类型的其他对象,所有的操作都只能基于这个唯一实例。为了确保对象的唯一性,我们可以通过单例模式(Singeton Pattern)来实现,这就是单例模式的动机所在。
结构

cpp
// 定义一个单例模式的类
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;
};
如果使用单例模式,首先要保证这个类的实例有且仅有一个,也就是说这个对象是独生子女,如果我们实施计划生育只生一个孩子,不需要也不能给再他增加兄弟姐妹。因此,就必须采取一系列的防护措施。对于类来说以上描述同样适用。涉及一个类多对象操作的函数有以下几个:
- 构造函数:创建一个新的对象
- 拷贝构造函数:根据已有对象拷贝出一个新的对象
- 拷贝赋值操作符重载函数:两个对象之间的赋值
++为了把一个类可以实例化多个对象的路堵死++,可以做如下处理:
- 构造函数私有化,在类内部只调用一次,这个是可控的。
- 由于使用者在类外部不能使用构造函数,所以在类内部创建的这个唯一的对象必须是静态的,这样就可以通过类名来访问了,为了不破坏类的封装,我们都会把这个静态对象的访问权限设置为私有的。
- 在类中只有它的静态成员函数才能访问其静态成员变量,所以可以给这个单例类提供一个静态函数用于得到这个静态的单例对象。
- 拷贝构造函数私有化或者禁用(使用 = delete)
- 拷贝赋值操作符重载函数私有化或者禁用(从单例的语义上讲这个函数已经毫无意义,所以在类中不再提供这样一个函数,故将它也一并处理一下。)
实现
在实现一个单例模式的类的时候,有两种处理模式:
- 饿汉模式
- 懒汉模式
饿汉模式和懒汉模式的区别:
📢**懒汉模式的缺点是在创建实例对象的时候有安全问题,但这样可以减少内存的浪费(如果用不到就不去申请内存了)。饿汉模式则相反,在我们不需要这个实例对象的时候,它已经被创建出来,占用了一块内存。对于现在的计算机而言,内存容量都是足够大的,这个缺陷可以被无视。**
饿汉模式
饿汉模式就是在类加载的时候立刻进行实例化 ,这样就得到了一个唯一的可用对象。这种方式最简单,也没有并发问题和效率问题,但是在类加载时就初始化,++有些浪费内存,因为有可能这个方法自始至终都不会被调用到,尤其是在一些对外提供的工具包或 API 时应该尽量避免这种方式。++
非局部静态实现
cpp
// 饿汉模式
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;
//对象在全局区/静态区上
//static TaskQueue m_taskQ;
};
// 静态成员初始化放到类外部处理
TaskQueue* TaskQueue::m_taskQ = new TaskQueue;
//TaskQueue TaskQueue::m_taskQ;
int main()
{
TaskQueue* obj = TaskQueue::getInstance();
}
这种实现方式一般情况可以正常工作,但是在某种情况下存在一个问题,就是在C++中 "非局部静态对象"的 "初始化" 顺序 的 "不确定性"。
如果存在A、B两个单例类,在B的初始化过程中需要用到A类的实例,如果在使用A类实例的时候由于A类没有被初始化就会存在问题。
cpp
class A {
private:
int key;
public:
A();
int getKey() const;
};
extern A a;
cpp
#include "A.h"
A::A()
{
key = 2;
}
int A::getKey() const
{
return key;
}
//A的全局变量 a
A a;
cpp
#include "A.h"
class B {
public:
B();
};
cpp
#include "B.h"
#include <iostream>
B::B()
{
int key = a.getKey();
std::cout<<key<<std::endl;
}
//B的全局变量 b
B b;
cpp
#include "A.h"
int main()
{
return 0;
}
使用不同的编译顺序来编译代码:
bash
g++ -o main B.cpp A.cpp main.cpp
#执行main.exe结果为:2
g++ -o main A.cpp B.cpp main.cpp
#执行main.exe结果为:0
可见,++尽管是B.cpp中调用A对象,但是不同的编译顺序会产生不同的输出结果++。
对于在**同一个编译单元**(产生单一目标文件的源码,由单一源文件和其包含的头文件构成)定义的非局部静态对象,** 它们的初始化顺序是由其定义顺序决定的**,而** 对于在不同编译单元定义的非局部静态对象**,它们的**初始化顺序却是未定义的,因此是不确定的**。
局部静态实现
单例对象作为静态局部变量 ,然后增加一个辅助类,并声明一个该辅助类的类静态成员变量,在该辅助类的构造函数中,初始化单例对象。
cpp
#include <iostream>
#include <queue>
#include <mutex>
class TaskQueue
{
public:
// 禁用拷贝构造和赋值
TaskQueue(const TaskQueue& obj) = delete;
TaskQueue& operator=(const TaskQueue& obj) = delete;
// 获取单例实例
static TaskQueue* getInstance()
{
// 核心:静态局部变量
static TaskQueue instance;
return &instance;
}
private:
// 构造函数私有化
TaskQueue()=delete;
// --- 核心辅助类设计 ---
class Creator
{
public:
Creator()
{
// 在辅助类的构造函数中调用 getInstance
// 这会强迫上面的静态局部变量 instance 进行初始化
TaskQueue::getInstance();
}
};
// 定义一个静态辅助对象,它会在程序启动时(main之前)初始化
static Creator m_creator;
};
// 静态成员变量必须在类外初始化
// 这一行执行时会调用 Creator 的构造函数,进而触发 TaskQueue 的创建
TaskQueue::Creator TaskQueue::m_creator;
int main()
{
// 此时 TaskQueue 已经由 m_creator 提前创建好了
TaskQueue* obj = TaskQueue::getInstance();
return 0;
}
懒汉模式
懒汉模式是在类加载的时候不去创建这个唯一的实例,而是在需要使用的时候再进行实例化。
线程非安全实现
cpp
// 懒汉模式
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个,很显然这与单例模式的定义是相悖的。
**内存泄漏问题:**类中只负责 new 出对象,却没有负责 delete 对象。
智能指针+双重检查锁定+加锁
cpp
#include <iostream>
#include <memory>
#include <mutex>
class TaskQueue {
public:
// 禁用拷贝和赋值
TaskQueue(const TaskQueue& obj) = delete;
TaskQueue& operator=(const TaskQueue& obj) = delete;
static std::shared_ptr<TaskQueue> getInstance() {
// 第一重检查:判断指针是否为空
if (m_taskQ == nullptr) {
std::lock_guard<std::mutex> locker(m_mutex);
// 第二重检查:加锁后再次判断,防止多个线程同时通过第一重检查
if (m_taskQ == nullptr) {
// 使用 reset 或直接赋值来初始化 shared_ptr
// 注意:由于构造函数是私有的,直接使用 std::make_shared 会报错
// 解决方案是使用 new 构造后再赋值给 shared_ptr
m_taskQ.reset(new TaskQueue);
}
}
return m_taskQ;
}
private:
// 构造函数私有
TaskQueue() = default;
// 使用智能指针管理单例对象
static std::shared_ptr<TaskQueue> m_taskQ;
static std::mutex m_mutex;
};
// 静态成员初始化
std::shared_ptr<TaskQueue> TaskQueue::m_taskQ = nullptr;
std::mutex TaskQueue::m_mutex;
- **加锁:**在上面代码中第二重检查这个代码块被互斥锁锁住了,也就意味着不论有多少个线程,同时执行这个代码块的线程只能是一个。
- 双重检查锁定:在加锁、解锁的代码块外层有添加了一个if判断,这样当任务队列的实例被创建出来之后,访问这个对象的线程就不会再执行加锁和解锁操作了(只要有了单例类的实例对象,限行就解除了),对于第一次创建单例对象的时候线程之间还是具有竞争关系,被互斥锁阻塞。上面这种**通过两个嵌套的 if 来判断单例对象是否为空的操作就叫做双重检查锁定。**
- **智能指针:**基于 shared_ptr ,用了C++比较倡导的 RAII思想,用对象管理资源,当 shared_ptr 析构的时候,new 出来的对象也会被 delete掉。以此避免内存泄漏。
⚠️**双重检查锁定的问题**
假设有两个线程A、B,当线程A 执行到第 8 行时在线程A中 TaskQueue 实例对象 被创建,并赋值给 m_taskQ。
cpp
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 使用了一个没有被初始化的队列对象就出问题了(出现这种情况是概率问题,需要反复的大量测试问题才可能会出现)。**
在C++11中引入了原子变量atomic,通过原子变量可以实现一种更安全的懒汉模式的单例,代码如下:
cpp
#include <iostream>
#include <memory>
#include <mutex>
#include <atomic>
class TaskQueue {
public:
TaskQueue(const TaskQueue& obj) = delete;
TaskQueue& operator=(const TaskQueue& obj) = delete;
static std::shared_ptr<TaskQueue> getInstance() {
// 使用原子加载获取当前的 shared_ptr
// memory_order_acquire 确保之后的操作不会被重排序到此操作之前
std::shared_ptr<TaskQueue> tmp = std::atomic_load_explicit(&m_taskQ, std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> locker(m_mutex);
// 二次检查
tmp = std::atomic_load_explicit(&m_taskQ, std::memory_order_relaxed);
if (tmp == nullptr) {
// 创建实例
tmp.reset(new TaskQueue);
// 使用原子存储
// memory_order_release 确保之前的 new 操作(分配和构造)已经完成
std::atomic_store_explicit(&m_taskQ, tmp, std::memory_order_release);
}
}
return tmp;
}
private:
TaskQueue() = default;
static std::shared_ptr<TaskQueue> m_taskQ;
static std::mutex m_mutex;
};
// 静态成员初始化
std::shared_ptr<TaskQueue> TaskQueue::m_taskQ = nullptr;
std::mutex TaskQueue::m_mutex;
局部静态对象实现
在实现懒汉模式的单例的时候,相较于双重检查锁定模式有一种更简单的实现方法并且不会出现线程安全问题,那就是使用静态局部局部对象,对应的代码实现如下:
cpp
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 行定义了一个**静态局部队列对象,并且将这个对象作为了唯一的单例实例**。使用这种方式之所以是线程安全的,是因为在C++11标准中有如下规定,并且这个操作是在编译时由编译器保证的:
如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。
设计一个任务队列

cpp
#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_guard<mutex> locker(m_mutex);
bool flag = m_taskQ.empty();
return flag;
}
// 添加任务
void addTask(int data)
{
lock_guard<mutex> locker(m_mutex);
m_taskQ.push(data);
}
// 取出一个任务
int takeTask()
{
lock_guard<mutex> locker(m_mutex);
if (!m_taskQ.empty())
{
return m_taskQ.front();
}
return -1;
}
// 删除一个任务
bool popTask()
{
lock_guard<mutex> locker(m_mutex);
if (!m_taskQ.empty())
{
m_taskQ.pop();
return true;
}
return false;
}
private:
TaskQueue() = default;
static TaskQueue m_obj;
queue<int> 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();
}

特点
主要优点
- 单例模式提供了对唯一实例的受控访问。因为单例类封装了它的唯一实例,所以它可以严格控制客户怎样以及何时访问它。
- 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能。
- 允许可变数目的实例(多例模式) 。基于单例模式我们可以进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例,既节省系统资源,又解决了单例对象共享过多有损性能的问题。
主要缺点
- 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
- 单例类的职责过重,在一定程度上违背了"单一职责原则"。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当了产品角色,包含一些业务方法,将产品的创建和产品的本身的功能融合到一起。
- 现在很多面向对象语言(如Java、C#)的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的共享对象长时间不被利用 ,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致共享的单例对象状态的丢失。
适用环境
- 系统只需要一个实例对象,如系统要求提供一个唯一的序列号生成器或资源管理器,或者需要考虑资源消耗太大而只允许创建一个对象。
- 客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。