Loading... > 单件设计模式定义:保证一个类仅有一个实例存在,同时提供能对该实例访问的全局方法(getInstance成员函数) 违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性 # 一、定义 \*\*单例设计模式(Singleton Design Pattern)\*\*理解起来非常简单。 一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作**单例设计模式**,简称单例模式。 Question: 为什么我们需要单例这种设计模式?它能解决哪些问题? ## 1、应用场景一:处理资源访问冲突 我们先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的 Logger 类。具体的代码实现如下所示: ```cpp class Logger { public: Logger() { File file = new File("/Users/zheng/log.txt"); writer_ = new FileWriter(file, true); //true表示追加写入 } void log(String message) { writer_->write(message); } private: FileWriter* writer_; } // Logger类的应用示例: class UserController { public: void login(String username, String password) { // ...省略业务逻辑代码... logger_->log(username + " logined!"); } private: Logger* logger_ = new Logger(); } class OrderController { public: void create(OrderVo order) { // ...省略业务逻辑代码... logger_->log("Created an order: " + order.toString()); } private: Logger* logger_ = new Logger(); } ``` 所有的日志都写入到同一个文件 /Users/zheng/log.txt 中。当在多线程的情况下,如果两个线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。 **为什么会出现互相覆盖呢?** 在多线程环境下,如果两个线程同时给同一个共享变量加 1,因为共享变量是竞争资源,所以,共享变量最后的结果有可能并不是加了 2,而是只加了 1。同理,**这里的 log.txt 文件也是竞争资源**,两个线程同时往里面写数据,就有可能存在互相覆盖的情况。 解决办法: ### Java的例子 case1: 通过加锁的方式:给 log() 函数加互斥锁(Java 中可以通过 synchronized 的关键字),同一时刻只允许一个线程调用执行 log() 函数。具体的代码实现如下所示: ```cpp //Java的写法 public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/zheng/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public void log(String message) { synchronized(this) { writer.write(mesasge); } } } //换成C++ 如下 class Logger { public: Logger() { File* file = new File("/Users/zheng/log.txt"); writer_ = new FileWriter(file, true); //true表示追加写入 } void log(String message) { mutex_.lock() { writer_.write(mesasge); } mutex_.unlock(); } private: FileWriter* writer_; std::mutex mutex_; } ``` 这真的能解决多线程写入日志时互相覆盖的问题吗?答案是否定的。这是因为,`这种锁是一个对象级别的锁`,一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行。但是,**不同的对象之间并不共享同一把锁**。在不同的线程下,通过不同的对象调用执行 log() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。  case2: 如何修复case1遇到的问题,**在JAVA中,可以使用类级别的锁,但是C++中没有这种概念** 我们只需要把对象级别的锁,**换成类级别的锁就可以了**。让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用 log() 函数。 ```cpp public class Logger { private FileWriter writer; public Logger() { File file = new File("/Users/wangzheng/log.txt"); writer = new FileWriter(file, true); //true表示追加写入 } public void log(String message) { synchronized(Logger.class) { // 类级别的锁 writer.write(mesasge); } } } ``` 解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情。除此之外,并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。 ### 在C++中如何去解决 case3:将 Logger 设计成一个单例类,程序中只允许创建一个 Logger 对 所有的线程共享使用的这一个 Logger 对象,**共享一个 FileWriter 对象,而 FileWriter 本身是对象级别线程安全的**,也就避免了多线程情况下写日志会互相覆盖的问题。 ```cpp class Logger { private: FileWriter* writer_; Logger* instance_; Logger() { File* file = new File("/Users/zheng/log.txt"); writer_ = new FileWriter(file, true); //true表示追加写入 } public: static Logger* getInstance() { //加锁 if (instance_ == nullptr) { instance_ = new Logger(); } return instance_; } void log(String message) { writer_->write(mesasge); } }; // Logger类的应用示例: class UserController { public: void login(String username, String password) { // ...省略业务逻辑代码... Logger::getInstance()->log(username + " logined!"); } } class OrderController { public: void create(OrderVo order) { // ...省略业务逻辑代码... Logger::getInstance()->log("Created a order: " + order.toString()); } } ``` Note: 这里有个前提条件:**一个对象在不同的线程下同时调用 log() 函数,会被强制要求顺序执行** 不满足这个条件的话仍然存在线程安全问题,还是会出现覆盖的情况。 ## 2、应用场景二: > **如果有些数据在系统中只应保存一份,那就比较适合设计为单例类** 比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。 ```cpp class CConfig { public: ~CConfig(); static CConfig* GetInstance() //在主线程中调用 这样就不会有缺陷 { if(m_instance == NULL) { //锁 if(m_instance == NULL) { m_instance = new CConfig(); static CGarhuishou cl;//这里是静态成员函数 要释放 } //放锁 } return m_instance; } class CGarhuishou //类中套类,用于释放对象 { public: ~CGarhuishou() { if (CConfig::m_instance) { delete CConfig::m_instance; CConfig::m_instance = NULL; } } }; public: //用于加载配置文件的相关参数 bool Load(const char *pconfName); //装载配置文件 const char *GetString(const char *p_itemname); int GetIntDefault(const char *p_itemname,const int def); std::vector<LPCConfItem> m_ConfigItemList; //存储配置信息的列表 private: CConfig(); static CConfig *m_instance; }; ``` 用于加载配置文件的类CConfig,在整个项目中只会实例化一份 # 二、如何实现一个单例? 代码上要实现一个单例,**`我们需要关注的点无外乎下面几个`**: * 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例; * 考虑对象创建时的线程安全问题; * 考虑是否支持延迟加载;这里的延迟加载,就是真正用的时候再去加载,而不是一开始就加载上去。 原因:内存有限,对象过大都会导致直接加载的话,对内存有影响或者影响效率 解决:啥时候用我再加载,那啥时候用呢,就是getInstance()的时候。 * 考虑 getInstance() 性能是否高(是否加锁)。 ## 1、单例有下面几种经典的实现方式 先看一个最简单的版本: ```cpp class GameConfig { //...... private: GameConfig() {}; GameConfig(const GameConfig& tmpobj); GameConfig& operator=(const GameConfig& tmpobj); ~GameConfig() {} static GameConfig* m_instance;//指向本类对象的指针 public: static GameConfig* getInstance() { if (m_instance == nullptr) { m_instance = new GameConfig(); } return m_instance; } } GameConfig* GameConfig::m_instance = nullptr;//在类外,某个.cpp文件的开头位置,为静态成员变量赋值 ``` 为了保证是单件类,**构造、拷贝构造、赋值操作、析构操作(无法delete)**都**被设置为私有**。则下面的使用都会报错: ```cpp GameConfig g_config1; GameConfig* g_gct = new GameConfig(); GameConfig g_gc3(*g_gc); (*g_gc2) = (*g_gc); delete g_gc; ``` **多线程中会有问题,当一个线程执行完这句话if (m\_instance == nullptr)之后,时间片发生了切换,另外一个线程刚好也执行到了这句话,也走进去了,就会出现多个线程都会创建这个实例对象。** 解决办法:粗暴加锁 ```cpp class GameConfig { //...... private: GameConfig() {}; GameConfig(const GameConfig& tmpobj); GameConfig& operator=(const GameConfig& tmpobj); ~GameConfig() {} static GameConfig* m_instance;//指向本类对象的指针 public: static GameConfig* getInstance() { std::lock_guard<std::mutex> gcguard(my_mutex); //粗暴加锁,不建议采用 if (m_instance == nullptr) { m_instance = new GameConfig(); } return m_instance; } } GameConfig* GameConfig::m_instance = nullptr;//在类外,某个.cpp文件的开头位置,为静态成员变量赋值 ``` 加上std::lock\_guard[std::mutex](std::mutex) gcguard(my\_mutex); 这句话之后,在一个线程执行完getInstance之前,其他线程必须等待。这样就保证了m\_instance = new GameConfig();不会被两个线程执行,但是**这样效率很低**,因为如果程序中频繁调用getInstance的话,会影响程序执行效率,因为这把锁其实只对第一次创建的时候是有效的,后面都是**只读对象**意义不大。 ### 1.1、双重检查 **饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发**。 **双重检测实现方式既支持延迟加载、又支持高并发的单例实现方式**。只要 instance 被创建之后,再调用 getInstance() 函数都不会进入到加锁逻辑中。所以,这种实现方式解决了懒汉式并发度低的问题。 ```cpp class GameConfig { //...... private: GameConfig() {}; GameConfig(const GameConfig& tmpobj); GameConfig& operator=(const GameConfig& tmpobj); ~GameConfig() {} public: static GameConfig* getInstance() { if (m_instance == nullptr)//在m_instance 可能被new过或者可能没被new过的情况下进行加锁 { //这里加锁(双重锁定/双重检查)——潜在问题:内存访问重新排序(重新排列编译器产生的汇编指令)导致双重锁定失效问题。 volatile。 std::lock_guard<std::mutex> gcguard(my_mutex); if (m_instance == nullptr) //if(m_instance != nullptr) { m_instance = new GameConfig(); } } return m_instance; } } GameConfig* GameConfig::m_instance = nullptr;//在类外,某个.cpp文件的开头位置,为静态成员变量赋值 ``` 双重加锁的情况,绝大部分时候都不会再执行if (m\_instance == nullptr) 这条语句。 双重的if在这里另有妙用,可以让lock的调用开销降低到最小。 但是: **这样的代码存在潜在问题:内存访问重新排序(重新排列编译器产生的汇编指令)导致双重锁定失效问题。换句话说,就是线程1中未初始化完的内容被线程2拿去使用了。** m\_instance = new GameConfig(); 其实**指令主要**包括1.分配一块内存 2.调用构造函数初始化这块内存 3.初始化一个指针指向这块内存,其中2和3的顺序是有可能互换的。 解决办法A: ```cpp class GameConfig { private: GameConfig() {}; GameConfig(const GameConfig& tmpobj); GameConfig& operator = (const GameConfig& tmpobj); ~GameConfig() {}; public: static GameConfig* getInstance() { GameConfig* tmp = m_instance.load(std::memory_order_relaxed); std::atomic_thread_fence(std::memory_order_acquire);//可参考https://blog.csdn.net/wxj1992/article/details/103917093 if (tmp == nullptr) { std::lock_guard<std::mutex> lock(m_mutex); tmp = m_instance.load(std::memory_order_relaxed); if (tmp == nullptr) { tmp = new GameConfig(); std::atomic_thread_fence(std::memory_order_release); m_instance.store(tmp, std::memory_order_relaxed); } } return tmp; } private: static atomic<GameConfig*> m_instance; static std::mutex m_mutex; }; std::atomic<GameConfig*> GameConfig::m_instance; std::mutex GameConfig::m_mutex ``` Note: **java**中要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,**禁止指令重排序才行**。 一个好的解决多线程创建GameConfig类对象问题的方法是在main主函数中(程序执行入口中),在创建任何其他线程之前, volatile 每次都是从内存中读取、写入, 解决办法B: 通常情况下调用CPU提供的一条指令,这条指令常常被称为barrier。一条barrier的指令会阻止CPU将该指令之前的指令交换到barrier之后,反之亦然。换句话说,barrier指令的作用类似于一个拦水坝,阻止换序”穿透”这个大坝。 ```cpp P30 程序员修养book P30P ``` ### 1.2、饿汉式 饿汉式的实现方式,在类加载的期间,就已经将 instance 静态实例初始化好了,所以,instance 实例的创建是线程安全的。不过,**这样的实现方式不支持延迟加载实例**。 ```cpp //饿汉式(很饥渴很迫切之意)——程序一致性,不管是否调用了getInstance成员函数,这个单件类就已经被创建了(对象创建不受多线程问题困扰)。 class GameConfig { //...... private: GameConfig() {}; GameConfig(const GameConfig& tmpobj); GameConfig& operator=(const GameConfig& tmpobj); ~GameConfig() {} public: static GameConfig* getInstance() { return m_instance; } private: static GameConfig* m_instance; }; GameConfig* GameConfig::m_instance = new GameConfig(); //趁静态成员变量定义的时候直接初始化是允许的,及时GameConfig构造函数是用private修饰 ``` 即便没有调用getInstance,这个单例也已经被new出来了,因为静态成员的初始化是在main函数执行之前。这样的话 getInstance都不需要加锁了。 **有人觉得这种实现方式不好,因为不支持延迟加载**,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。 如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。 Note: 在饿汉式的情况下, **比如有全局变量int g\_test = GameConfig::getInstance()->m\_i;这种场景会产生异常** 全局变量的初始化顺序是不能被保证的的,可能g\_test 先被初始化,而GameConfig::getInstance()后被初始化。 **解决办法:将GameConfig::getInstance()->m\_i 放到main函数中**。 ### 1.3、懒汉式 **懒汉式相对于饿汉式的优势是支持延迟加载**。这种实现方式会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调用会产生性能瓶颈。 ```cpp //懒汉式(很懒惰之意)——程序执行后该单件类对象并不存在,只有第一次调用getInstance成员函数时该单件类对象才被创建 class GameConfig { //...... private: GameConfig() {}; GameConfig(const GameConfig& tmpobj); GameConfig& operator=(const GameConfig& tmpobj); ~GameConfig() {}; static GameConfig* m_instance; public: static GameConfig* getInstance() { if (m_instance == nullptr) { m_instance = new GameConfig(); } return m_instance; } } GameConfig* GameConfig::m_instance = nullptr;//在类外,某个.cpp文件的开头位置,为静态成员变量赋值 ``` 不过懒汉式的缺点也很明显,就是要加锁避免多线程,或者是在main函数中创建其他线程之前,调用一次getInstance,实例化这个对象。这样其实就和饿汉式差不多了。 在Java中: 我们给 getInstance() 这个方法加了一把大锁(synchronzed),**导致这个函数的并发度很低**。 量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。 ### 1.4、静态内部类 推荐用这种方法 > **在多线程情况下,需要使用饿汉实现或者是这种静态内部类的方式实现** 利用静态内部类来实现单例,将指针类型m\_instance改为引用类型(对象类型),返回局部静态成员的引用 ```cpp class GameConfig// 其实命名可以为Singleton { //...... private: GameConfig() {}; GameConfig(const GameConfig& tmpobj); GameConfig& operator=(const GameConfig& tmpobj); ~GameConfig() {} public: static GameConfig& getInstance() { static GameConfig instance; //注意区别 函数第一次执行时被初始化的静态变量 与 // 与通过编译期常量进行初始化的基本类型静态变量 return instance; } }; //main函数的使用 GameConfig& g_gc40 = GameConfig::getInstance(); ``` static GameConfig instance; //注意区别 函数第一次执行时被初始化的静态变量 调用第一次getInstance的时候才会被初始化,才会给他分配内。 **这个版本仍然存在多线程问题,所以也是最好在主线程中创建这个单例对象**。 在**C++11及以上环境**中,这种实现在C++11及以后的版本中是完全线程安全的,**局部静态变量版(Meyers' Singleton)是最优选择**,原因如下: * **延迟加载**:仅在需要时创建实例,平衡了启动速度和资源利用率; * **线程安全**:无需额外同步代码(如互斥锁),标准直接保证安全性; * **代码简洁**:无需处理指针、内存释放或复杂的双重检查锁定(DCLP); * **自动管理生命周期**:实例随程序结束自动销毁,避免内存泄漏。 最后修改:2025 年 07 月 27 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