单例模式-C++实现

目录

单例模式是一种设计模式,用于确保一个类只有一个实例,并提供一个全局的访问点来获取该实例。它常用于需要在整个应用程序中共享相同资源或状态的情况下。

单例模式分为饿汉式懒汉式

饿汉式

在饿汉式中,实例在类加载时就被初始化,并且保证在多线程环境下的线程安全。

cpp 复制代码
// 饿汉式
class Singleton {
private:

	static Singleton* instance; // 静态成员变量,保存实例指针
	Singleton() {} // 构造函数私有化
	Singleton(const Singleton& other) {}	// 拷贝构造函数私有化
	Singleton& operator=(const Singleton&) {} // 赋值运算符私有化

public:
	static Singleton* getInstance() {
		return instance;
	}
};

Singleton* Singleton::instance = new Singleton(); // 在类加载时初始化实例

// 使用示例
Singleton* obj1 = Singleton::getInstance();
Singleton* obj2 = Singleton::getInstance();

// obj1 和 obj2 是同一个实例

饿汉式下类的实例对象在类加载时就被创建并赋值给静态成员变量instance,因此不需要考虑线程安全问题。因为每次调用getInstance方法都会返回同一个实例。

饿汉式的优点是实现简单,线程安全;缺点是无法实现延迟加载,即类在加载时就创建好了实例,可能会浪费资源。

懒汉式

懒汉式的实现方法是将实例的创建延迟到第一次请求访问时再进行初始化,这样可以避免初始化时资源的浪费和额外的开销,但需要考虑多线程之间的线程安全问题。

cpp 复制代码
class Singleton
{
private:
	Singleton() {}
	Singleton(const Singleton& other) {}
	Singleton& operator=(const Singleton&) {} // 赋值运算符私有化
public:
	static Singleton* getInstance();
	static Singleton* Singleton::m_instance;
};

Singleton* Singleton::m_instance = nullptr;

// 线程不安全
Singleton* Singleton::getInstance()
{
	if (m_instance == nullptr)
		m_instance = new Singleton();

	return m_instance;
}

在getInstance方法中,我们先判断m_instance是否为空,为空就new一个出来。但是这样在单线程下是安全的,因为m_instance只会被创建一次,在多线程下可能会被创建多次。

双检查锁,线程安全的版本

cpp 复制代码
class Singleton
{
private:
	Singleton() {}
	Singleton(const Singleton& other) {}
	Singleton& operator=(const Singleton&) {} // 赋值运算符私有化
	
public:
	static Singleton* getInstance();

	static Singleton* m_instance;

	static std::mutex m_mutex;
};

Singleton* Singleton::m_instance = nullptr;
std::mutex m_mutex;

// 多线程安全,但锁的代价过高
Singleton* Singleton::getInstance()
{
	std::lock_guard<std::mutex> lock(m_mutex);
	if (m_instance == nullptr)
		m_instance = new Singleton();

	return m_instance;
}

这个版本在多线程下是安全的,因为加锁了,但是在读操作的情况下,也就是如果m_instance直接返回时是不需要加锁的,所以这个版本在高并发的情况下开销很大,很耗时,因为不管是写操作还是读操作都需要加锁减锁。

为了解决这个问题,我们可以使用双检查锁来避免这样的开销问题

cpp 复制代码
// 双检查锁,但由于内存读写reorder不安全
Singleton* Singleton::getInstance()
{
	if (m_instance == nullptr)
	{
		std::lock_guard<std::mutex> lock(m_mutex);

		if (m_instance == nullptr)
			m_instance = new Singleton();
	}

	return m_instance;
}

锁前检查是否为空是为了避免读操作下还进行加锁,锁后检查是为了避免多次创建。

但是这样还是有问题,内存读写reorder不安全。

什么是reorder?

reorder就是在编译器底层进行优化重排指令的执行顺序。

举个例子:

m_instance = new Singleton();

这行代码在编译器底层大致可以分为三个步骤:

1、分配内存

2、调用构造器对内存进行初始化

3、将内存的地址赋值给m_instance

但在实际的运行过程中,编译器执行的顺序可能是1-》3-》2,这就会导致当多个线程同时调用getInstance方法并且m_instance为nullptr时,它们可能会同时通过if语句的判断条件进入临界区。在这种情况下,第一个线程通过了if语句的条件检查,并在锁内部实例化了Singleton对象。但由于内存读写重排序的存在,编译器或处理器可能会将Singleton对象的初始化操作重排到锁的外部,这会导致第二个线程在第一个线程完成实例化之前通过了if语句的条件检查,直接返回使用,但此时m_instance还没有进行初始化。

