【C++】单例模式

参考C++大佬恋恋风辰的博客

文章目录


前言

当一个函数中定义一个静态局部变量时,那么这个局部变量只会初始化一次,就是在这个函数第一次调用的时候,以后无论调用几次这个函数,函数内的静态局部变量都不再初始化。

那我们可以利用局部静态变量这一特点实现单例

意图: 保证一个类仅有一个实例,并提供一个访问它的全局访问点。
主要解决: 一个全局使用的类频繁地创建与销毁。
何时使用: 当想控制实例数目,节省系统资源的时候。
单例模式在多线程中的问题:多线程操作会涉及到对单例模式的影响,多个线程可能会同时创建类的实例,从而违反了单例模式的原则。


以下是本篇文章正文内容

一、早期的单例模式

利用static静态局部变量进行单例模式实现

  • C++11 以前该方式存在风险,在多个线程初始化时存在开辟多个实例情况
  • C++ 11以后,进行了编译优化,不会出现多线程问题,大部分的单例都回归到这个模式了
cpp 复制代码
//单例模式
class Single2 {
private: // 私有化无参构造
	Single2(){}
	
	// 删除拷贝构造和"="操作符,保证单例
	Single2(const Single2&) = delete;
	Single2& operator=(const Single2&) = delete;
public:
	static Single2& GetInst(){
		// static只会在第一次调用时候初始化,以实现单例
		static Single2 single;
		return single;
	}
};

void  test_single2() {
	// 在C++11之前,多线程情况下可能存在问题,同时调用GetInst时可能出现问题
	std::cout << "s1 addr is " << &Single2::GetInst() << std::endl;
	std::cout << "s2 addr is " << &Single2::GetInst() << std::endl; // 打印地址是一样的
}

全局变量、文件域的静态变量类的静态成员变量 在main执行之前的静态初始化过程中分配内存并初始化;
局部静态变量(一般为函数内的静态变量)在第一次使用时分配内存并初始化。
非局部静态变量 一般在main执行之前的静态初始化过程中分配内存并初始化,可以认为是线程安全的;
局部静态变量在编译时,编译器的实现一般是在初始化语句之前设置一个局部静态变量的标识来判断是否已经初始化,运行的时候每次进行判断,如果需要初始化则执行初始化操作,否则不执行。这个过程本身不是线程安全的。

二、 单例模式:饿汉模式

在C++11 推出以前,局部静态变量的方式实现单例存在线程安全问题,所以部分人推出了一种方案:就是在主线程启动后,其他线程没有启动前,由主线程先初始化单例资源,这样其他线程获取的资源就不涉及重复初始化的情况了。

cpp 复制代码
// 饿汉式的单例类
class Single2Hungry{
private:
    Single2Hungry(){}
	
	// 删除拷贝构造和"="操作符,保证单例
    Single2Hungry(const Single2Hungry&) = delete;
    Single2Hungry& operator=(const Single2Hungry&) = delete;
public:
    static Single2Hungry* GetInst(){
        if (single == nullptr){
            single = new Single2Hungry();
        }
        return single;
    }
private:
    static Single2Hungry* single;
};
cpp 复制代码
// 饿汉式初始化
Single2Hungry* Single2Hungry::single = Single2Hungry::GetInst();

void thread_func_s2(int i){
    std::cout << "this is thread " << i << std::endl;
    std::cout << "inst is " << Single2Hungry::GetInst() << std::endl;
}

// 多线程调用
void test_single2hungry(){
	// 主线程打印地址
    std::cout << "s1 addr is " << Single2Hungry::GetInst() << std::endl;
    std::cout << "s2 addr is " << Single2Hungry::GetInst() << std::endl;
    // 子线程打印地址,这和主线程应该一致
    for (int i = 0; i < 3; i++){
        std::thread tid(thread_func_s2, i);
        tid.join();
    }
}

三、 单例模式:懒汉模式

在用到时如果没有初始化单例则初始化,如果初始化了则直接使用.

所以这种方式我们要加锁,防止资源被重复初始化。

cpp 复制代码
class SinglePointer{
private:
    SinglePointer(){ }

