【设计模式学习笔记】单例模式详解(懒汉式遇上多线程问题解析基于C++实现)

目录

一、什么是单例模式

1. 设计模式

2. 单例模式

二、单例模式的实现

1. 懒汉式单例模式

1.1 如何保证只有一个实例对象

1.2 懒汉式单例模式的缺陷

2. 懒汉式单例模式与多线程

2.1 多线程构造对象

2.2 饿汉式单例模式

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机制来避免多线程竞争资源的问题,并且懒汉式可以在需要使用对象的时候才去创建对象,节省了资源。

posted @ 2022-04-15 09:30  Mindtechnist  阅读(127)  评论(0编辑  收藏  举报  来源