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

文章目录

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

引言

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

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

简介

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

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

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

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

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

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

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

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

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

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

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

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

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

Lazy Singleton

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

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. 使用静态的嵌套类对象

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

// 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

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

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是否为空,然后返回。每次进入函数体都会创建锁,但是这个锁只有第一次才有真正的作用,之后都是在浪费资源。

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

还有没有改进版本呢?

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

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不安全,会导致双检查锁失效。

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

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

m_instance = new Singleton();

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

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

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

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

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

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

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作为关键字纳入标准:

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++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);//获取内存fenceif(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);//释放内存fencem_instance.store(tmp,std::memory_order_relaxed);}}return tmp;
}

Meyers Singleton

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

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函数被编译器改写成这样了:

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 对象初始化完成。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/7350.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

华为OD机试 - 分月饼 - 递归(Java 2024 C卷 200分)

华为OD机试 2024C卷题库疯狂收录中&#xff0c;刷题点这里 专栏导读 本专栏收录于《华为OD机试&#xff08;JAVA&#xff09;真题&#xff08;A卷B卷C卷&#xff09;》。 刷的越多&#xff0c;抽中的概率越大&#xff0c;每一题都有详细的答题思路、详细的代码注释、样例测试…

apache基于IP和端口的虚拟主机

基于IP虚拟机主机 vim /etc/httpd/conf/httpd.conf添加监听IP Listen 192.168.0.1:80 Listen 192.168.0.10:80添加虚拟主机和发布目录 第一台虚拟主机 <VirtualHost 192.168.0.1:80>ServerAdmin www.123.comDocumentRoot /webroot/192.168.0.1ErrorLog logs/192.168.…

Splay 树简介

【Splay 树简介】 ● Treap 树解决平衡的办法是给每个结点加上一个随机的优先级&#xff0c;实现概率上的平衡。Splay 树直接用旋转调整树的形态&#xff0c;通过旋转改善树的平衡性。计算量小&#xff0c;效果好。 ● Splay 树的旋转主要分为“单旋”和“双旋”。 所谓“单旋”…

代码审计之浅谈RASP技术

前言&#xff1a; 想摆会烂&#xff0c;所以就落个笔吧。 其实本来是想写关于iast技术的&#xff0c;但是认真思考了下&#xff0c;感觉笔者自己本身也不太能讲清楚iast技术&#xff0c;怕误人子弟。 所以最后还是基于笔者的理解以及实际应用写一篇关于RASP技术的文章&#xf…

强化学习:时序差分法【Temporal Difference Methods】

强化学习笔记 主要基于b站西湖大学赵世钰老师的【强化学习的数学原理】课程&#xff0c;个人觉得赵老师的课件深入浅出&#xff0c;很适合入门. 第一章 强化学习基本概念 第二章 贝尔曼方程 第三章 贝尔曼最优方程 第四章 值迭代和策略迭代 第五章 强化学习实例分析:GridWorld…

软件游戏丢失XINPUT1_4.dll文件的多种解决方法分享

当玩家在尝试启动某款游戏时&#xff0c;遇到了系统提示“游戏找不到XINPUT1_4.dll”&#xff0c;这个错误通常发生在玩家尝试启动游戏时&#xff0c;游戏无法找到所需的XINPUT1_4.dll文件&#xff0c;呆滞无法正常启动运行。但是幸运的是&#xff0c;有一些简单的修复方法可以…

自制AI:Park_01修改bug

修改了一下不能存东西&#xff0c;不能打开东西的bug #include<bits/stdc.h> #include<windows.h> using namespace std; double mem10737418240; map<string,string> jishiben; string mulu"朴同学给你的一封信.memo\n"; int cntnote1; void sta…

软件测试与管理:黑盒测试-因果图法和场景法

知识思维导图&#xff1a; ​​​​​​​ 例题1&#xff1a;运用因果图法设计测试用例 有一个处理单价为5角钱的饮料的自动售货机软件测试用例的设计。其规格说明如下&#xff1a; 若投入5角钱或1元钱的硬币&#xff0c;按下〖橙汁〗或〖啤酒〗的按钮&#xff0c;则相应的饮料…

代码随想录算法训练营第四十六天|LeetCode139.完全平方数

