设计模式学习[19]---单例模式(饿汉式/懒汉式)

文章目录

前言

我们在游戏里面使用的游戏配置,还有软件开发中打日志的类,从合理性来说,一般只会创建一个实例,给所有用户去使用。

比如我游戏中对图像精细程度,窗口比例等进行调整,这种一般只会设置一次,适配当前电脑。不可能说,我换个存档,这个显示设置就全变了吧?

还有就是打日志,其实打日志就是往文件或者控制台输出一段文本,用来提示或者警告的作用,方便程序后续出现异常定位问题,那这种情况如果我每次都创建日志对象,输出日志,再去销毁这个对象,在庞大的程序里面,这种就非常浪费性能,显然是不可取。

于是,我们期望部分类在整个软件的生命周期中,有且只有一个对象,于是就有了单例模式

1.引例

我们现在对于游戏配置来举个例子,由于是单例,所以我们并不希望GameConfig这个类在外部通过默认构造函数,拷贝构造函数,赋值构造函数等方式进行构造。同时它也不可以被外界随意释放。

下面是一个基本的单例模式的样板

cpp 复制代码
#include <iostream>

class GameConfig
{
private:
	GameConfig() {};
	GameConfig(const GameConfig& tmpobj);
	GameConfig& operator=(const GameConfig& tmpobj);
	~GameConfig() {};

public:
	static GameConfig* getInstance()
	{
		if (m_instance == nullptr)
		{
			printf("创建m_instance\n");
			m_instance = new GameConfig();
		}
		printf("返回m_instance=%p\n", m_instance);
		return m_instance;
	}

private:
	static GameConfig* m_instance;
};

GameConfig* GameConfig::m_instance = nullptr;//类外定义静态成员

int main()
{

	GameConfig* g_gc = GameConfig::getInstance();
	//GameConfig* g_gc2 = GameConfig::getInstance(); //创建不同指针获取实例,指向的是同一个对象


}

2.单例在多线程情况

对于上面的例子,如果是在多线程的情况,我们在14行,if (m_instance == nullptr),可能会出现下面的情况:

A线程在判断完m_instance为空之后,开始创建对象,但是还没创建完,此时B线程也进入到m_instance == nullptr的判断,这时候也开始创建对象。这就会导致单例失效,在程序的某些地方可能会导致难以排查的错误。

那么对于这种情况,最先想到的处理方式肯定就是加锁。

我们在类外定义一个锁std::mutex GameConfig::my_mutex,并在类内声明,同时在获取单例对象的时候,加锁,获取完后自动释放

初步改版后,代码如下:

cpp 复制代码
static GameConfig* getInstance()
	{
		std::lock_guard<std::mutex>gcguard(my_mutex);
		if (m_instance == nullptr)
		{
			printf("创建m_instance\n");
			m_instance = new GameConfig();
		}
		printf("返回m_instance=%p\n", m_instance);
		return m_instance;
	}

这种情况,看似是解决了多线程的冲突,但是我们考虑一下,如果每次获取这个对象都需要加锁解锁,如果这个获取单例对象的函数是一个热点函数,那就需要反复调用这个函数,那么频繁的加锁解锁对性能的影响是非常大的,所以直接加锁是一种比较暴力的方式,考虑还欠缺。

那有没有一种方式能够优化一下呢?

我们加锁其实只针对m_instance == nullptr这种情况有意义,因为我们加锁的是为了在初始化的时候,确保在创建对象的时候,其他线程不会创建新的GameConfig对象,始终确保这个对象只有一份,达到单例的标准。

那么,在对象创建完之后,这个对象其实就只是一个只读对象,在多线程中,对一个只读对象的访问进行加锁不但代价很大,且没有意义。所以我们考虑用一个双重锁定机制,基于这种机制的getInstance成员函数代码实现如下:

cpp 复制代码
static GameConfig* getInstance()
	{
		if (m_instance == nullptr)
		{
			//加锁
			std::lock_guard<std::mutex>gcguard(my_mutex);
			if (m_instance == nullptr)
			{
				printf("创建m_instance\n");
				m_instance = new GameConfig();
			}
		}
		printf("返回m_instance=%p\n", m_instance);
		return m_instance;
	}

这里双重锁定机制在第一个判断时,并没有加锁,而是在第二给if判断才加锁。

(1)首先,如果条件if(m_instance!=nullptr)成立,则肯定代表m_instance已经被new过了

(2)如果条件if(m_instance==nullptr)成立,不代表m_instance一定没被new过,这个在多线程一开始就说过了。

那么,在m_instance可能被new过(也可能没被new过)的情况,再去加锁。加锁后,只要这个锁能锁住,就再次判断条件if(m_instance==nullptr),如果这个条件依然满足,那么肯定表示这个单例对象还没有被初始化,这时候就可以使用new来初始化。

在new完对象之后,我们后面正常的调用,因为if(m_instance==nullptr)始终不成立,直接返回对象。因为用了双重判断的方式,所以后面返回对象前,并没有进行加锁解锁操作,这就提高了执行getInstance的效率。

难道双重判断就无敌了吗?其实也不尽然。即使加了双重锁定机制,这里也还存在潜在问题---内存访问重新排序(重新排列编译器产生的汇编指令)导致双重锁定失效的问题。