解决内存读写reorder不安全方法

为了解决这个问题,我们可以使用如下代码实现,支持C11以上版本并跨平台

cpp 复制代码
class Singleton
{
private:
	Singleton();
	Singleton(const Singleton& other);

public:
	static Singleton* getInstance();

	static std::atomic<Singleton*> m_instance;

	static std::mutex m_mutex;
};

std::atomic<Singleton*> Singleton::m_instance;
std::mutex m_mutex;

Singleton* Singleton::getInstance()
{
	Singleton* tmp = m_instance.load(std::memory_order_relaxed);
	std::atomic_thread_fence(std::memory_order_acquire);

	if (tmp == nullptr)
	{
		std::lock_guard<std::mutex> lock(m_mutex);
		tmp = m_instance.load(std::memory_order_relaxed);

		if (tmp == nullptr)
		{
			tmp = new Singleton;
			std::atomic_thread_fence(std::memory_order_release);
			m_instance.store(tmp, std::memory_order_relaxed);
		}
	}

	return tmp;
}

代码解释

首先,使用了双重检查锁定来提高性能。代码开始时,通过调用 m_instance.load(std::memory_order_relaxed) 加载 m_instance 变量的值,并将结果赋给 tmp 变量。

接下来,通过调用 std::atomic_thread_fence(std::memory_order_acquire) 来添加内存屏障,保证之前的读操作完成后,之后的读写操作不会被重排序。

然后,通过判断 tmp 是否为 nullptr,来确定是否需要创建实例。如果 tmp 是 nullptr,表示还没有创建实例,需要进行创建。

在创建实例之前,先获取一个互斥锁 m_mutex,确保只有一个线程可以访问临界区代码。

再次检查 tmp 是否为 nullptr,是为了防止多个线程同时通过第一个检查而进入临界区,因为在第一个检查后可能已经有其他线程创建了实例。

在临界区内部,首先创造了一个 Singleton 类的实例 tmp。然后通过 std::atomic_thread_fence(std::memory_order_release) 添加内存屏障,确保在 tmp 赋值完成后,该实例的构造函数中的其他写操作不会被重排序。

最后,通过调用 m_instance.store(tmp, std::memory_order_relaxed) 将 tmp 存储到 m_instance 变量中。

在临界区外部,返回已经创建的实例 tmp。

这种实现方式既兼顾了性能又保证了线程安全。通过使用双重检查锁定和互斥锁,可以避免多个线程同时创建实例,同时使用原子操作和内存屏障来保证实例的可见性和有序性。

懒汉式的优缺点

优点:

1、延迟加载:懒汉式在需要用到实例的时候才创建,可以在程序启动时减少不必要的消耗。

2、节约内存:懒汉式只会在用到对象时创建,避免了无谓的内存占用。

缺点:

1、线程安全性问题:多线程下同时获取实例时,可能会造成实例被多次创建的问题

2、性能问题:在多线程环境下,为了保证实例被唯一创建,需要引入额外的同步开销,高并发下会影响性能

3、实现复杂:为了保证线程安全,需要使用双检查锁等方法增加了代码的复杂性。

相关推荐
lxyzcm5 分钟前
C++23新特性解析:[[assume]]属性
java·c++·spring boot·c++23
蜀黍@猿24 分钟前
C/C++基础错题归纳
c++
古希腊掌管学习的神32 分钟前
[LeetCode-Python版]相向双指针——611. 有效三角形的个数
开发语言·python·leetcode
赵钰老师32 分钟前
【R语言遥感技术】“R+遥感”的水环境综合评价方法
开发语言·数据分析·r语言
雨中rain39 分钟前
Linux -- 从抢票逻辑理解线程互斥
linux·运维·c++
就爱学编程40 分钟前
重生之我在异世界学编程之C语言小项目:通讯录
c语言·开发语言·数据结构·算法
Oneforlove_twoforjob1 小时前
【Java基础面试题025】什么是Java的Integer缓存池?
java·开发语言·缓存
emoji1111111 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
每天都要学信号1 小时前
Python(第一天)
开发语言·python
TENET信条1 小时前
day53 第十一章:图论part04
开发语言·c#·图论