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

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 【设计模式学习笔记】单例模式详解(懒汉式遇上多线程问题解析基于C++实现)

一、什么是单例模式

1. 设计模式

模式就是解决问题的固定套路,设计模式(Design pattern)就是一套经过前人反复使用,总结出来的程序设计经验。设计模式总共分为三大类:

第一类是创建型模式 ,该模式通常和对象的创建有关,涉及到对象实例化的方式。包括:单例模式、工厂模式、抽象工厂模式、建造者模式、原型模式五种;

第二类是结构型模式,结构型模式描述的是如何组合类和对象来获得更大的结构。包括:代理模式、装饰者模式、适配器模式、桥接模式、组合模式、外观模式、享元模式共7种模式。

第三种是行为型模式,用来描述对类或对象怎样交互和怎样分配职责。共有:模板模式、命令模式、责任链模式、策略模式、中介者模式、观察者模式、备忘录模式、访问者模式、状态模式、解释器模式、迭代器模式11种模式。

2. 单例模式

单例模式是创建型模式的一种,正常情况下,我们定义一个类是可以创建很多个对象的,而单例模式顾名思义就是指一个类只能创建一个实例对象,也就是说在整个程序空间中,这个类只有一个对象,并且对外提供一个全局访问点来访问这个唯一的实例对象。单例模式主要分为两类:

饿汉式单例模式:一开始就创建好了一个唯一的对象;

懒汉式单例模式:在使用实例对象的时候去创建该唯一的对象;

单例模式的结构图:

二、单例模式的实现

1. 懒汉式单例模式

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

当我们在使用类来new创建一个对象的时候,会自动调用构造函数,每创建一个对象都会调用构造函数来构造一个新的对象

1. class classA{};
2. 
3. void func()
4. {
5.     classA* a1 = new classA; //调用构造函数
6.     classA* a2 = new classA; //调用构造函数
7. if (a1 != a2)
8.  {
9.    cout << "a1和a2是两个不同的对象" << endl;
10.   }
11. }

在上面程序中,我们new了两个对象,会调用两次构造函数,并创建出两个不同的对象,我们可以直接通过判断来测试一下

既然我们希望这个类只有一个实例对象,那么就应该禁止类的外部访问构造函数,因为每次在类的外部调用构造函数都会构造出一个新的实例对象。解决办法就是把构造函数设置为私有属性,在类的内部完成实例化对象的创建,这样就对外隐藏了创建对象的方法。但是类的出现就是要定义对象的,我们要使用类创建的对象,所以还需要提供一个全局访问点来获取类内部创建好的对象

1. class SingletonPattern
2. {
3. private:
4.  SingletonPattern()
5.  {
6.    cout << "私有的构造函数" << endl;
7.  }
8. public: //构造函数被私有化了,所以应该提供一个对外访问的方法,来创建对象
9.  static SingletonPattern* get_single() 
10.   {
11.     if (single == NULL) //为保证单例,只new一次
12.     {         //如果不加这个判断,每次创建对象都会new一个single,这就不是单例了
13.       single = new SingletonPattern;
14.     }
15.     //return this->single;
16.     return single; //静态成员属于整个类,没有this指针
17.   }
18. private: //static 成员,类定义的所有对象共有static成员
19.   static SingletonPattern* single; //指针,不能是变量,否则编译器不知道如何分配内存
20. };
21. 
22. SingletonPattern* SingletonPattern::single = NULL; //告诉编译器分配内存

上面程序所示的就是一个懒汉式单例模式的实现。这里面有几点要注意的:

(1)为了让这个类所定义的所有对象共享属性,应该把属性设置为static类型,因为static类型的属性属于整个类而不是属于某个对象。

(2)为了保证单例模式,应该在全局访问点get_single()函数中加一个判断,如果对象已经被创建了,那么就直接返回这个对象,如果对象还没有被创建,那么久new创建一个对象,并返回该对象。

(3)因为是在使用到对象的时候,才去创建对象(single初始化为NULL,在全局访问点get_single被调用的时候才去创建对象),有点偷懒的感觉,所以称之为懒汉式单例模式。

我们再来测试一下,是不是真正的实现了单例

1. {
2.     SingletonPattern* s1 = SingletonPattern::get_single(); //在get_single中会new一个对象
3.  SingletonPattern* s2 = SingletonPattern::get_single(); 
4.  if (s1 == s2)
5.  {
6.    cout << "单例" << endl;
7.  }
8.  else
9.  {
10.     cout << "不是单例" << endl;
11.   }
12. }

运行测试程序,看打印结果

通过打印结果可以看到,创建的两个对象s1和s2是相等的,也就是说我们实现了单例,通过全局访问点获取的实例对象是同一个。

通过上面的分析,我们可以得到实现单例模式的步骤

1. 构造函数私有化;

2. 提供全局访问点;

3. 内部定义一个static静态指针指向当前类定义的对象;

1.2 懒汉式单例模式的缺陷

