设计模式之单例模式

什么是单例模式

我们希望类的实例对象有且仅有一个,比如数据库连接配置、应用设置时,系统中需要一个全局对象,所有模块共享同一配置。再比如数据库连接池也需要全局管理,避免频繁创建与销毁。

感觉上述例子并不直观,举个通俗的例子------家里的电表。家里的电表记录整个家庭的用电量。无论你在厨房、卧室还是客厅用电,电表都是同一个。一般不会给每个房间都装一个独立的电表,因为那样使得整个家里的用电信息没有共享,还需要做第二步的汇总,实属多此一举。

我们希望实例对象有且只有一个,此时就需要用到单例模式了。单例模式保证一个类有且仅有一个实例,并且会提供一个全局访问点。

如何实现让一个类的实例只有一个呢?

那么就需要在构造函数 上做点"手脚"。如果构造函数是public(本文主要从C++语言的角度来讲述单例模式,不同的编程语言都是互通的,Java和C++都有public和private属性,印象里python貌似没有),那么就没有任何限制地可以构造对象了。

因此,需要将构造函数私有化 即设置为private属性。另外C++11及之后还可以使用= delete来禁用一个函数,因此还可以

cpp 复制代码
class Singleton {
    Singleton() = delete;
    ...
};

另外需要对外提供一个接口 ,便于外界通过这个接口获取到全局统一实例化的对象,并且这个接口得是静态 的即static关键字修饰的接口。

如果不使用static关键字修饰,那么调用这个接口就需要创建对象,而构造函数又被私有化了,无法创建对象。因此这个接口得是静态的。

在C++中类中的静态方法可以通过类名::方法名的方式调用,无需对象。

此外,还需要禁用拷贝构造函数和复制运算符,代码如下,不再细讲。对于拷贝构造函数可以看下方的补充,复制运算符另写文章补充。

cpp 复制代码
class Singleton {
		...
		Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
		Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
		...
}

如何实现单例模式

单例模式实现有两种方式,懒汉方式和饿汉方式。

懒汉方式

懒汉方式,顾名思义,很"懒",只有用到了才实例化对象并返回(调用了对外的接口才会实例化对象),也就是"懒加载",可以节省一部分系统资源,节省从系统启动到第一次调用对外接口这部分的资源。

就C++语言来说,单例模式的懒汉实现有两种写法,为了更好地适配更多的语言,先讲第一种写法

写法一

现在,我们可以得到如下单例模式的懒汉实现,请你阅读下述代码,看是否满足要求

cpp 复制代码
class Singleton {
private:
		static Singleton* instance;	// 静态成员变量,类内声明,类外初始化
		Singleton() {};	// 构造函数属性设置为私有
		Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
		Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符

public:
	static Singleton& getInstance()
	{
		if (instance == nullptr)
			instance = new Singleton();
		return *instance;
	}
};
Singleton* Singleton::instance=nullptr; // 初始化静态变量

上述实现是线程不安全的,原因在于getInstance方法中的条件判断和实例创建操作不是原子的。在多线程环境下,可能会导致多个线程同时创建实例,从而破坏单例模式的唯一性。

在 getInstance 方法中,以下代码是问题的根源

cpp 复制代码
if (instance == nullptr) 
    instance = new Singleton();

因为多个线程可能同时进入 if (instance == nullptr) 的判断,如果 instance 为 nullptr,多个线程会同时执行 instance = new singleClass(),导致创建多个实例。最终,会实例化多个对象,破坏了单例模式的唯一性。

通过以下代码获取两个单例对象,并打印它们的地址,有时会发现它们的地址不同。

cpp 复制代码
int main()
{
	Singleton& singlep1 = Singleton::getInstance();
	Singleton& singlep2 = Singleton::getInstance();
	cout << &singlep1 << endl;
	cout << &singlep2 << endl;
 
	return 0;
}

上述问题是在多线程环境下出现的问题,解决多线程问题,首当其冲的当然是"锁"啦。每次在getInstance方法里都上锁,然后判断instance == nullptr和实例化,然后解锁,返回对象。

于是就有了以下方案

cpp 复制代码
class Singleton {
private:
	static Singleton* instance;   // 静态成员变量,类内声明,类外初始化
	static mutex mtx;
	Singleton() {};  // 构造函数属性设置为私有
	Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
	Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		mtx.lock();
		if (instance == nullptr)
			instance = new Singleton();
		mtx.unlock();
		return *instance;
	}
};
Singleton* Singleton::instance = nullptr; // 初始化静态变量
mutex Singleton::mtx; // 类外初始化

非常简单直观,但是这种实现方法有很明显的缺点,每次调用getInstance都需要加锁,性能开销较大。事实上,出现多线程竞争的问题是在实例化时,而实例化是在instance == nullptr时才进行,因此可以先判断instance == nullptr,为nullptr时再加锁,这样在实例化过instance之后就不需要再加锁了。

那以下实现可以吗?

cpp 复制代码
static Singleton& getInstance()
{
	if (instance == nullptr) {
		mtx.lock();
		instance = new Singleton();
		mtx.unlock();
	}
	return *instance;
}

答案是No!原因是多个线程可能同时进入if (instance == nullptr)的内部,然后只有一个线程才能获取到锁,其他锁只能等待锁的释放。当获取到锁的那个线程,执行完instance = new Singleton();后会释放锁,然后其他线程就可以获取锁,进而执行instance = new Singleton();,导致重复实例化对象。

因此在获取到锁后,还需要再次判断if (instance == nullptr),这样就可以避免后续获取锁的线程重复实例化,看到这里,那么恭喜你已经学会双重校验加锁,即两次检查和一次加锁

第一次检查时不加锁,检查实例是否已经创建。如果已经创建,直接返回实例,避免加锁的开销。

