设计模式学习[19]---单例模式(饿汉式/懒汉式)
文章目录
- 前言
- 1.引例
- 2.单例在多线程情况
- 3.饿汉式与懒汉式
- 4.内存释放问题
- 总结
前言
我们在游戏里面使用的游戏配置,还有软件开发中打日志的类,从合理性来说,一般只会创建一个实例,给所有用户去使用。
比如我游戏中对图像精细程度,窗口比例等进行调整,这种一般只会设置一次,适配当前电脑。不可能说,我换个存档,这个显示设置就全变了吧?
还有就是打日志,其实打日志就是往文件或者控制台输出一段文本,用来提示或者警告的作用,方便程序后续出现异常定位问题,那这种情况如果我每次都创建日志对象,输出日志,再去销毁这个对象,在庞大的程序里面,这种就非常浪费性能,显然是不可取。
于是,我们期望部分类在整个软件的生命周期中,有且只有一个对象,于是就有了单例模式
1.引例
我们现在对于游戏配置来举个例子,由于是单例,所以我们并不希望GameConfig这个类在外部通过默认构造函数,拷贝构造函数,赋值构造函数等方式进行构造。同时它也不可以被外界随意释放。
下面是一个基本的单例模式的样板
#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
,并在类内声明,同时在获取单例对象的时候,加锁,获取完后自动释放
初步改版后,代码如下:
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成员函数代码实现如下:
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,大体就是这么一回事~!
我们懒汉式代码上面已经编写了,下面是饿汉式的代码
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这个类里面写一个成员函数
public:static void freeInstance(){if(m_instance!=nullptr){delete GameConfig::m_instance;GameConfig::m_instance = nullptr;}}
这个函数需要去手动调用一下,如果忘记了,就内存泄漏了,所以并不保险。最好的方案还是让他自动释放,所以不放看一下下面这个方案。
通过在GameConfig中放一个Garbo类(嵌套类),将单例对象的生命周期和Garbo捆绑到一起
class Garbo
{
public:~Garbo(){if (GameConfig::m_instance != nullptr){delete GameConfig::m_instance;GameConfig::m_instance = nullptr;}}
};
如果是饿汉式模式
在类GameConfig定义中,增加一个private修饰的静态成员变量:
private:static Garbo garboobj;
在类外,cpp源文件的开头位置,对上述静态成员进行定义:
GameConfig::Garbo GameConfig::garboobj;
在释放garboobj的时候,会自动调用其析构函数,同时将m_instance也一同删除并释放内存。
如果是懒汉式
则意味着如果不调用getInstance成员函数,则单件类对象不会被new出来,自然也就不需要释放。具体的实现代码相对简单,只需要在getInstance 成员函数中的new语句行下面增加一个局部静态变量定义即可:
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;
}
经过上述步骤后,如果程序调用过getInstancegetInstancegetInstance为单件类分配了内存,自然也就相当于garboobjgarboobjgarboobj局部静态变量被构造出来了,该局部静态变量所分配的内存会在程序执行结束前由操作系统回收,该回收动作会导致garboobj所属类Garbo析构函数的执行,在该析构函数中,正好释放单件类对象。
总结
单例模式是常用的一种设计模式,这里对于饿汉式和懒汉式的选择,其实我更倾向于饿汉式,这样就避免了多线程问题。本篇博客还介绍了一种垃圾回收的方式,通过类对象的析构来自动回收,这样避免了忘记释放导致内存泄漏的问题。
最后再贴一下UML类图