设计模式 —— 单例模式

“对象性能”模式

面向对象很好的解决了“抽象”的问题,但是不可避免付出一定代价,如虚函数。通常情况,面向对象的成本可忽略不计。但是某些情况,面向对象所带来的成本必须谨慎处理。

典型模式

  • 单件模式
  • 享元模式

单例模式

动机

  • 在软件系统中,经常有一些特殊的类,必须保证它们在系统中只存在一个实例,才能确保它们的逻辑正确性以及效率。
  • 如何绕过常规的构造器,提供一种机制来保证一个类只有一个实例?
  • 以上要求应该是类设计者的责任,而非使用的责任。

如何实现:

 1 class Singleton{
 2 private:
 3     Singleton();
 4     Singleton(const Singleton& other);
 5 public:
 6     static Singleton* getInstance();
 7     static Singleton* m_instance;
 8 };
 9 
10 Singleton* Singleton::m_instance=nullptr;
11 
12 //线程非安全版本
13 Singleton* Singleton::getInstance() {
14     if (m_instance == nullptr) {
15         m_instance = new Singleton();
16     }
17     return m_instance;
18 }

首先显示声明构造函数与拷贝构造函数为私有的,避免外界调用。
其中静态方法getInstance(),在第一次调用时,条件为真,创建对象,后面再调用时,判断条件不成立,就一直只有一个对象。该方法在单线程环境下是安全的。但是在多线程条件下,就不安全。例如在ThreadA执行完14行还未执行15行new,此时ThreadB分配到时间片,执行14行也会进入到执行15行,所以多线程环境下对象可能会被创建多次。

那么如何解决?有以下思路:

加锁

1 //线程安全版本,但锁的代价过高
2 Singleton* Singleton::getInstance() {
3     Lock lock;
4     if (m_instance == nullptr) {
5         m_instance = new Singleton();
6     }
7     return m_instance;
8 }

虽然能保证线程安全,但是锁的代价太高。分析如下:
假设线程A已经进到行4,此时线程B分到时间片,想要调用,到第3行就会不成立。如果对象已经创建,每次执行if判断都不会进入new,所以整个调用都是在判断与返回,即是在读变量m_instance,而对于读操作,是不需要加锁的。所有此时的锁会造成浪费(比如等待,锁自己也是种资源),尤其是在高并发的场景下。于是有了双检查锁:

 1 //双检查锁,锁前检查锁后检查
 2 Singleton* Singleton::getInstance() {
 3     if(m_instance == nullptr){
 4         Lock lock;
 5         if(m_instance == nullptr) {
 6             m_instance = new Singleton();
 7         }
 8     }
 9     return m_instance;
10 }

锁前检查是为了让线程判断到对象已创建时,不用访问锁,并直接返回,这样就减少开销。在锁后检查是为了当两线程都进入第一个 if(m_instance == nullptr) 后,防止多次创建对象。

但是,双检查锁会由于内存读写reorder而失效
通常情况,代码编译时会生成指令序列,且会认为会按照指令序列执行。代码是以指令形式来抢占CPU的时间片的。以第6行为例:m_instance = new Singleton();
 我们假设该语句会有以下几个部分:
  Step1 :先分配内存
  Step2 :调用SingleTon的构造器并对内存进行初始化
  Step3 :把指向内存的指针赋值给m_instance


以上三个步骤是人认为的,但经编译器优化,CPU有可能会reorder,如下:
  Step1 :先分配内存
  Step2 :把指向内存的指针赋值给m_instance
  Step3 :调用SingleTon的构造器并对内存进行初始化

那么就可能出现这种情况:

线程A执行到6,先分配内存,在将指向内存的指针赋给m_instance,此时轮到线程B,线程B判断到m_instance不为空,就直接返回对象,但是此时该对象还没有被构造。
所有的编译器,如果不对双检查锁的reorder漏洞处理,不能使用双检查锁,出错的概率很高。
对于Java,C#语言可以采用volatile处理,C++只有在11后才有解决方案:

 1 std::atomic<Singleton*> Singleton::m_instance;
 2 std::mutex Singleton::m_mutex;
 3 
 4 Singleton* Singleton::getInstance() {
 5     Singleton* tmp = m_instance.load(std::memory_order_relaxed);
 6     std::atomic_thread_fence(std::memory_order_acquire);
 7     if(m_instance == nullptr){
 8         std::lock_guard<std::mutex> lock(m_mutex);
 9         tmp = m_instance.load(std::memory_order_relaxed);
10         if(m_instance == nullptr) {
11             m_instance = new Singleton();
12     std::atomic_thread_fence(std::memory_order_release);
13     m_instance.store(tmp, std::memory_order_relaxed);
14 
15         }
16     }
17     return tmp;
18 }

要点总结

  • 单例模式中的实例构造器可以设置为protected以允许子类派生。
  • 单例模式一般不要支持拷贝构造和clone接口,避免导致多个实例对象
  • 如何实现多线程环境下的安全的单例模式,注意双检查锁的实现。

更多更详细的单例模式实现见:C++设计模式——单例模式

原文地址:https://www.cnblogs.com/y4247464/p/15472044.html