【设计模式学习笔记】单例模式详解(懒汉式遇上多线程问题解析基于C++实现)
目录
2.3 DCL(double-checked locking)
一、什么是单例模式
1. 设计模式
模式就是解决问题的固定套路,设计模式(Design pattern)就是一套经过前人反复使用,总结出来的程序设计经验。设计模式总共分为三大类:
第一类是创建型模式 ,该模式通常和对象的创建有关,涉及到对象实例化的方式。包括:单例模式、工厂模式、抽象工厂模式、建造者模式、原型模式五种;
第二类是结构型模式,结构型模式描述的是如何组合类和对象来获得更大的结构。包括:代理模式、装饰者模式、适配器模式、桥接模式、组合模式、外观模式、享元模式共7种模式。
第三种是行为型模式,用来描述对类或对象怎样交互和怎样分配职责。共有:模板模式、命令模式、责任链模式、策略模式、中介者模式、观察者模式、备忘录模式、访问者模式、状态模式、解释器模式、迭代器模式11种模式。
2. 单例模式
单例模式是创建型模式的一种,正常情况下,我们定义一个类是可以创建很多个对象的,而单例模式顾名思义就是指一个类只能创建一个实例对象,也就是说在整个程序空间中,这个类只有一个对象,并且对外提供一个全局访问点来访问这个唯一的实例对象。单例模式主要分为两类:
饿汉式单例模式:一开始就创建好了一个唯一的对象;
懒汉式单例模式:在使用实例对象的时候去创建该唯一的对象;
单例模式的结构图:
二、单例模式的实现
1. 懒汉式单例模式
1.1 如何保证只有一个实例对象
当我们在使用类来new创建一个对象的时候,会自动调用构造函数,每创建一个对象都会调用构造函数来构造一个新的对象
class classA{};
void func()
{
classA* a1 = new classA; //调用构造函数
classA* a2 = new classA; //调用构造函数
if (a1 != a2)
{
cout << "a1和a2是两个不同的对象" << endl;
}
}
在上面程序中,我们new了两个对象,会调用两次构造函数,并创建出两个不同的对象,我们可以直接通过判断来测试一下
既然我们希望这个类只有一个实例对象,那么就应该禁止类的外部访问构造函数,因为每次在类的外部调用构造函数都会构造出一个新的实例对象。解决办法就是把构造函数设置为私有属性,在类的内部完成实例化对象的创建,这样就对外隐藏了创建对象的方法。但是类的出现就是要定义对象的,我们要使用类创建的对象,所以还需要提供一个全局访问点来获取类内部创建好的对象
class SingletonPattern
{
private:
SingletonPattern()
{
cout << "私有的构造函数" << endl;
}
public: //构造函数被私有化了,所以应该提供一个对外访问的方法,来创建对象
static SingletonPattern* get_single()
{
if (single == NULL) //为保证单例,只new一次
{ //如果不加这个判断,每次创建对象都会new一个single,这就不是单例了
single = new SingletonPattern;
}
//return this->single;
return single; //静态成员属于整个类,没有this指针
}
private: //static 成员,类定义的所有对象共有static成员
static SingletonPattern* single; //指针,不能是变量,否则编译器不知道如何分配内存
};
SingletonPattern* SingletonPattern::single = NULL; //告诉编译器分配内存
上面程序所示的就是一个懒汉式单例模式的实现。这里面有几点要注意的:
(1)为了让这个类所定义的所有对象共享属性,应该把属性设置为static类型,因为static类型的属性属于整个类而不是属于某个对象。
(2)为了保证单例模式,应该在全局访问点get_single()函数中加一个判断,如果对象已经被创建了,那么就直接返回这个对象,如果对象还没有被创建,那么久new创建一个对象,并返回该对象。
(3)因为是在使用到对象的时候,才去创建对象(single初始化为NULL,在全局访问点get_single被调用的时候才去创建对象),有点偷懒的感觉,所以称之为懒汉式单例模式。
我们再来测试一下,是不是真正的实现了单例
{
SingletonPattern* s1 = SingletonPattern::get_single(); //在get_single中会new一个对象
SingletonPattern* s2 = SingletonPattern::get_single();
if (s1 == s2)
{
cout << "单例" << endl;
}
else
{
cout << "不是单例" << endl;
}
}
运行测试程序,看打印结果
通过打印结果可以看到,创建的两个对象s1和s2是相等的,也就是说我们实现了单例,通过全局访问点获取的实例对象是同一个。
通过上面的分析,我们可以得到实现单例模式的步骤
1. 构造函数私有化;
2. 提供全局访问点;
3. 内部定义一个static静态指针指向当前类定义的对象;
1.2 懒汉式单例模式的缺陷
通过懒汉式单例模式,我们实现了一个类只创建一个实例对象,且只有在用到实例对象的时候,才会通过全局访问点去new创建这个对象,节省了资源。但是,懒汉式单例模式有一个致命的缺点,就是在C++的构造函数中,不能保证线程安全。什么意思呢,也就是说,在多个线程都去创建对象,调用全局访问点get_single()的时候,会面临资源竞争问题,假如在类的构造函数中增加一个延迟函数,我们第一个线程调用get_single()的时候,会进入构造函数,这时,因为延时的存在,第一个线程可能会在这里卡顿一会,假如正好这时候第二个线程也调用get_single()去创建实例对象,而第一个线程还在构造函数中延时,这样在get_single()函数中(single == NULL)这个判断条件依然成立,第二个线程也会进入构造函数。这样,两个线程创建的对象就不再是同一个对象了,也就不是单例模式了。下面,我们就详细分析多线程与懒汉式。
2. 懒汉式单例模式与多线程
2.1 多线程构造对象
首先,我们把类改造一下,在构造函数中加一个延时,并在类中加一个计数器来记录构造函数的调用次数
class SingletonPattern
{
private:
SingletonPattern()
{
count++;
Sleep(1000); //第一个线程在new的时候,如果延时还没结束
//第二个线程又过来new一个对象,这时候因为第一个对象还没有new出来
//所以single还是NULL,这样又会进入构造函数,最后总共new了两个对象
//这样返回的两个对象是两次new出来的,就不是单例模式了
printf("私有的构造函数\n");
}
public:
static int get_count()
{
return count;
}
public: //构造函数被私有化了,所以应该提供一个对外访问的方法,来创建对象
static SingletonPattern* get_single() //只有在调用该函数的时候才会new一个对象
{
if (single == NULL)
{
single = new SingletonPattern;
}
return single;
}
private: //static 成员,类定义的所有对象共有static成员
static SingletonPattern* single;
static int count;
};
SingletonPattern* SingletonPattern::single = NULL;
int SingletonPattern::count = 0;
这样,一个类就定义好了。接下来,我们要在main进程中创建三个线程,每个线程都去创建一个对象,在Windows下多线程编程应包含头文件<process.h>,并且会用到线程创建函数_beginthread(),对于_beginthread()函数的使用可以直接转到源码查看函数原型
typedef void (__cdecl* _beginthread_proc_type )(void*);
typedef unsigned (__stdcall* _beginthreadex_proc_type)(void*);
_ACRTIMP uintptr_t __cdecl _beginthread(
_In_ _beginthread_proc_type _StartAddress,
_In_ unsigned _StackSize,
_In_opt_ void* _ArgList
);
该函数包含三个参数,分别代表如下含义:
第一个参数是_beginthread_proc_type,通过上面的typedef可知,它是一个回调函数(函数指针),指向新开辟的线程的起始地址;
第二个参数_StackSize是新线程的堆栈大小,可以直接给个0,表示和主线程共用堆栈;
第三个参数_ArgList是一个参数列表,它表示要传递给新开辟线程的参数,新线程没有参数的话可以传入NULL;
函数返回值可以理解为创建好的线程的句柄。
首先搭建测试程序如下
{
int i = 0, ThreadNum = 3;
HANDLE h_thread[3];
for (i = 0; i < ThreadNum; i++)
{
h_thread[i] = (HANDLE)_beginthread(_cbThreadFunc, 0, NULL);
}
for (i = 0; i < ThreadNum; i++)
{
WaitForSingleObject(h_thread[i], INFINITE); //windows 下的等待
//thread_join //Linux 下的等待函数
//等待子线程结束,如果不等待子线程结束主进程就死掉的话,子线程也会随之死掉,所以主进程挂起等待
}
cout << "子线程已结束" << endl;
}
这里用到了一个函数WaitForSingleObject(),它用于等待子线程结束。因为子线程是依附于主线程存在的(共用堆栈、内存四区),如果子线程还没结束主线程就结束了,那么子线程也将不复存在,所以需要等待子线程结束后,主线程才能结束。该函数类似于Linux中的thread_join函数。
搭建好测试程序后,在定义一个线程函数
void _cbThreadFunc(void* arc)
{
DWORD id = GetCurrentThreadId(); //获取当前线程ID
int num = SingletonPattern::get_single()->get_count(); //创建对象
printf("\n构造函数调用次数:%d\n", num); //调用了3次构造函数 --- 不是单例
printf("当前线程是:%d\n", id);
//cout << "当前线程是:" << id << endl; //会有问题
}
编译运行测试函数
可以看到,构造函数调用了三次,每个线程都创建了一个新的对象,已经不再是单例模式了。对于这个问题的解决主要有两种,下面分别介绍。
2.2 饿汉式单例模式
第一种解决方法就是在类中定义static SingletonPattern*指针的时候就创建一个对象,在全局访问点get_single()直接返回创建好的对象,因为对象早就提前创建好了,这样即使多个线程调用创建对象所得到的也是同一个对象。因为对象还没使用就创建好了,所以叫做饿汉式单例模式。
上面的测试程序不用修改,我们只需要修改类即可
class SingletonPattern
{
private:
SingletonPattern()
{
count++;
Sleep(1000);
printf("私有的构造函数\n");
}
public:
static int get_count()
{
return count;
}
public:
static SingletonPattern* get_single()
{
return single;
}
private:
static SingletonPattern* single;
static int count;
};
//SingletonPattern* SingletonPattern::single = NULL;
SingletonPattern* SingletonPattern::single = new SingletonPattern; //饿汉式单例,一开始就new了一个对象
int SingletonPattern::count = 0;
再次运行前面的测试函数,看打印结果
从打印结果可以看到,三个不同的线程只调用了一次类的构造函数,得到的是同一个对象。
2.3 DCL(double-checked locking)
既然多个线程会竞争资源,那么如何才能防止多个线程之间的竞争呢?最简单的方法就是对临界区资源加一个锁🔒,当一个线程持有锁的时候,其他线程挂起等待锁的释放,只有持有锁的线程才能进入临界资源,这就解决了多线程资源竞争的问题(此处涉及到多线程同步问题)。这里还有一个问题,当我们第一次判断(single == NULL)后,如果之前没有创建对象,那么就进入下面的临界区
if (single == NULL)
{
cs.Lock();
single = new SingletonPattern;
cs.Unlock();
}
当第一个线程创建完对象后释放了锁,第二个线程进入临界区又创建了一个对象,这也违反了单例原则。所以应该加入一个二次检查,如果第一个线程已经创建了对象(指针不为NULL),那么第二个线程即使获取了锁,也不再创建新的对象,而是直接使用第一个线程创建的对象,这就是二次检测的原因。
static SingletonPattern* get_single()
{
if (single == NULL) //double check
{ //因为在这之前并没有保护机制,所以三个线程都有可能执行到这一步
cs.Lock();
if (single == NULL) //所以需要二次检查,进入临界区后再一次判断
{
single = new SingletonPattern;
}
cs.Unlock();
}
return single; //静态成员属于整个类,没有this指针
}
对全局访问点get_single()修改过后,再次运行测试函数
三、总结
单例模式主要有懒汉式和饿汉式两种实现,饿汉式不会有线程安全的问题,但是提前构造对象占用了一定的资源,如果对内存要求较低的场景可以使用饿汉式实现;懒汉式应使用DCL机制来避免多线程竞争资源的问题,并且懒汉式可以在需要使用对象的时候才去创建对象,节省了资源。