Featured image of post 单例模式

单例模式

单例模式简介

介绍

单例模式的目的是确保某个类在整个应用程序中只有一个实例,并提供一个全局访问点来获取该实例。这个模式常常用来管理全局共享资源,如配置对象、数据库连接、线程池等。

如下所示,我们通过new运算符创建出了类A的三个对象实例,而我们现在要做的是,如何设计类A,使得上述代码运行之后永远只产生同一个对象实例

1
2
3
A* p1=new A();
A* p2=new A();
A* p3=new A();

单例模式的核心要点:

  1. 唯一性:保证类只有一个实例。
  2. 全局访问点:提供一个全局的静态方法来访问该实例。

单例模式的结构:

  1. 私有构造函数:防止外部通过 new 关键字直接实例化对象。
  2. 静态成员变量:用于存储唯一的实例。
  3. 静态方法:提供外部获取实例的全局访问点。

设计方法

接下来我们一步步设计代码,来观察单例模式是如何被设计出来的

将构造函数设为私有

这一步的目的在于拒绝用户使用构造函数创建对象,只能使用我们提供的静态方法创建一个全局对象

1
2
3
4
5
class Singleton{
public:
private:
	Singleton(){};
};

声明全局对象并提供访问接口

我们知道静态成员变量的特点是它在类的所有实例之间是共享的,因此,我们可将类实例直接创建出来,并声明为static

1
2
3
4
5
6
class Singleton{
public:
private:
	static Singleton instance_;
	Singleton(){};
};

由于上述定义的实例对象是private的,因此我们需要定义一个公共接口,来供外部用户进行访问

1
2
3
4
5
6
7
8
9
class Singleton{
public:
	static Singleton& getInstance(){
		return instance_;
	}
private:
	static Singleton instance_;
	Singleton(){};
};

该接口声明是static的原因是,当用户调用接口时,对象实例还没有被创建出来,假如声明为普通成员函数,则普通成员函数的调用必须通过对象进行,这将成为一个矛盾点,因此如果想要用户使用这个函数,就必须要将其声明为static的

拒绝对象的拷贝和赋值

至此,我们已经完成了一个半成品,也就是说,这个时候我们要使用Sington对象,就必须通过其中的static成员函数getInstance来获取,而该函数返回的对象实例永远都是同一个实例

但还有个问题是,编译器还会为我们创建拷贝构造函数和拷贝赋值运算符,因此,当我们进行对象拷贝操作的时候,这个实例对象就不是“单例”的了

故而我们需要将拷贝构造和拷贝赋值运算符禁止掉

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 
class Singleton{
public:
	static Singleton& getInstance(){
		return instance_;
	}
private:
	static Singleton instance_;
	Singleton(){};
 
	Singleton(const Singleton&)=delete;
	Singleton& operator=(const Singleton&)=delete;
};

单例模式的分类

饿汉式单例模式

饿汉式单例模式是指,当在类加载时就将实例创建出来了,上述我们实现的单例模式就是饿汉式单例模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#if 1

class Singleton{
  public:
    static Singleton& GetInstance(){
      return instance_;
    }

  private:
    static Singleton instance_;
    Singleton(){}
    Singleton(const Singleton&)=delete;
    Singleton& operator=(const Singleton&)=delete;
};
//初始化静态变量
Singleton Singleton::instance_;

#endif

饿汉式单例模式的特点是

  • 优点:

    • 实现简单、线程安全:在类加载时就创建了实例,不存在多线程并发访问的问题
    • 执行效率高、没有加锁同步等额外操作
  • 缺点:

    • 内存浪费:类加载时即创建实例,如果单例对象占用大量资源或者初始化耗时较长,会导致程序启动变慢。
    • 无法延迟加载:即使没有使用到该单例对象,也会被创建

懒汉式单例模式

由于饿汉式单例模式在类加载时就会创建对象,即使我们不使用它它也会存在,当对象数据占据空间较大时,这显然是一种浪费,因此我们设计出了第二种单例模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
 
class Singleton
{
public:
	static Singleton* GetInstance(){
		// 将对象实例的创建延迟到第一次访问时
		if(instance_==nullptr){
			instance_=new Singleton();
		}
		return instance_;
	}
private:
	static Singleton *instance_;
	Singleton(){};
 
