单例模式——C++版本

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;

实际上 双重检测锁定 的代码还是有问题的。

假设此时有两个线程 AB,线程 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;

上面代码中使用原子变量 atomicstore() 函数来存储单例对象,使用 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.参考

本篇博客是对于 :单例模式 的整理。

相关推荐
槿花Hibiscus16 分钟前
C++基础:Pimpl设计模式的实现
c++·设计模式
吾与谁归in37 分钟前
【C#设计模式(4)——构建者模式(Builder Pattern)】
设计模式·c#·建造者模式
shinelord明39 分钟前
【再谈设计模式】建造者模式~对象构建的指挥家
开发语言·数据结构·设计模式
黑不拉几的小白兔1 小时前
PTA部分题目C++重练
开发语言·c++·算法
写bug的小屁孩1 小时前
websocket身份验证
开发语言·网络·c++·qt·websocket·网络协议·qt6.3
chordful1 小时前
Leetcode热题100-32 最长有效括号
c++·算法·leetcode·动态规划
材料苦逼不会梦到计算机白富美1 小时前
线性DP 区间DP C++
开发语言·c++·动态规划
ahadee2 小时前
蓝桥杯每日真题 - 第12天
c++·vscode·算法·蓝桥杯
vortex52 小时前
解决 VSCode 中 C/C++ 编码乱码问题的两种方法
c语言·c++·vscode
醉颜凉4 小时前
【NOIP提高组】潜伏者
java·c语言·开发语言·c++·算法