LeetCode 139 完全平方数 题目链接&#xff1a;139. 单词拆分 - 力扣&#xff08;LeetCode&#xff09; 【解题思路】 1.确定dp数组以及下标含义 dp[j]的定义是&#xff1a; 字符串长度为i的话&#xff0c;dp[i]为true&#xff0c;表示可以拆分为一个或多个在字典中出现的单词…

[方法] Unity 实现仿《原神》第三人称跟随相机 v1.1

参考网址&#xff1a;【Unity中文课堂】RPG战斗系统Plus 在Unity游戏引擎中&#xff0c;实现类似《原神》的第三人称跟随相机并非易事&#xff0c;但幸运的是&#xff0c;Unity为我们提供了强大的工具集&#xff0c;其中Cinemachine插件便是实现这一目标的重要工具。Cinemachi…

4步快速配置Java和MySQL环境

每次入职一家新公司或者用一台其他的临时电脑或者新电脑时都要重新配置Java开发环境&#xff0c;很麻烦&#xff0c;因此我在这里记录一下快速配置环境的方式&#xff0c;四步搞定&#xff01;此处以win为操作系统进行讲解。 第一步&#xff1a;下载链接 下载链接&#xff1a…

二叉树1:二叉树的基本运算

1. (简答题) 编写一个程序&#xff0c;实现二叉树的基本运算&#xff0c;具体要求如下&#xff1a;&#xff08;指定示范实例1&#xff1a;P243图7.34。指定示范实例2&#xff1a;P201图7.13 &#xff09; 1&#xff0c;括号表示法输出该树。 2&#xff0c;输入一个结点的值&…

04.1.添加多个监控同步其他主机

添加多个监控&同步其他主机 1.首先在agent配置文件中存在Include的&#xff0c;也就是说明&#xff0c;可以配置多个监控项并且同步到其他主机上的进行使用&#xff1b; 2.主机之间互相推送配置文件即可&#xff1b; 开始测试 我这里实在agent节点上直接在路径/etc/zabbi…

Go实现树莓派控制舵机

公式说明 毫秒&#xff08;ms&#xff09;是时间的单位&#xff0c;赫兹&#xff08;Hz&#xff09;是频率的单位&#xff0c;而DutyMax通常是一个PWM&#xff08;脉冲宽度调制&#xff09;信号中表示最大占空比的值。以下是它们之间的关系和一些相关公式&#xff1a; 频率&…

Python内置函数min()详解

Python中的min()函数是一个内置函数&#xff0c;用于找出给定参数或可迭代对象中的最小值。 函数定义 min()函数可以有两种不同的使用方式&#xff1a; min(iterable, *[, defaultobj, keyfunc]) min(arg1, arg2, *args[, keyfunc])iterable&#xff1a;一个可迭代对象。def…

设计模式之建造者模式BuilderPattern(七)

一、建造者模式 建造者模式&#xff08;Builder Pattern&#xff09;使用多个简单的对象一步一步构建成一个复杂的对象。这种类型的设计模式属于创建型模式&#xff0c;它提供了一种创建对象的最佳方式。 二、代码实例 1、OrderItem类 Data&#xff1a;这是Lombok中提供的Ge…

【linux软件基础知识】-cdev_alloc

struct cdev *cdev_alloc(void) {struct cdev *p = kzalloc(sizeof(struct cdev), GFP_KERNEL);if <

并发编程陷阱:32位CPU下long写操作的线程安全漏洞

1. 现象描述 1.1 Bug问题简述 在多线程环境下操作共享数据时&#xff0c;往往面临各种并发问题。其中&#xff0c;一种常见的情况是&#xff0c;即使一段代码在单线程下执行没有问题&#xff0c;当它在多线程环境下执行时&#xff0c;却可能由于线程安全问题导致意想不到的Bu…

ADS过孔---过孔建模自动化

当前快速建模的方法有两类&#xff1a;一是脚本自动化&#xff0c;也就是今天要分享的方法&#xff0c;但该方法需要工程师有基本的脚本编辑能力&#xff0c;然后根据自己的需要去修改&#xff0c;难度较大一点点&#xff1b;二是参数化建模&#xff0c;也就是在GUI界面输入相应…

百度语音识别开发笔记

目录 简述 开发环境 1、按照官方文档步骤开通短语音识别-普通话 2、创建应用 3、下载SDK 4、SDK集成 5、相关接口简单说明 5.1权限和key 5.2初始化 5.3注册回调消息 5.4开始转换 5.5停止转换 6、问题 简述 最近想做一些语音识别的应用&#xff0c;对比了几个大厂…