	// 删除拷贝构造和"="操作符,保证单例
    SinglePointer(const SinglePointer&) = delete;
    SinglePointer& operator=(const SinglePointer&) = delete;
    
public:
    static SinglePointer* GetInst(){
        if (single != nullptr){
            return single;
        }
        // 加入互斥锁
        s_mutex.lock();
        if (single != nullptr){
            s_mutex.unlock();
            return single;
        }
        single = new SinglePointer();
        // 初始化完成后解锁
        s_mutex.unlock();
        return single;
    }
    
private:
	// 单例对象
    static SinglePointer* single;
    // 互斥锁
    static std::mutex s_mutex;
};
cpp 复制代码
SinglePointer* SinglePointer::single = nullptr;
std::mutex SinglePointer::s_mutex;
void thread_func_lazy(int i){
    std::cout << "this is lazy thread " << i << std::endl;
    std::cout << "inst is " << SinglePointer::GetInst() << std::endl;
}

void test_singlelazy(){
    for (int i = 0; i < 3; i++) {
        std::thread tid(thread_func_lazy, i);
        tid.join();
    }
    //何时释放new的对象?造成内存泄漏
}

问题:这种方式存在一个很严重的问题,就是当多个线程都调用单例函数时,我们不确定资源是被哪个线程初始化的

回收指针存在问题,存在多重释放或者不知道哪个指针释放的问题。

四、利用智能指针实现懒汉模式

利用智能指针的目的:自动对new出来的空间进行管理释放,但是要防止用户进行私自释放,造成智能指针释放失败

cpp 复制代码
//为了规避用户手动释放内存,可以提供一个辅助类帮忙回收内存
//并将单例类的析构函数写为私有

class SingleAutoSafe;
// 仿函数,因为重载了"()"操作符,像函数一样,这个在【C++】STL的第十章记录过
class SafeDeletor{
public:
	// 重载()符,当作了删除器
    void operator()(SingleAutoSafe* sf)
    {
        std::cout << "this is safe deleter operator()" << std::endl;
        delete sf;
    }
};


class SingleAutoSafe
{
private:
    SingleAutoSafe() {}
    // 将析构函数设置为私有,防止用户私自使用,造成智能指针指向的空间被释放
    ~SingleAutoSafe(){
        std::cout << "this is single auto safe deletor" << std::endl;
    }
    SingleAutoSafe(const SingleAutoSafe&) = delete;
    SingleAutoSafe& operator=(const SingleAutoSafe&) = delete;
    //定义友元类,通过友元类调用该类析构函数
    friend class SafeDeletor;

public:
    static std::shared_ptr<SingleAutoSafe> GetInst() {
        //1处,被初始化了直接用
        if (single != nullptr){
            return single;
        }
        s_mutex.lock();
        //2处,先加锁,再次判断有没有被其他线程初始化,严谨
        if (single != nullptr){
            s_mutex.unlock();
            return single;
        }
        //额外指定删除器  
        //3 处
        single = std::shared_ptr<SingleAutoSafe>(new SingleAutoSafe, SafeDeletor());
        // 也可以指定自定义的删除函数
        // single = std::shared_ptr<SingleAutoSafe>(new SingleAutoSafe, SafeDelFunc);
        s_mutex.unlock();
        return single;
    }
private:
    static std::shared_ptr<SingleAutoSafe> single;
    static std::mutex s_mutex;
};

但是上面的代码存在危险,比如懒汉式的使用方式,当多个线程调用单例时,有一个线程加锁进入3处的逻辑。

其他的线程有的在1处,判断指针非空则跳过初始化直接使用单例的内存会存在问题。

主要原因在于SingleAutoSafe * temp = new SingleAutoSafe() 这个操作是由三部分组成的

1 调用allocate开辟内存

2 调用construct执行SingleAutoSafe的构造函数

3 调用赋值操作将地址赋值给temp

而现实中2和3的步骤可能颠倒,所以有可能在一些编译器中通过优化是1,3,2的调用顺序,

其他线程取到的指针就是非空(已经被赋值,但是没有初始化),还没来的及调用构造函数就交给外部使用造成不可预知错误。(比如程序崩溃、是一片原始空间等)

为解决这个问题,C++11 推出了std::call_once函数保证多个线程只执行一次

五、 call_once

为了解决上述问题

C++11 提出了call_once函数,我们可以配合一个局部的静态变量once_flag实现线程安全的初始化。