第二次检查时需要先加锁再检查,如果实例未创建,加锁后再次检查实例是否为空。如果仍然为空,则创建实例。

一方面只有在实例未创建时才加锁,避免了每次调用 getInstance 都加锁的性能开销,另一方面就是线程安全,非常Nice。

代码如下(使用mutex或者lock_guard中的一个就行)

cpp 复制代码
class Singleton {
private:
	static Singleton* instance;   // 静态成员变量,类内声明,类外初始化
	static mutex mtx;
	Singleton() {};  // 构造函数属性设置为私有
    Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
    Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		if (instance == nullptr) {
			// mtx.lock();
			lock_guard<mutex> lock(mtx);
			if (instance == nullptr)
				instance = new Singleton();
			// mtx.unlock();
		}
		return *instance;
	}
};
Singleton* Singleton::instance = nullptr; // 初始化静态变量
mutex Singleton::mtx; // 类外初始化

写法二

单例模式的懒汉实现的写法二是基于C++语言特性的,其他语言可能不支持。

C++11 标准规定,静态局部变量的初始化是线程安全的 。因此可以使用静态局部变量初始化来实现懒汉模式。这样无需手动管理锁和静态成员变量,代码更简洁,无需考虑复杂的线程安全问题,因为静态局部变量只会被初始化一次,并且C++11以上标准来保证线程安全。

另外,静态局部变量的生命周期从第一次执行到它的声明语句开始,直到程序结束。

代码实现如下,但是实际面试时,如果面试官问到,大概率是想问写法一,可以在回答写法一后,顺带提一嘴写法二,来一个锦上添花。

cpp 复制代码
class Singleton {
private:
	Singleton() {};  // 构造函数属性设置为私有
	Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
	Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		static Singleton instance;
		return instance;
	};
};

饿汉方式

接下来介绍饿汉实现方法。

饿汉模式是不管调不调用对外接口,都直接实例化对象,这样在调用外部接口时,就没有实例化操作,因为在调用外部接口之前就已经实例化好了。

cpp 复制代码
class Singleton {
private:
	static Singleton* instance;   // 静态成员变量,类内声明,类外初始化
	Singleton() {};  // 构造函数属性设置为私有
	Singleton(const Singleton & sc) = delete; // 禁用拷贝构造函数
	Singleton& operator=(const Singleton & sc) = delete; // 禁用赋值运算符
public:
	static Singleton& getInstance()
	{
		return *instance;
	}
};
Singleton* Singleton::instance = new Singleton(); // 初始化静态变量,C++要求静态成员变量类内声明,类外初始化

补充拷贝构造函数

复习一下拷贝构造函数。

拷贝构造函数是C++中的一个特殊成员函数,用于创建一个新对象,并将其初始化为另一个同类型对象的副本。它在以下情况下会被调用:

  • 用一个对象初始化另一个对象时。
  • 将对象作为参数按值传递给函数时。
  • 从函数按值返回对象时。

拷贝构造函数的典型形式如下:

cpp 复制代码
class MyClass {
public:
    // 拷贝构造函数
    MyClass(const MyClass& other) {
        // 拷贝函数逻辑
    }
};

拷贝构造函数的参数是一个常量引用(const MyClass&),表示被拷贝的对象。

拷贝构造函数常见问题是浅拷贝的问题,不再多说啦。

对于复制运算符,另写博客说明。

补充一下C++中的锁

上述实现中对于互斥信号量 的使用的是mutex。其实也可以使用lock_guard或者unique_lock来实现,它俩是更高级的封装,支持构造时自动加锁,析构时自动解锁。而直接使用mutex可以做到更精细的调控罢了,更加灵活。

mutex需要手动加锁和解锁,也需要显式调用解锁。如果忘记调用 mtx.unlock(),会导致锁未被释放,可能引发死锁。可以在代码的任何地方加锁和解锁,非常适合需要精细控制锁的场景。

lock_guard自动加锁和解锁,是一个 RAII(资源获取即初始化 resouce acquire is initialized)封装类,在构造时自动加锁,在析构时自动解锁,并且无需手动解锁,即使函数提前返回或抛出异常,lock_guard 也能保证锁被释放。代码更加简洁,不易出错。unique_locklock_guard类似,有细微差别,另写文章细讲。

特性 lock_guard lock(mtx) mtx.lock() 和 mtx.unlock()
加锁方式 构造时自动加锁 手动加锁
解锁方式 析构时自动解锁 手动解锁
异常安全 安全,即使抛出异常也能解锁 不安全,需要手动处理异常
代码简洁性
灵活性
相关推荐
Moe4882 小时前
Spring Boot 自动配置核心:AutoConfigurationImportSelector 深度解析
java·后端·设计模式
G***T6912 小时前
Java设计模式之责任链
设计模式
6***x5453 小时前
Java设计模式之策略模式
java·设计模式·策略模式
miss_you12133 小时前
策略模式 + 模板方法 + 注册式工厂 统一设计方案(营销优惠场景示例)
设计模式·工厂方法模式·策略模式·模板方法模式
q***547512 小时前
Spring Boot 经典九设计模式全览
java·spring boot·设计模式
Irene199114 小时前
JavaScript 模块 单例模式 和 副作用 详解
单例模式·副作用
7***n7514 小时前
API网关设计模式
linux·服务器·设计模式
那我掉的头发算什么14 小时前
【javaEE】多线程 -- 超级详细的核心组件精讲(单例模式 / 阻塞队列 / 线程池 / 定时器)原理与实现
java·单例模式·java-ee
ZHE|张恒14 小时前
设计模式(七)桥接模式 — 抽象与实现分离,让维度独立扩展
设计模式·桥接模式