通过懒汉式单例模式,我们实现了一个类只创建一个实例对象,且只有在用到实例对象的时候,才会通过全局访问点去new创建这个对象,节省了资源。但是,懒汉式单例模式有一个致命的缺点,就是在C++的构造函数中,不能保证线程安全。什么意思呢,也就是说,在多个线程都去创建对象,调用全局访问点get_single()的时候,会面临资源竞争问题,假如在类的构造函数中增加一个延迟函数,我们第一个线程调用get_single()的时候,会进入构造函数,这时,因为延时的存在,第一个线程可能会在这里卡顿一会,假如正好这时候第二个线程也调用get_single()去创建实例对象,而第一个线程还在构造函数中延时,这样在get_single()函数中(single == NULL)这个判断条件依然成立,第二个线程也会进入构造函数。这样,两个线程创建的对象就不再是同一个对象了,也就不是单例模式了。下面,我们就详细分析多线程与懒汉式。

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

2.1 多线程构造对象

首先,我们把类改造一下,在构造函数中加一个延时,并在类中加一个计数器来记录构造函数的调用次数

1. class SingletonPattern
2. {
3. private:
4.  SingletonPattern()
5.  {
6.    count++;
7.    Sleep(1000); //第一个线程在new的时候,如果延时还没结束
8.           //第二个线程又过来new一个对象,这时候因为第一个对象还没有new出来
9.           //所以single还是NULL,这样又会进入构造函数,最后总共new了两个对象
10.            //这样返回的两个对象是两次new出来的,就不是单例模式了
11.     printf("私有的构造函数\n");
12.   }
13. public:
14.   static int get_count()
15.   {
16.     return count;
17.   }
18. public: //构造函数被私有化了,所以应该提供一个对外访问的方法,来创建对象
19.   static SingletonPattern* get_single() //只有在调用该函数的时候才会new一个对象
20.   {
21.     if (single == NULL) 
22.     {         
23.       single = new SingletonPattern;
24.     }
25.     return single; 
26.   }
27. private: //static 成员,类定义的所有对象共有static成员
28.   static SingletonPattern* single; 
29.   static int count;
30. };
31. 
32. SingletonPattern* SingletonPattern::single = NULL; 
33. int SingletonPattern::count = 0;

这样,一个类就定义好了。接下来,我们要在main进程中创建三个线程,每个线程都去创建一个对象,在Windows下多线程编程应包含头文件<process.h>,并且会用到线程创建函数_beginthread(),对于_beginthread()函数的使用可以直接转到源码查看函数原型

1. typedef void     (__cdecl*   _beginthread_proc_type  )(void*);
2. typedef unsigned (__stdcall* _beginthreadex_proc_type)(void*);
3. 
4. _ACRTIMP uintptr_t __cdecl _beginthread(
5.  _In_     _beginthread_proc_type _StartAddress,
6.  _In_     unsigned               _StackSize,
7.  _In_opt_ void*                  _ArgList
8. );

该函数包含三个参数,分别代表如下含义:

第一个参数是_beginthread_proc_type,通过上面的typedef可知,它是一个回调函数(函数指针),指向新开辟的线程的起始地址;

第二个参数_StackSize是新线程的堆栈大小,可以直接给个0,表示和主线程共用堆栈;

第三个参数_ArgList是一个参数列表,它表示要传递给新开辟线程的参数,新线程没有参数的话可以传入NULL;

函数返回值可以理解为创建好的线程的句柄。

首先搭建测试程序如下

1. {
2. int i = 0, ThreadNum = 3;
3.  HANDLE h_thread[3];
4. 
5.  for (i = 0; i < ThreadNum; i++)
6.  {
7.    h_thread[i] = (HANDLE)_beginthread(_cbThreadFunc, 0, NULL);
8.  }
9. 
10.   for (i = 0; i < ThreadNum; i++)
11.   {
12.     WaitForSingleObject(h_thread[i], INFINITE); //windows 下的等待
13.     //thread_join  //Linux 下的等待函数
14.     //等待子线程结束,如果不等待子线程结束主进程就死掉的话,子线程也会随之死掉,所以主进程挂起等待
15.   }
16. 
17.   cout << "子线程已结束" << endl;
18. }

这里用到了一个函数WaitForSingleObject(),它用于等待子线程结束。因为子线程是依附于主线程存在的(共用堆栈、内存四区),如果子线程还没结束主线程就结束了,那么子线程也将不复存在,所以需要等待子线程结束后,主线程才能结束。该函数类似于Linux中的thread_join函数。

搭建好测试程序后,在定义一个线程函数

1. void _cbThreadFunc(void* arc)
2. {
3.  DWORD id = GetCurrentThreadId(); //获取当前线程ID
4. 
5.  int num = SingletonPattern::get_single()->get_count(); //创建对象
6. 
7.  printf("\n构造函数调用次数:%d\n", num); //调用了3次构造函数 --- 不是单例
8.  printf("当前线程是:%d\n", id);
9.  //cout << "当前线程是:" << id << endl; //会有问题
10. }

编译运行测试函数

可以看到,构造函数调用了三次,每个线程都创建了一个新的对象,已经不再是单例模式了。对于这个问题的解决主要有两种,下面分别介绍。

