单例模式的概念比较简单,即某一类只能创建一个对象,这样设计类的方式就是单例模式。
在直接看代码之前,可以稍微思考一下这个单例模式大概会是个什么情况。
在C++中,调用一次构造函数就会创建一个对象,这个机制是货真价实的,因此也可以推测出,单例模式某种程度上实现了构造函数的“隐藏”。
构造函数怎么藏这个具体想不到,不过大致能想出来一个点,应该将构造函数放在private
区域内,令其属于类的私有成员,这样在对象中(类的区域之外)就不能直接调用构造函数了。如果不这样做,构造函数天天露在外面,实在藏不住。
另外还可以考虑到一个点:类的静态成员。类的静态成员并不依附于类的对象实例存在,无论对象的情况如何,静态成员都是由所有对象共享的,是唯一的存在。既然如此,会不会使用静态成员对单例模式的实现进行一种“管控”呢?
下面是单例模式的实现代码,注意代码主要使用了C++中类的静态成员知识。
头文件的内容:
class MyClass
{
private:
MyClass() {};
private:
static MyClass* m_instance;
public:
static MyClass* GetInstance();
void func(int a);
};
先别急着看cpp文件中的实现,先来鉴赏一下头文件中的内容:
首先,不出意料的是,构造函数成为了私有成员,也就是说,如果我们想要调用构造函数新建一个实例对象,我们至少还需要调用一个函数,在这个函数里调用构造函数。
接下来出现了一个静态成员变量m_instance
,这是一个MyClass
类的指针变量,MyClass
类的所有对象都共享这一个指针。
往后一看,没有别的必要的成员变量了,显然这个指针变量就是我们实现单例类的核心元素。
继续看,到了公有成员领域,看到两个成员函数,首先这个func函数吧……没有返回值,然后输入一个整形变量a,虽然普通的成员函数可以调用类中的所有成员变量,但是成员变量也就一个指针,说明这整形变量不是拿来给类用的,这函数八成是个测试用的显示函数,不需要去管。
需要特别关注的是GetInstance()
这个函数,它是一个静态成员函数。我们复习一下C++中的相关知识:类中的静态成员函数只能使用静态成员变量,不能使用普通成员变量。这说明了该函数只与静态成员变量m_instance
有关,并且该函数的返回值也正是m_instance
。
经过上面的分析,接下来就可以分析cpp文件中的内容了:
#include "MyClass.h"
#include
using namespace std;
MyClass * MyClass::GetInstance()
{
if (m_instance == nullptr) {//当该指针为空指针时,才会返回新建对象的指针!
m_instance = new MyClass();
}
return m_instance;//返回该指针
}
void MyClass::func(int a)//测试用的函数
{
cout << "test!——" << a << endl;
}
MyClass *MyClass::m_instance = nullptr;//静态成员变量需要在类的作用域外初始化
经过对头文件的分析,源文件里面的内容就相对好理解了。我们直接锁定核心函数GetInstance()
,在这个函数中其实只做了一次判断:如果m_instance
是个空指针,就新建一个实例对象,并且用m_instance
指向该实例对象,并将其返回。
如果m_instance
已经不是空指针了,它已经指向了一个实例对象了,那会如何?显然,函数就不会进行到m_instance = new MyClass();
,也就是说构造函数再也不会被调用了,类的单例化就实现了。
此外还需要注意下,在C++中静态成员变量必须要在类的作用域外面初始化,因此初始化语句在cpp中必须写在类成员函数之外。
我们还可以根据单例类的写法,来推测出单例类实例化的方式:
MyClass *pa = MyClass::GetInstance();
pa->func(1);
首先,单例类使用指针指向对象,所以程序中也只能使用对象指针的方式来创建实例对象。此外,GetInstance()
是一个静态成员函数,因此在程序里可以不依托对象的存在而直接调用!
之后我们可以写无数个实例化单例类的程序语句,但实际上这些指针都指向的同一个对象,这些指针创建时虽然都进入了GetInstance()
函数,但是它们无法进入条件判断语句块中,所以也无法新建实例对象。
多线程下的单例模式拥有巨大漏洞,我们可以细品这里的代码:
if (m_instance == nullptr) {//位置1
m_instance = new MyClass();//位置2
}
在思考多线程情况时,我们可以将多线程条件抽象为“有两个线程,在并发执行”。也就是只思考两个线程的情况。
假设有线程a和线程b,在一起工作。
首先,还没有实例化对象,因此线程a和b可能同时到了位置1,也就是都进入了条件判断语句块(毕竟没有实例化对象,大家都满足条件)
if (m_instance == nullptr){
//线程a和b都到这里了
}
假设这个时候线程a的时间片用光了,要进入就绪状态了,于是线程a在位置1暂时停了下来。
这个时候切换到线程b,线程b进入了运行态(多线程嘛,轮着干活呗),线程b一路向下走,完成了实例化,这样就已经新建了一个对象,并假设线程b已经离开了这个函数。
if (m_instance == nullptr) {//线程a暂时停在这里
m_instance = new MyClass();//线程b执行完了这句程序,已经完成了实例化。
}//线程b暂时停在这里
其实到这里问题就已经暴露出来了,不过我们还是把这个问题讲到最后吧。
这个时候,线程a从就绪态进入了运行态,继续往下走(他不知道线程b干了啥,毕竟轮着干活,理论上也是各干各的)。于是线程a走到了位置2,并再次进行了一次实例化,悲剧就发生了。
if (m_instance == nullptr) {
m_instance = new MyClass();//线程a执行完了这句程序,又来了一次实例化!
}//线程b暂时停在这里
可见,这一块代码:
if (m_instance == nullptr) {//位置1
m_instance = new MyClass();//位置2
}
在多线程里应该是个临界区域,也就是每次只能有一个线程进入,显然,如果每次只有一个线程进入该代码区域,就能完美实现单例类的功能。
说到保证每次只让一个线程进入代码块,我互斥量就要开始表演了。
基础方案就是加个互斥量。
#include
std::mutex resource_mutex;//新建一个互斥量
MyClass * MyClass::GetInstance()
{
std::unique_lock<std::mutex> myMutex(resource_mutex);//上锁
if (m_instance == nullptr) {//当该指针为空指针时,才会返回新建对象的指针!
m_instance = new MyClass();
}
return m_instance;//返回该指针
}
直接对该代码块进行加锁,这样每次都能保证只会有一个线程进入该代码块。
我们回顾一下刚才的方案。
#include
std::mutex resource_mutex;//新建一个互斥量
MyClass * MyClass::GetInstance()
{
std::unique_lock<std::mutex> myMutex(resource_mutex);//上锁
if (m_instance == nullptr) {//当该指针为空指针时,才会返回新建对象的指针!
m_instance = new MyClass();
}
return m_instance;//返回该指针
}
我们去想一下,单例类对象是如何工作的:
MyClass *pa = MyClass::GetInstance();
pa->func(1);
MyClass *pb = MyClass::GetInstance();
pa->func(2);
MyClass *pc = MyClass::GetInstance();
pa->func(3);
从上面的代码中可以看到,每个对象都需要调用一次GetInstance()
,这样每个对象都需要进行一次同步,也就是访问一下锁。
实际上,我们需要防范的场景是第一个对象还没被创建时的多线程竞争问题,一旦第一个实例对象被创建完毕,就不再需要互斥量对代码上锁了。
不仅如此,如果实例对象创建完毕后,每个对象指针建立时都会访问一下互斥量,这样无形中增加了额外的工作量,影响了代码的性能。
因此我们的优化目的是:当唯一的对象被创建后,其他线程不会去访问互斥量。
优化方案:双重检测
td::mutex resource_mutex;//新建一个互斥量
MyClass * MyClass::GetInstance()
{
if (m_instance == nullptr){//先判定一次
std::unique_lock<std::mutex> myMutex(resource_mutex);//上锁
if (m_instance == nullptr) {//当该指针为空指针时,才会返回新建对象的指针!
m_instance = new MyClass();
}
}
return m_instance;//返回该指针
}
在上锁语句前再增加一次检测,这样等到唯一的实例对象被创建后,其余的线程不会再去访问互斥量,提高了程序运行效率。