这里就走远了,具体的可以互联网上查询一下研究一下。

总之,一个好的解决多线程创建 GameConfig类对象问题的方法是:在main主函数(程序执行入口)中,在创建任何其他线程之前,先执行一次"GameConfig::getInstance();"来把这个单独的 GameConfig 类对象创建出来。这样,后续再对 GameConfig类的 getInstance 成员函数进行调用时就相当于只读取m_instance 成员变量,对getInstance 成员函数的调用就不再需要加锁了。

3.饿汉式与懒汉式

我们上面的这种实现单例的实现方式是懒汉式,因为对象的创建在我们要去获取的时候才开始第一次创建。而饿汉式就是在一开始就把对象创建好。

为什么叫这两个名字,emm,第一个就是一开始不创建对象,要用到来才创建,属于拖延式的。而第二种一开始就创建,就很饥渴,所以就叫饿汉式。emm,大体就是这么一回事~!

我们懒汉式代码上面已经编写了,下面是饿汉式的代码

cpp 复制代码
class GameConfig
{
private:
	GameConfig() {};
	GameConfig(const GameConfig& tmpobj);
	GameConfig& operator=(const GameConfig& tmpobj);
	~GameConfig() {};

public:
	static GameConfig* getInstance()
	{
		return m_instance;
	}

private:
	static GameConfig* m_instance;
};

GameConfig* GameConfig::m_instance = new GameConfig();

饿汉式的代码就不存在多线程的问题,因为一开始就把对象定义好了,getInstance函数只负责返回一个对象。

4.内存释放问题

我们的单例都是通过new的方式进行创建,所以释放的话,正常都需要手动调用一次delete,乍一看号线也挺OK的,比如我们在GameConfig这个类里面写一个成员函数

cpp 复制代码
public:
	static void freeInstance()
	{
		if(m_instance!=nullptr)
		{
			delete GameConfig::m_instance;
			GameConfig::m_instance = nullptr;
		}
	}
	

这个函数需要去手动调用一下,如果忘记了,就内存泄漏了,所以并不保险。最好的方案还是让他自动释放,所以不放看一下下面这个方案。

通过在GameConfig中放一个Garbo类(嵌套类),将单例对象的生命周期和Garbo捆绑到一起

cpp 复制代码
class Garbo
{
public:
	~Garbo()
	{
		if (GameConfig::m_instance != nullptr)
		{
			delete GameConfig::m_instance;
			GameConfig::m_instance = nullptr;
		}
	}
};

如果是饿汉式模式

在类GameConfig定义中,增加一个private修饰的静态成员变量:

cpp 复制代码
private:
	static Garbo garboobj;

在类外,cpp源文件的开头位置,对上述静态成员进行定义:

cpp 复制代码
GameConfig::Garbo GameConfig::garboobj;

在释放garboobj的时候,会自动调用其析构函数,同时将m_instance也一同删除并释放内存。

如果是懒汉式

则意味着如果不调用getInstance成员函数,则单件类对象不会被new出来,自然也就不需要释放。具体的实现代码相对简单,只需要在getInstance 成员函数中的new语句行下面增加一个局部静态变量定义即可:

cpp 复制代码
static GameConfig* getInstance()
{
	if (m_instance == nullptr)
	{
		printf("创建m_instance\n");
		m_instance = new GameConfig();
		static Garbo garboobj;
	}
	printf("返回m_instance=%p\n", m_instance);
	return m_instance;
}

经过上述步骤后,如果程序调用过 g e t I n s t a n c e getInstance getInstance为单件类分配了内存,自然也就相当于 g a r b o o b j garboobj garboobj局部静态变量被构造出来了,该局部静态变量所分配的内存会在程序执行结束前由操作系统回收,该回收动作会导致garboobj所属类Garbo析构函数的执行,在该析构函数中,正好释放单件类对象。

总结

单例模式是常用的一种设计模式,这里对于饿汉式和懒汉式的选择,其实我更倾向于饿汉式,这样就避免了多线程问题。本篇博客还介绍了一种垃圾回收的方式,通过类对象的析构来自动回收,这样避免了忘记释放导致内存泄漏的问题。

最后再贴一下UML类图

相关推荐
翰霖努力成为专家1 小时前
STM32,新手学习
stm32·嵌入式硬件·学习
Nan_Shu_6142 小时前
学习:uniapp全栈微信小程序vue3后台 (24)
前端·学习·微信小程序·小程序·uni-app
9毫米的幻想2 小时前
【Linux系统】—— 进程切换&&进程优先级&&进程调度
linux·运维·服务器·c++·学习·算法
bkspiderx2 小时前
C++设计模式之创建型模式:工厂方法模式(Factory Method)
c++·设计模式·工厂方法模式
半夏知半秋2 小时前
skynet.dispatch与skynet.register_protocol
笔记·后端·学习·安全架构
yujkss2 小时前
23种设计模式之【工厂方法模式】-核心原理与 Java实践
java·设计模式·工厂方法模式
风语者日志2 小时前
创建者模式:工厂方法模式
java·设计模式
月盈缺2 小时前
学习嵌入式的第四十天——ARM
学习
CIb0la2 小时前
介绍一套体系化的工作流程或学习方法:标准化输出
运维·笔记·学习·学习方法