多线程调用call_once函数时,会判断once_flag是否被初始化,如没被初始化则进入初始化流程,调用我们提供的初始化函数。

但是同一时刻只有一个线程能进入这个初始化函数。

cpp 复制代码
class SingletonOnce {
private:
    SingletonOnce() = default;
    SingletonOnce(const SingletonOnce&) = delete;
    SingletonOnce& operator = (const SingletonOnce& st) = delete;
    static std::shared_ptr<SingletonOnce> _instance;
    
public :
    static std::shared_ptr<SingletonOnce> GetInstance() {
        static std::once_flag s_flag;  // 只会初始化一次,初始化为false
        
        // 判断s_flag的值,为false时,调用我们提供的初始化函数
        // call_once函数底层有锁,所以只有一个线程会进入call_once内部
        std::call_once(s_flag, [&]() {
            _instance = std::shared_ptr<SingletonOnce>(new SingletonOnce);
            });
        return _instance;
    }
    
    void PrintAddress() {
        std::cout << _instance.get() << std::endl;
    }
    ~SingletonOnce() {
        std::cout << "this is singleton destruct" << std::endl;
    }
};
std::shared_ptr<SingletonOnce> SingletonOnce::_instance = nullptr;

测试函数

cpp 复制代码
void TestSingle() {
    std::thread t1([]() {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            SingletonOnce::GetInstance()->PrintAddress();    
        });
    std::thread t2([]() {
            std::this_thread::sleep_for(std::chrono::seconds(1));
            SingletonOnce::GetInstance()->PrintAddress();
    });
    t1.join();
    t2.join();
}

5.1 call_once的模板类

为了使用单例类更通用,比如项目中使用多个单例类,可以通过继承实现多个单例类

cpp 复制代码
//为了让单例更加通用,可以做成模板类
// 此类可以实现T类型的单例模式
template <typename T>
class Singleton {
protected:
    Singleton() = default;
    Singleton(const Singleton<T>&) = delete;
    Singleton& operator=(const Singleton<T>& st) = delete;
    static std::shared_ptr<T> _instance;  // 智能指针类型
    
public:
    static std::shared_ptr<T> GetInstance() {
        static std::once_flag s_flag;  // 只会初始化一次,初始化为false

		 // 判断s_flag的值,为false时,调用我们提供的初始化函数
        // call_once函数底层有锁,所以只有一个线程会进入call_once内部
        std::call_once(s_flag, [&]() {
            _instance = std::shared_ptr<T>(new T);
            });
        return _instance;
    }
    // 打印单例对象地址
    void PrintAddress() {
        std::cout << _instance.get() << std::endl;
    }
    ~Singleton() {
        std::cout << "this is singleton destruct" << std::endl;
    }
};

template <typename T>
std::shared_ptr<T> Singleton<T>::_instance = nullptr;

想实现单例类,可以通过继承实现单例模式

cpp 复制代码
//想使用单例类,可以继承上面的模板
class LogicSystem :public Singleton<LogicSystem>{
    friend class Singleton<LogicSystem>;
public:
    ~LogicSystem(){}
private:
    LogicSystem(){}
};

总结

如果只是实现一个简单的单例类,推荐使用返回局部静态变量的方式

如果想大规模实现多个单例类,可以用call_once实现的模板类。

相关推荐
BeyondESH22 分钟前
Linux线程同步—竞态条件和互斥锁(C语言)
linux·服务器·c++
豆浩宇31 分钟前
Halcon OCR检测 免训练版
c++·人工智能·opencv·算法·计算机视觉·ocr
WG_1740 分钟前
C++多态
开发语言·c++·面试
Charles Ray2 小时前
C++学习笔记 —— 内存分配 new
c++·笔记·学习
重生之我在20年代敲代码2 小时前
strncpy函数的使用和模拟实现
c语言·开发语言·c++·经验分享·笔记
迷迭所归处8 小时前
C++ —— 关于vector
开发语言·c++·算法
CV工程师小林8 小时前
【算法】BFS 系列之边权为 1 的最短路问题
数据结构·c++·算法·leetcode·宽度优先
white__ice9 小时前
2024.9.19
c++
天玑y9 小时前
算法设计与分析(背包问题
c++·经验分享·笔记·学习·算法·leetcode·蓝桥杯
姜太公钓鲸2339 小时前
c++ static(详解)
开发语言·c++