【设计模式】单例模式的前世今生

文章目录

    • 引言
    • 简介
    • [起航!向"确保某个类在系统中只有一个实例"进发 ⛵️](#起航!向“确保某个类在系统中只有一个实例”进发 ⛵️)
      • [Lazy Singleton](#Lazy Singleton)
      • [Double-checked locking(DCL) Singleton](#Double-checked locking(DCL) Singleton)
      • [Volatile Singleton](#Volatile Singleton)
      • [Atomic Singleton](#Atomic Singleton)
      • [Meyers Singleton](#Meyers Singleton)
    • 附:C++静态对象的初始化

引言

说起单例模式,我想,即便屏幕前的你此前没有系统学习过设计模式,也应该听说过它的大名。

但是,这篇文章的重点不是去聊这个模式在实际生产过程中怎么用,而是想聊一下这个模式发展的历史。如果你的目的是想了解其具体用法,你可以在检索一下其他人写的总结,再往下看的话,可能不会有你想要的答案。

简介

在软件系统中,经常有一些特殊的类,必须保证它们在系统中只存在一个实例,才能确保它们的逻辑正确性以及良好的效率。

单例模式是一种设计模式,其核心目的是确保某个类在系统中只有一个实例,并提供一个全局访问点来访问这个实例。

"确保某个类在系统中只有一个实例"------这个目的听起来似乎很简单,不要觉得荒谬,某些特定的情况下,我们的系统中确实只需要某个类的一个实例就可以了,这样既能满足实际使用场景,又能减少内存开销,避免资源的多重占用,提升性能。

倘若我们从这个目的出发------"确保某个类在系统中只有一个实例",现在的任务就是:设计某种手段以达到我们的目的。

起航!向"确保某个类在系统中只有一个实例"进发 ⛵️

也许,刚看到这个目标的时候你会有点疑惑:这不是很简单吗?既然你想要确保系统中只有一个某个类的对象,那我就只创建一个对象不就好了吗?

听起来好像没错,但是"确保某个类在系统中只有一个实例",这应该是类设计者的责任,而不是使用者的责任。

现在,让我们从类设计者的角度重新审视这个问题。

我们知道,创建类的实例------这个动作是借由类的构造函数完成的,换句话说,我们可以确定问题的突破点是在构造函数身上。那么,如何绕过常规的构造器,提供一种机制来保证一个类只有一个实例呢?

首先,我们先解决构造函数的权限问题。C++中的权限说起来一共有三种:public,protect,private。而无论对于用户还是派生类来讲,真正的权限事实上只有两种:

  • 对于用户而言,public权限是可访问的,private权限和protect权限是不可访问的;
  • 对于派生类而言,private是不可访问的,protect与public是可访问的;

而如果将这个类的构造函数用public去修饰,意味着用户可以随意创建对象,"创建对象"这个动作无法受到我们的管控,因此,如果想要限制用户"不那么自由"的创建实例,我们应当将构造函数声明为private:

c++ 复制代码
class Singleton{
  private:
  	Singleton();//私有构造函数
  	static Singleton* m_instance;
  public:
  	static Singleton* getInstance();//全局访问点
}
Singleton* Singleton::m_instance = NULL;

Lazy Singleton

那么如何"确保某个类在系统中只有一个实例"?很容易想到:

c++ 复制代码
1 Singleton* Singleton::getInstance(){
2  if(m_instance == nullptr){
3    	m_instance = new Singleton();
4  }
5  return m_instance;
6 }

懒汉版(Lazy Singleton):单例实例在第一次被使用时才进行初始化,这叫做延迟初始化,也叫做懒加载。

Lazy Singleton存在内存泄露的问题,这里有两种解决方法:

  1. 使用智能指针
  2. 使用静态的嵌套类对象

对于第二种解决方法,代码如下:

c++ 复制代码
// version 1.1
class Singleton
{
private:
    static Singleton* instance;
private:
    Singleton() { };
    ~Singleton() { };
    Singleton(const Singleton&);
    Singleton& operator=(const Singleton&);
private:
    class Deletor {
    public:
        ~Deletor() {
            if(Singleton::instance != NULL)
                delete Singleton::instance;
        }
    };
    static Deletor deletor;
public:
    static Singleton* getInstance() {
        if(instance == NULL) {
            instance = new Singleton();
        }
        return instance;
    }
};

// init static member
Singleton* Singleton::instance = NULL;

在程序运行结束时,系统会调用静态成员deletor的析构函数,该析构函数会删除单例的唯一实例。使用这种方法释放单例对象有以下特征:

  • 在单例类内部定义专有的嵌套类。
  • 在单例类内定义私有的专门用于释放的静态成员。
  • 利用程序在结束时析构全局变量的特性,选择最终的释放时机。

这是一个简单的实现版本,"有条件" 的完成了我们的目标,因为这个版本只能针对于单线程下的程序,是个"线程非安全"版本,一旦线程数大于1,这个版本将不再起作用。

假设现在有两个线程:thread A与thread B。

thread A 执行完第2行,还没来得及执行第3行时,thread B 抢到了时间片,由于此时的m_instance仍为空,因此thread也能进入if分支,然后m_instance就被创建了两次。

有没有什么办法能够快速修复这个"bug"呢?

Double-checked locking(DCL) Singleton

很自然的,你会想到加锁:

c++ 复制代码
1 Singleton* Singleton::getInstance(){
2  Lock lock;
3  if(m_instance == nullptr){
4    	m_instance = new Singleton();
5  }
6  return m_instance;
7 }

如你所愿,我们在这个版本里加了一个锁,再遇到上述场景时,由于thread A抢到了锁并且还没释放,因此,thread A能正常创建实例,并且当thread A出了函数体释放了锁之后,thread B 进入函数体,由于此时m_instance已经被创建,因此并不会被创建两次。

问题解决了吗?

按照上面的分析,好像是的。但是,你有没有注意到当实例已经被创建后的场景?

假设实例m_instance已经被创建,在之后的场景中,程序再次进入该函数时,都会先创建锁,然后判断m_instance是否为空,然后返回。每次进入函数体都会创建锁,但是这个锁只有第一次才有真正的作用,之后都是在浪费资源。

这个版本能够保证线程安全,但是锁的代价过高。

还有没有改进版本呢?

于是,双检查锁版本诞生了:

C++ 复制代码
1 Singleton* Singleton::getInstance(){
2 if(m_instance == nullptr){
3  	Lock lock; 基于作用域的加锁,超出作用域,自动调用析构函数解锁
4  	if(m_instance == nullptr){
5    		m_instance = new Singleton();
6  	}
7 }
8  	return m_instance;
9 }

之前的版本是不管三七二十一,都加锁,现在的版本是进入函数体之后,先问一次m_instance是不是空,根据结果去决定是否加锁。规避了上一个版本锁的代价过高的问题。

有的小伙伴可能会在这里犯迷糊:认为第二个if分支没有必要,即可以删去第4行。

事实上,如果删去了第4行,那么情况就会变得跟第一个版本一模一样,只要线程能同时通过第2行的检查,那么这个实例就有被创建多次的可能。就算此时加了这个锁,无非也就是多等一会儿,没有其他作用。

这个版本看起来很完美,问题似乎已经被我们解决了!

但是我要告诉你,这个版本在很长一段时间内迷惑了很多人,包括一些专家都认为这个版本已经达到目标了。直到2000年左右,Java领域的某些研究者才发现有问题,而且很快在几乎所有的语言领域都发现这种实现有漏洞。由于内存读写reorder不安全,会导致双检查锁失效。

怎么样的一个失效问题呢?

让我们将目光聚焦到这行代码上:

c++ 复制代码
m_instance = new Singleton();

这行代码最终会被编译器编译成一段指令序列,线程是在指令层次抢时间片的。但是这个指令有时候跟我们的假设不一样。

比如上面那行代码通常情况下到了指令层次之后,可以划分为三个动作:

  1. 分配一片内存;
  2. 在这片内存上执行初始化操作;
  3. 将得到的内存地址赋值给m_instance;

是这三个动作没错,但是到了指令层面之后,它们的顺序却可能由于编译器优化而被打乱成下面这样:

  1. 分配一片内存;
  2. 将最后得到的内存地址赋值给m_instance;
  3. 在这片内存上执行初始化操作;

看到了吗?第二步和第三步的顺序可能会被颠倒!

c++ 复制代码
1 Singleton* Singleton::getInstance(){
2 if(m_instance == nullptr){
3  	Lock lock;
4  	if(m_instance == nullptr){
5    		m_instance = new Singleton();
6  	}
7 }
8  	return m_instance;
9 }

现在再次回到之前的场景,假设有两个thread,thread A执行第5步之后,由于编译器优化而执行了:

  1. 分配一片内存;
  2. 将最后得到的内存地址赋值给m_instance;

第三步还没来得及执行,时间片就被thread B抢走了,由于此时m_instance已经被赋予了地址,因此m_instance不再为空!当thread B再次进入函数体之后,由于第2步判断m_instance是否为空的结果为false,导致被直接返回。而事实上m_instance并没有完成初始化操作,此时还不能使用。

当这个问题被发现后,由于是编译器优化导致了此类问题的出现,于是人们敦促编译器厂商给出问题解决方案。

Volatile Singleton

反过来想想,编译器优化的目的是提升程序性能,只是不巧导致了这个问题的出现,如果为了一个单例模式的实现直接禁止这种优化,属实有点说不过去。这个时候java和C#就很聪明,在各自的语言中加了一个关键字:Volatile,其作用也很直截了当:禁止指令重排。

C++呢?Visual C++嫌标准委员会动作太慢,2005年左右,在自家编译器里也加入了volatile关键字,但是由于是个人行为,很显然不能跨平台。之后C++11正式将volatile作为关键字纳入标准:

C++ 复制代码
class Singleton {  
public:  
    static Singleton* instance() {  
        if (pInstance == 0) {  
            Lock lock;  
            if (pInstance == 0) {  
                pInstance = new Singleton;  
            }  
        }  
        return pInstance;  
    }  
private:  
    static Singleton * volatile pInstance;  
    Singleton(){  
    }  
};  

volatile这个关键字有两层语义:

第一层语义是可见性。可见性指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory),所以会马上反应在其它线程的读取操作中,即看到的都是最新的结果。

第二层语义是禁止指令重排序优化。我们写的代码(尤其是多线程代码),由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。

Atomic Singleton

另外在C++11 将原子操作纳入了标准,我们可以通过标准提供的原子操作来处理该问题。

通过给原子变量设置 std::std::memory_order_xxx 来防止 CPU 的指令重排操作。

c++ 复制代码
//C++11版本之后的跨平台实现(volatile)

std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance(){
	Singleton* tmp = m_instance.load(std::memory_order_relaxed);
	std::atomic_thread_fence(std::memory_order_acquire);//获取内存fence
	if(tmp == nullptr){
		std::lock_guard<std::mutex> lock(m_mutex);
		tmp = m_instance.load(std::memory_order_relaxed);
		if(tmp == nullptr){
			tmp = new Singleton;
			std::atomic_thread_fence(std::memory_order_relaced);//释放内存fence
			m_instance.store(tmp,std::memory_order_relaxed);
		}
	}
	return tmp;
}

Meyers Singleton

《Effective C++》的作者Meyer,在<<Effective C++>>3rd Item4中,提出了一种到目前为止最简洁高效的解决方案:

c++ 复制代码
template<typename T>
class Singleton
{
public:
    static T& getInstance()
    {
        static T value;
        return value;
    }
 
private:
    Singleton();
    ~Singleton();
};

非常优雅的一种实现。

先说结论:

  • 单线程下,正确。
  • C++11及以后的版本(如C++14)的多线程下,正确。
  • C++11之前的多线程下,不一定正确。

原因在于在C++11之前的标准中并没有规定local static变量的内存模型,所以很多编译器在实现local static变量的时候仅仅是进行了一次check(参考《深入探索C++对象模型》),于是getInstance函数被编译器改写成这样了:

C++ 复制代码
bool initialized = false;
char value[sizeof(T)];
 
T& getInstance()
{
    if (!initialized)
    {
       initialized = true;
       new (value) T();
    }
    return *(reinterpret_cast<T*>(value));
}

于是乎它就是不是线程安全的了。

但是在C++11却是线程安全的,这是因为新的C++标准规定了当一个线程正在初始化一个变量的时候,其他线程必须得等到该初始化完成以后才能访问它。

附:C++静态对象的初始化

non-local static对象(函数外)

C++规定,non-local static 对象的初始化发生在main函数执行之前,也即main函数之前的单线程启动阶段,所以不存在线程安全问题。但C++没有规定多个non-local static 对象的初始化顺序,尤其是来自多个编译单元的non-local static对象,他们的初始化顺序是随机的。

local static 对象(函数内)

对于local static 对象,其初始化发生在控制流第一次执行到该对象的初始化语句时。多个线程的控制流可能同时到达其初始化语句。

在C++11之前,在多线程环境下local static对象的初始化并不是线程安全的。具体表现就是:如果一个线程正在执行local static对象的初始化语句但还没有完成初始化,此时若其它线程也执行到该语句,那么这个线程会认为自己是第一次执行该语句并进入该local static对象的构造函数中。这会造成这个local static对象的重复构造,进而产生内存泄露问题。所以,local static对象在多线程环境下的重复构造问题是需要解决的。

而C++11则在语言规范中解决了这个问题。C++11规定,在一个线程开始local static 对象的初始化后到完成初始化前,其他线程执行到这个local static对象的初始化语句就会等待,直到该local static 对象初始化完成。

相关推荐
理想不理想v1 分钟前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云11 分钟前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
aPurpleBerry1 小时前
JS常用数组方法 reduce filter find forEach
javascript
ZL不懂前端2 小时前
Content Security Policy (CSP)
前端·javascript·面试
乐闻x2 小时前
ESLint 使用教程(一):从零配置 ESLint
javascript·eslint
我血条子呢2 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
半开半落2 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt
理想不理想v3 小时前
vue经典前端面试题
前端·javascript·vue.js
小阮的学习笔记3 小时前
Vue3中使用LogicFlow实现简单流程图
javascript·vue.js·流程图