1.什么是单例模式
在一个项目中,全局范围内,某个类的实例有且仅有一个,通过这个唯一实例向其他模块提供数据的全局访问,这种模式就叫单例模式。
类中多对象的操作函数有如下几个:
构造函数
: 能够创建出一个新对象;拷贝构造函数
:能够根据一个已经存在的对象拷贝出一个新对象;赋值操作符重载函数
:用一个对象给另一个对象赋值;
为了使得类全局只有一个实例,我们需要对这些函数做一些处理:
- 构造函数私有化,且在类内部只被调用一次;
-
- 由于使用者在类外部不能使用构造函数,所以在类内部创建的这个唯一的对象必须是静态 的,这样就可以通过类名来访问了,为了不破坏类的封装,我们都会把这个静态对象的访问权限设置为
private
。
- 由于使用者在类外部不能使用构造函数,所以在类内部创建的这个唯一的对象必须是静态 的,这样就可以通过类名来访问了,为了不破坏类的封装,我们都会把这个静态对象的访问权限设置为
-
- 类中只有它的静态成员函数才能访问其静态成员变量 ,所以可以给这个单例类提供一个静态函数 用于得到这个静态的实例对象。
- 拷贝构造函数 私有化 或者 禁用(
private
或者delete
); - 赋值操作符重载函数私有化 或者 禁用。(这个操作有没有都没影响)
单例模式的代码模板:
cpp
// 定义一个单例模式的类
class Singleton
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
Singleton(const Singleton& rhs) = delete;
Singleton& operator=(const Singleton& rhs) = delete;
static Singleton* getInstance();
private:
Singleton() = default;
static Singleton* m_obj;
};
2.单例模式
单例模式可以分为 :懒汉式 和 饿汉式。
一、懒汉式
饿汉模式就是在类加载的时候立刻进行实例化,这样就得到了一个唯一的可用对象。
定义:
cpp
// 饿汉模式 在调用 get_instance 之前 实例就已经存在了
// 多线程环境下 , 饿汉模式是线程 安全的
class TaskQueue {
public:
TaskQueue(const TaskQueue& rhs) = delete;
TaskQueue& operator = (const TaskQueue& rhs) = delete;
static TaskQueue* get_instance() {
return m_task_queue;
}
void print() {
cout << "我是单例对象 TaskQueue 的一个成员函数..." << endl;
}
private:
TaskQueue() = default;
static TaskQueue* m_task_queue;
};
TaskQueue* TaskQueue::m_task_queue = new TaskQueue;
int main()
{
TaskQueue* task_queue = TaskQueue::getInstance();
task_queue->print();
}
需要注意的是:
- 在定义这个
TaskQueue
类的时候,这个静态的单例对象m_task_queue
就已经被创建出来了,当调用TaskQueue::get_instance()
的时候,对象就已经被实例化了; - 类中的静态成员变量需要在类外初始化;
- 饿汉式在多线程环境下是线程安全的;
二、懒汉式
懒汉式是在类加载 的时候不去创建这个唯一的实例,而是在需要使用的时候再进行实例化。
定义:
cpp
// 懒汉模式 在调用 get_instance 之前 实例存在 , 第一次调用 get_instance 才会实例化对象
// 多线程环境下, 饿汉模式是线程 不安全的
class TaskQueue {
public:
TaskQueue(const TaskQueue& rhs) = delete;
TaskQueue& operator = (const TaskQueue& rhs) = delete;
static TaskQueue* get_instance() {
if (m_task_queue == nullptr) {
//在第一次调用 get_instance() 的时候再初始化
m_task_queue = new TaskQueue;
}
return m_task_queue;
}
void print() {
cout << "我是单例对象 TaskQueue 的一个成员函数..." << endl;
}
private:
TaskQueue() = default;
static TaskQueue* m_task_queue;
};
TaskQueue* TaskQueue::m_task_queue = nullptr;
int main()
{
TaskQueue* task_queue = TaskQueue::getInstance();
task_queue->print();
}
上述代码在单线程环境下是没问题的。但是在多线程环境下,就会出问题,假设多个线程同时调用 get_instance()
函数,并且此时 m_task_queue = nullptr
,那么就可能创建出多个实例,这就不符合单例模式的定义。
解决方案一:加锁(效率比较低)
我们可以使用互斥锁 mutex
将创建实例的代码锁住,第一次只有一个线程进来创建对象。
代码:
cpp
// 用 双重检测锁定 解决懒汉式多线程环境下线程不安全的问题
class TaskQueue {
public:
TaskQueue(const TaskQueue& rhs) = delete;
TaskQueue& operator = (const TaskQueue& rhs) = delete;
static TaskQueue* get_instance() {
m_mutex.lock(); //加锁
if (m_task_queue== nullptr)
{
m_task_queue= new TaskQueue;
}
m_mutex.unlock(); //解锁
return m_task_queue;
}
void print() {
cout << "我是单例对象 TaskQueue 的一个成员函数..." << endl;
}
private:
TaskQueue() = default;
static TaskQueue* m_task_queue;
static mutex m_mutex;
};
TaskQueue* TaskQueue::m_task_queue = nullptr;
mutex TaskQueue::m_mutex;
上面代码虽然解决了问题,但是 get_instance()
中的锁住的代码段,每次就只有一个线程来访问,这样效率就非常低。
解决方法二:双重检测锁定(存在问题)
双重检测锁定的思路是:在加锁和解锁代码块 之外再加一个 if
判断。这样的话,在第一次调用 get_instance()
的线程仍然会阻塞;第二次调用 get_instance()
的线程,此时 m_task_queue
已经被实例化了,也就是不为 nullptr
了,那么第二次的线程在来到一个 if
判断的时候,就直接退出了,不需要再加锁解锁,这样效率就提升了。
代码:
cpp
// 用 双重检测锁定 解决懒汉式多线程环境下线程不安全的问题
class TaskQueue {
public:
TaskQueue(const TaskQueue& rhs) = delete;
TaskQueue& operator = (const TaskQueue& rhs) = delete;
static TaskQueue* get_instance() {
//外面再加一层判断
if (m_task_queue == nullptr) {
m_mutex.lock();
if (m_task_queue == nullptr) {
m_task_queue = new TaskQueue;
}
m_mutex.unlock();
}
return m_task_queue;
}
void print() {
cout << "我是单例对象 TaskQueue 的一个成员函数..." << endl;
}
private:
TaskQueue() = default;
static TaskQueue* m_task_queue;
static mutex m_mutex;
};
TaskQueue* TaskQueue::m_task_queue = nullptr;
mutex TaskQueue::m_mutex;
实际上 双重检测锁定 的代码还是有问题的。
假设此时有两个线程 A
和 B
,线程 A
刚好要调用 m_task_queue = new TaskQueue;
这一句代码(假设此时 m_task_queue == nullptr
);而线程 B
刚好来到第一个 if
判断。
cpp
static TaskQueue* get_instance() {
//线程B 马上进入下面这个 if 判断
if (m_task_queue == nullptr) {
m_mutex.lock();
if (m_task_queue == nullptr) {
//线程A 马上调用下面这一句代码
m_task_queue = new TaskQueue;
}
m_mutex.unlock();
}
return m_task_queue;
}
对于 m_task_queue = new TaskQueue;
创建对象的这一句代码,在底层实际上时会被分成三个步骤:
- 第一步:分配内存用于存储
TaskQueue
对象; - 第二步:在分配的内存中构造一个
TaskQueue
对象(初始化内存); - 第三步:指针
m_task_queue
指向分配的内存;
由于编译器底层对我们的代码进行优化,就会将这些指令进行重排序,也就是打乱了它本来的步骤。
比如说将上述的步骤重排序之后,变成下面的:
- 第一步:分配内存用于存储
TaskQueue
对象; - 第二步:指针
m_task_queue
指向分配的内存; - 第三步:在分配的内存中构造一个
TaskQueue
对象(初始化内存);
即 第二步 和 第三步 颠倒了顺序。
指令重排序在单线程下没有问题,在多线程下就有可能出现问题。
假设线程 A
此时刚好把前两步执行完了,m_task_queue
此时已经指向一块内存了,不过对这块内存进行操作是非法操作,因为创建对象还没有完成;线程 B
此时正好,进入第一个 if
判断,此时 m_task_queue
不为 nullptr
,就直接退出,返回了没有构造完全的对象 m_task_queue
。
如果线程 B
对这个对象进行操作,就会出问题。
解决方法三:双重检测锁定 + 原子变量 (效率更低)
C++ 11 引入了 原子变量 atomic
可以解决 双重检测锁定 的问题。
代码:
cpp
// 用 原子变量 解决双重检测 的问题
class TaskQueue {
public:
TaskQueue(const TaskQueue& rhs) = delete;
TaskQueue& operator = (const TaskQueue& rhs) = delete;
static TaskQueue* get_instance() {
TaskQueue* task_queue = m_task_queue.load();
if (task_queue == nullptr) {
m_mutex.lock();
task_queue = m_task_queue.load();
if (task_queue == nullptr) {
task_queue = new TaskQueue;
m_task_queue.store(task_queue);
}
m_mutex.unlock();
}
return task_queue;
}
void print() {
cout << "我是单例对象 TaskQueue 的一个成员函数..." << endl;
}
private:
TaskQueue() = default;
//static TaskQueue* m_task_queue;
static atomic<TaskQueue*> m_task_queue;
static mutex m_mutex;
};
//TaskQueue* TaskQueue::m_task_queue = nullptr;
atomic<TaskQueue*> TaskQueue::m_task_queue;
mutex TaskQueue::m_mutex;
上面代码中使用原子变量 atomic
的 store()
函数来存储单例对象,使用 load()
函数来加载单例对象。
在原子变量中这两个函数在处理指令的时候默认的原子顺序是 memory_order_seq_cst
(即顺序原子操作 - sequentially consistent ),这样也就避免了之前的指令重排的问题,使用顺序约束原子操作库,整个函数执行都将保证顺序执行,并且不会出现数据竞态(data races),缺点就是使用这种方法实现的懒汉模式的单例执行效率更低一些。
解决方法四:静态局部变量(推荐)
在 C++ 11 直接使用 静态局部变量 在多线程环境下是不会出现问题的。
代码:
cpp
class TaskQueue
{
public:
// = delete 代表函数禁用, 也可以将其访问权限设置为私有
TaskQueue(const TaskQueue& rhs) = delete;
TaskQueue& operator=(const TaskQueue& rhs) = delete;
static TaskQueue* getInstance()
{
static TaskQueue task_queue;
return &task_queue;
}
void print()
{
cout << "我是单例对象 TaskQueue 的一个成员函数..." << endl;
}
private:
TaskQueue() = default;
};
int main()
{
TaskQueue* queue = TaskQueue::getInstance();
queue->print();
return 0;
}
之所以上面代码是线程安全的 ,是因为 C++ 11 规定了,并且这个操作是在编译时由编译器保证的:
如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。
三、总结
- 懒汉模式的缺点是在创建实例对象的时候有安全问题,但这样可以减少内存的浪费(如果用不到就不去申请内存了)。
- 饿汉模式则相反,在我们不需要这个实例对象的时候,它已经被创建出来,占用了一块内存。
四、练习
实现一个 任务队列。生产者线程生产任务加入任务队列;消费者线程取出任务队列的任务执行。
类成员:
- 存储任务的容器,我们直接使用 STL 中的容器
queue
; - 互斥锁(
mutex
),在多线程访问的情况下,用于保护共享数据;
成员函数:
- 判断任务队列是否为空;
- 往任务队列中添加一个任务;
- 往任务队列总删除一个任务;
- 从任务队列中取出一个任务
为了简单起见,我们用一个 int
数,表示一个任务。
代码:
cpp
#if 1
// 用局部静态变量饿汉式单例 实现任务队列
class TaskQueue {
public:
TaskQueue(const TaskQueue& rhs) = delete;
TaskQueue& operator = (const TaskQueue& rhs) = delete;
static TaskQueue* get_instance() {
static TaskQueue task_queue;
return &task_queue;
}
//判断任务队列是否为空
bool is_empty() {
lock_guard<mutex> locker(m_mutex);
return q.empty();
}
//删除任务
bool delete_task() {
lock_guard<mutex> locker(m_mutex);
if (q.empty()) return false;
q.pop();
return true;
}
//取出任务 (不删除任务)
int take_task() {
lock_guard<mutex> locker(m_mutex);
if (q.empty()) return -1;
return q.front();
}
//添加任务
void add_task(int task) {
lock_guard<mutex> locker(m_mutex);
q.push(task);
}
private:
TaskQueue() = default;
queue<int> q;
mutex m_mutex;
};
#endif
int main() {
TaskQueue* task_queue = TaskQueue::get_instance();
thread t1([=]() {
//生产者 t1 给任务队列添加10个任务
for (int i = 0; i < 10; i++) {
int task = i + 100;
task_queue->add_task(task);
cout << "producer thread produce a task : " << task << " , thread id is " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(500));
}
});
thread t2([=](){
//让生产者线程先执行 保证先有任务
this_thread::sleep_for(chrono::milliseconds(500));
while (!task_queue->is_empty()) {
int task = task_queue->take_task();
task_queue->delete_task();
cout << "consumer thread consume a task : " << task << " , thread id is " << this_thread::get_id() << endl;
this_thread::sleep_for(chrono::milliseconds(1000));
}
});
t1.join();
t2.join();
return 0;
}
3.参考
本篇博客是对于 :单例模式 的整理。