	Singleton(const Singleton&)=delete;
	Singleton& operator=(const Singleton&)=delete;
};
Singleton* Singleton::instance_=nullptr;

如上,我们将类中创建的对象实例声明为指针类型,这样当类加载的时候,内存中存在的是一个指针,而指针变量的大小在相同的操作系统下永远都是固定的,除此以外,在GetInstance方法的实现中,我们还将对象实例的创建延迟到了第一次访问的时候,平时如果我们不是该对象,该对象就不会被new出来

懒汉式单例模式的线程安全问题

问题复现

在懒汉式单例模式的代码里,虽然可以实现对象的延迟加载,但是会存在多线程并发时代码的线程安全问题

考虑一种情况,假如现在有两个线程,都要执行如下代码创建单例对象的实例

1
Singleton* ptr1=Singleton::GetInstance();

那么

  • 线程1执行到这段代码时,开始执行下面这句代码时,突然切换回了线程2
  • 而由于此时线程1并没有将对象创建创建出来就切换到了线程2,因此线程2也能执行到这段代码
1
if(instance_==nullptr)

那么问题来了,也就是说,此时线程1和线程2都执行到了上述这段代码,那么接下来,线程1和线程2就都可以创建这个“单例对象”

因此,在这种情况下,下述对象的创建其实是被执行了两次

1
instance_=new Singleton();

这就是懒汉式单例模式的线程安全问题

问题解决
方法1

第一种方法很自然的就会想到使用互斥量锁住这段创建对象的代码,如下所示

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Singleton{
public:
	static Singleton* getInstance(){
		std::lock_guard<std::mutex> lock(_mutex);
		if(!instance){
			instance_=new Singleton();
		}
		return instance_;
	}
 
	void show(){
		std::cout<<"this is singleton!"<<std::endl;
	}
private:
	static Singleton* instance_;
	static std::mutex _mutex;
 
	Singleton(){};
	Singleton(const Singleton&)=delete;
	Singleton& operator=(const Singleton&)=delete;
};
 
Singleton* Singleton::instance_=nullptr;
std::mutex Singleton::_mutex;

但是这样写的效率就会及其底下,在GetInstance成员函数中,就为了解决一个初始化该类对象时的互斥问题,居然在GetInstance中增加互斥量,导致所有调用该函数的调用者线程都被互斥一下,这非常影响性能。

因为除了初始化那个时刻,其他的时候完全不需要互斥。一旦初始化完毕,不管是否互斥调用GetInstance,这个if(!instance)条件都不会成立,从而完全可以确保初始化完毕之后,“instance=new Singleton();”代码行绝不会被再次执行。

方法2

方法2也被称之为双重锁定,先给出代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include<mutex>

#if 1
class Singleton {
 public:
    static Singleton *GetInstance() {
        if (!instance_) {
            std::lock_guard<std::mutex> lock(mtx_);
            if (!instance_)
                instance_ = new Singleton();
        }
        return instance_;
    }


 private:
    static Singleton *instance_;
    static std::mutex mtx_;

    Singleton() {};
    Singleton(const Singleton &) = delete;
    Singleton &operator=(const Singleton &) = delete;
};


// 静态成员变量定义:初始化为 nullptr
Singleton* Singleton::instance_ = nullptr;

// 静态成员变量初始化:mutex
std::mutex Singleton::mtx_;
#endif

使用互斥锁可以避免竞态问题的出现,但是会代码效率问题,但是如果我们此时在外层多加一层判断,那么一旦instance被创建成功后,因为最外面有一个条件判断if (instance ==NULL)在,这样就不会每次调用GetInstance都会创建一次互斥量

也就是说,平常的调用根本就执行不到创建互斥量的代码,而是直接执行“return instance_;”,这样调用者就能够直接拿到这个单例类的对象,所以肯定提高了执行GetInstance的效率。

因此,互斥锁解决了多线程对单例对象创建的竞态问题,而双重判断解决了互斥锁带来的效率问题

网站已运行
发表了17篇文章 · 总计 118,129字