• 单例模式——C++版本


    1.什么是单例模式

    在一个项目中,全局范围内,某个类的实例有且仅有一个,通过这个唯一实例向其他模块提供数据的全局访问,这种模式就叫单例模式。

    类中多对象的操作函数有如下几个:

    • 构造函数 : 能够创建出一个新对象;
    • 拷贝构造函数 :能够根据一个已经存在的对象拷贝出一个新对象;
    • 赋值操作符重载函数 :用一个对象给另一个对象赋值;

    为了使得类全局只有一个实例,我们需要对这些函数做一些处理

    • 构造函数私有化,且在类内部只被调用一次;
      • 由于使用者在类外部不能使用构造函数,所以在类内部创建的这个唯一的对象必须是静态的,这样就可以通过类名来访问了,为了不破坏类的封装,我们都会把这个静态对象的访问权限设置为 private
      • 类中只有它的静态成员函数才能访问其静态成员变量,所以可以给这个单例类提供一个静态函数用于得到这个静态的实例对象
    • 拷贝构造函数 私有化 或者 禁用(private 或者 delete);
    • 赋值操作符重载函数私有化 或者 禁用。(这个操作有没有都没影响)

    单例模式的代码模板:

    // 定义一个单例模式的类
    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;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    2.单例模式

    单例模式可以分为 :懒汉式饿汉式

    一、懒汉式

    饿汉模式就是在类加载的时候立刻进行实例化,这样就得到了一个唯一的可用对象。

    定义:

    // 饿汉模式 在调用 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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    需要注意的是:

    • 在定义这个 TaskQueue 类的时候,这个静态的单例对象 m_task_queue 就已经被创建出来了,当调用 TaskQueue::get_instance() 的时候,对象就已经被实例化了;
    • 类中的静态成员变量需要在类外初始化
    • 饿汉式在多线程环境下是线程安全的

    二、懒汉式

    懒汉式是在类加载的时候不去创建这个唯一的实例,而是在需要使用的时候再进行实例化

    定义:

    // 懒汉模式 在调用 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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    上述代码在单线程环境下是没问题的。但是在多线程环境下,就会出问题,假设多个线程同时调用 get_instance() 函数,并且此时 m_task_queue = nullptr,那么就可能创建出多个实例,这就不符合单例模式的定义。

    解决方案一:加锁(效率比较低)

    我们可以使用互斥锁 mutex 将创建实例的代码锁住,第一次只有一个线程进来创建对象。

    代码:

    // 用 双重检测锁定 解决懒汉式多线程环境下线程不安全的问题
    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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    上面代码虽然解决了问题,但是 get_instance() 中的锁住的代码段,每次就只有一个线程来访问,这样效率就非常低

    解决方法二:双重检测锁定(存在问题)

    双重检测锁定的思路是:在加锁和解锁代码块 之外再加一个 if 判断。这样的话,在第一次调用 get_instance() 的线程仍然会阻塞;第二次调用 get_instance() 的线程,此时 m_task_queue 已经被实例化了,也就是不为 nullptr 了,那么第二次的线程在来到一个 if 判断的时候,就直接退出了,不需要再加锁解锁,这样效率就提升了。

    代码:

    // 用 双重检测锁定 解决懒汉式多线程环境下线程不安全的问题
    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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

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

    假设此时有两个线程 AB,线程 A 刚好要调用 m_task_queue = new TaskQueue; 这一句代码(假设此时 m_task_queue == nullptr);而线程 B 刚好来到第一个 if 判断。

    	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;
    	}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    对于 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 可以解决 双重检测锁定 的问题。

    代码:

    // 用 原子变量 解决双重检测 的问题
    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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    上面代码中使用原子变量 atomicstore() 函数来存储单例对象,使用 load() 函数来加载单例对象。

    在原子变量中这两个函数在处理指令的时候默认的原子顺序是 memory_order_seq_cst即顺序原子操作 - sequentially consistent),这样也就避免了之前的指令重排的问题,使用顺序约束原子操作库,整个函数执行都将保证顺序执行,并且不会出现数据竞态(data races),缺点就是使用这种方法实现的懒汉模式的单例执行效率更低一些

    解决方法四:静态局部变量(推荐)

    在 C++ 11 直接使用 静态局部变量 在多线程环境下是不会出现问题的。

    代码:

    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    之所以上面代码是线程安全的 ,是因为 C++ 11 规定了,并且这个操作是在编译时由编译器保证的:

    如果指令逻辑进入一个未被初始化的声明变量,所有并发执行应当等待该变量完成初始化。

    三、总结

    • 懒汉模式的缺点是在创建实例对象的时候有安全问题,但这样可以减少内存的浪费(如果用不到就不去申请内存了)。
    • 饿汉模式则相反,在我们不需要这个实例对象的时候,它已经被创建出来,占用了一块内存。

    四、练习

    实现一个 任务队列。生产者线程生产任务加入任务队列;消费者线程取出任务队列的任务执行。

    类成员:

    • 存储任务的容器,我们直接使用 STL 中的容器 queue
    • 互斥锁(mutex),在多线程访问的情况下,用于保护共享数据;

    成员函数:

    • 判断任务队列是否为空;
    • 往任务队列中添加一个任务;
    • 往任务队列总删除一个任务;
    • 从任务队列中取出一个任务

    为了简单起见,我们用一个 int 数,表示一个任务。

    代码:

    #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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80

    3.参考

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

  • 相关阅读:
    <爬虫部署,进阶Docker>----第一章 介绍Docker
    uniapp如何上传文件,使用API是什么
    产品关键词该怎么优化改进?
    java基础题——二维数组的基本应用2
    CVE-2022-39197(CobaltStrike XSS <=4.7)漏洞复现
    Java学习笔记(二十九)
    【牛客-剑指offer-数据结构篇】JZ52 两个链表的第一个公共节点 两种思路 Java实现
    基于IOS音乐播放器在线歌词同步小程序系统(音乐小程序)
    C++关键字decltype
    毕业设计-基于机器学习的二维码和条形码识别
  • 原文地址:https://blog.csdn.net/m0_74396439/article/details/132639497