2.2 饿汉式单例模式

第一种解决方法就是在类中定义static SingletonPattern*指针的时候就创建一个对象,在全局访问点get_single()直接返回创建好的对象,因为对象早就提前创建好了,这样即使多个线程调用创建对象所得到的也是同一个对象。因为对象还没使用就创建好了,所以叫做饿汉式单例模式。

上面的测试程序不用修改,我们只需要修改类即可

1. class SingletonPattern
2. {
3. private:
4.  SingletonPattern()
5.  {
6.    count++;
7.    Sleep(1000); 
8.    printf("私有的构造函数\n");
9.  }
10. public:
11.   static int get_count()
12.   {
13.     return count;
14.   }
15. public: 
16.   static SingletonPattern* get_single()
17.   {
18.     return single;
19.   }
20. private: 
21.   static SingletonPattern* single; 
22.   static int count;
23. };
24. 
25. //SingletonPattern* SingletonPattern::single = NULL;
26. SingletonPattern* SingletonPattern::single = new SingletonPattern; //饿汉式单例,一开始就new了一个对象
27. int SingletonPattern::count = 0;

再次运行前面的测试函数,看打印结果

从打印结果可以看到,三个不同的线程只调用了一次类的构造函数,得到的是同一个对象。

2.3 DCL(double-checked locking)

既然多个线程会竞争资源,那么如何才能防止多个线程之间的竞争呢?最简单的方法就是对临界区资源加一个锁🔒,当一个线程持有锁的时候,其他线程挂起等待锁的释放,只有持有锁的线程才能进入临界资源,这就解决了多线程资源竞争的问题(此处涉及到多线程同步问题)。这里还有一个问题,当我们第一次判断(single == NULL)后,如果之前没有创建对象,那么就进入下面的临界区

1. if (single == NULL)  
2. {   
3.  cs.Lock(); 
4.  single = new SingletonPattern;
5.  cs.Unlock();
6. }

当第一个线程创建完对象后释放了锁,第二个线程进入临界区又创建了一个对象,这也违反了单例原则。所以应该加入一个二次检查,如果第一个线程已经创建了对象(指针不为NULL),那么第二个线程即使获取了锁,也不再创建新的对象,而是直接使用第一个线程创建的对象,这就是二次检测的原因。

1. static SingletonPattern* get_single() 
2. {
3.  if (single == NULL)  //double check 
4.  {   //因为在这之前并没有保护机制,所以三个线程都有可能执行到这一步
5.    cs.Lock(); 
6.    if (single == NULL) //所以需要二次检查,进入临界区后再一次判断
7.    {
8.      single = new SingletonPattern;
9.    }
10.     cs.Unlock();
11.   }
12.   return single; //静态成员属于整个类,没有this指针
13. }

对全局访问点get_single()修改过后,再次运行测试函数

三、总结

单例模式主要有懒汉式和饿汉式两种实现,饿汉式不会有线程安全的问题,但是提前构造对象占用了一定的资源,如果对内存要求较低的场景可以使用饿汉式实现;懒汉式应使用DCL机制来避免多线程竞争资源的问题,并且懒汉式可以在需要使用对象的时候才去创建对象,节省了资源。


相关文章
|
13天前
|
设计模式 安全 Java
Kotlin教程笔记(57) - 改良设计模式 - 单例模式
Kotlin教程笔记(57) - 改良设计模式 - 单例模式
|
21天前
|
设计模式 存储 数据库连接
PHP中的设计模式:单例模式的深入理解与应用
【10月更文挑战第22天】 在软件开发中,设计模式是解决特定问题的通用解决方案。本文将通过通俗易懂的语言和实例,深入探讨PHP中单例模式的概念、实现方法及其在实际开发中的应用,帮助读者更好地理解和运用这一重要的设计模式。
15 1
|
27天前
|
设计模式 安全 Java
Kotlin教程笔记(57) - 改良设计模式 - 单例模式
Kotlin教程笔记(57) - 改良设计模式 - 单例模式
25 0
|
30天前
|
设计模式 安全 Java
Kotlin教程笔记(57) - 改良设计模式 - 单例模式
本教程详细讲解了Kotlin中的单例模式实现,包括饿汉式、懒汉式、双重检查锁、静态内部类及枚举类等方法,适合需要深入了解Kotlin单例模式的开发者。快速学习者可参考“简洁”系列教程。
28 0
|
30天前
|
设计模式 存储 数据库连接
Python编程中的设计模式之美:单例模式的妙用与实现###
本文将深入浅出地探讨Python编程中的一种重要设计模式——单例模式。通过生动的比喻、清晰的逻辑和实用的代码示例,让读者轻松理解单例模式的核心概念、应用场景及如何在Python中高效实现。无论是初学者还是有经验的开发者,都能从中获得启发,提升对设计模式的理解和应用能力。 ###
|
9天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
36 4
|
10天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
33 4
|
1月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
27 4
|
1月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
23 4
|
1月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
21 1

推荐镜像

更多