目錄
一、版本一:禁用構造與拷貝
二、版本二:注冊析構函數/嵌套垃圾回收
(1)使用atexit注冊程序結束時的函數
(2)使用對象嵌套垃圾回收
三、版本三:線程安全
四、版本四:編譯器、CPU指令重排問題
五、版本五:局部靜態變量線程安全性
六、版本六:模板提高復用率
????????單例模式是C++中最常用的一種設計模式,他確保無論在單線程還是多線程中,都只會有一個實例對象,并提供一個全局訪問點。這種模式在配置管理,日志記錄,設備驅動等場景中非常有用。本文將從思維迭代的方式,一步步完善單例模式,方便大家理解和記憶。
? ? ? ? 分析一個設計模式,通常要從穩定點和變化點入手。對于單例模式其穩定點顯然是一個類要提供一個一個全局的訪問方式,且不允許外部任意構造、拷貝。而變化點理論上是沒有的,但是我們強行認為變化點在于使用繼承+模板來擴展單例模式。
一、版本一:禁用構造與拷貝
????????既然單例模式只能提供一個全局訪問點,且不能讓其他人隨意創建,那么很顯然需要禁用其構造函數、拷貝構造等。當然這里是懶漢模式,如果你采取餓漢模式,直接在靜態區創建單例對象,而非創建單例指針,則可以避免析構調不到的問題。下面我們來分析一下:
class Singleton
{
public://獲取全局訪問點static Singleton* GetInstance(){if (_instance==nullptr){_instance = new Singleton();}return _instance;}private:static Singleton* _instance; //全局訪問點//私有化各種構造函數,防止外面任意創建對象
private:Singleton() {}; //構造~Singleton() {}; //析構Singleton(const Singleton&) = delete; //拷貝構造Singleton& operator=(const Singleton&) = delete; //賦值運算符重載Singleton(Singleton&&) = delete; //移動構造Singleton& operator=(Singleton&&) = delete; //移動賦值運算符重載
};
Singleton* Singleton::_instance = nullptr; //初始化靜態成員
????????我們可以看到這個代碼存在一些問題。比如他不能自動調用析構函數(即使我們把析構函數public,再手動調用也會出現信號等情況沒有執行到這里就退出了),因為單例對象的雖然是創建在堆上的,但是其指針在全局靜態區。
????????當程序聲明周期到達、或者以外收到信號退出的時候,該進程的地址空間雖然會被操作系統回收,僅僅會對這個指針銷毀,無法析構其指向的內容(堆上的對象必須要手動調用delete才會被析構)。
? ? ? ? 既然無法調用到析構函數,那么其析構的執行流也無法被執行。當他的析構函數涉及到文件操作、網絡連接等資源。比如關閉文件描述符、刷新文件緩沖區到內核態時就會出問題。舉個例子:日志對象是一個單例對象,他打開了一系列文件,正常情況下手動調用析構函數會正常關閉文件描述符,而關閉文件描述符是一個把用戶態文件緩沖區刷新到內核態的步驟,如果沒有close文件描述符,操作系統會直接回收資源,并不管你用戶態的緩沖區是否有數據沒有刷新,即你丟失了這部分數據。
二、版本二:注冊析構函數/嵌套垃圾回收
? ? ? ? 既然版本一存在這種明顯的無法正確析構的問題。而在c庫中有一個atexit,它可以向操作系統注冊一個函數,該函數僅會在程序正常終止時被調用。
(1)使用atexit注冊程序結束時的函數
class Singleton
{
public://獲取全局訪問點static Singleton* GetInstance(){if (_instance == nullptr){_instance = new Singleton();atexit(Destructor);}return _instance;}private:static void Destructor(){if (_instance != nullptr){delete _instance;_instance = nullptr;}}private:static Singleton* _instance; //全局訪問點//私有化各種構造函數,防止外面任意創建對象
private:Singleton() {}; //構造~Singleton() {}; //析構Singleton(const Singleton&) = delete; //拷貝構造Singleton& operator=(const Singleton&) = delete; //賦值運算符重載Singleton(Singleton&&) = delete; //移動構造Singleton& operator=(Singleton&&) = delete; //移動賦值運算符重載
};
Singleton* Singleton::_instance = nullptr; //初始化靜態成員
(2)使用對象嵌套垃圾回收
????????利用GarbageCollector靜態全局對象在程序正常結束的時候,會自動調用其析構函數,而在他的析構函數中又調用了單例對象的析構函數,從而完成回收。簡單來說就是利用了智能指針RAII的思路。
class Singleton {
private:static Singleton* _instance;// 嵌套垃圾回收類class GarbageCollector {public:~GarbageCollector() {if (Singleton::_instance != nullptr) {delete Singleton::_instance;Singleton::_instance = nullptr;}}};static GarbageCollector _gc; // 全局靜態成員,程序結束時自動析構Singleton() {std::cout << "Singleton created" << std::endl;}~Singleton() {std::cout << "Singleton destroyed" << std::endl;}public:static Singleton* GetInstance() {if (_instance == nullptr) {_instance = new Singleton();}return _instance;}// 禁用拷貝和移動操作Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;Singleton(Singleton&&) = delete;Singleton& operator=(Singleton&&) = delete;
};Singleton* Singleton::_instance = nullptr;
Singleton::GarbageCollector Singleton::_gc;
三、版本三:線程安全
? ? ? ? 雖然版本二在單線程場景下已經足夠使用。但在多線程情況,卻會出現重復走到if,然后創建多個對象的競態問題。
? ? ? ? 這個代碼中使用到了雙重檢測機制。即使在沒有創建單例對象的時候,多個線程進入了第一個if里面,然后會因為鎖競爭只能有一個線程執行到第二個if里面去創建單例對象。當他釋放鎖后,別的線程會繼續競爭鎖并判斷是否為nullptr。如果為空則退出。
? ? ? ? 所以在這個代碼中,只會有第一次n個線程進入if后的n次加鎖、解鎖開銷。
// 雙重檢查鎖定(DCL)
class Singleton
{
private:static Singleton* instance;static std::mutex mtx;Singleton() {}public:static Singleton* getInstance() {if (instance == nullptr) { // ① 第一次檢查(無鎖)std::lock_guard<std::mutex> lock(mtx); // 加鎖if (instance == nullptr) { // ② 第二次檢查(有鎖)instance = new Singleton();atexit(Destructor);// 向操作系統注冊析構函數}}return instance;}private:static void Destructor(){if (instance != nullptr){delete instance;instance = nullptr;}}
private://禁用各種構造Singleton() {}; //構造~Singleton() {}; //析構Singleton(const Singleton&) = delete; //拷貝構造Singleton& operator=(const Singleton&) = delete; //賦值運算符重載Singleton(Singleton&&) = delete; //移動構造Singleton& operator=(Singleton&&) = delete; //移動賦值運算符重載
};
四、版本四:編譯器、CPU指令重排問題
????????解決了多線程競態問題后,發現編譯器、CPU會按照單線程的執行思想,自以為是的優化執行順序,這就導致了new本身可能亂序。
? ? ? ? new操作符在底層會分為三個步驟:
????????其中operator new是基于內存池的,所以他是線程安全的。而構造對象這一步是程序員手動執行的,既不線程安全,執行順序也不能保證。
編譯器或 CPU 為了優化性能,可能把步驟 3 調整到步驟 2 之前,變成:
所以我們需要使用內存屏障來保證執行流的可見性問題。
同時由于對普通指針?instance
?的讀寫不是原子操作。在多線程環境下,可能出現線程 A 寫入指針的 “中間狀態”(比如只更新了低 32 位),線程 B 讀取時拿到一個無效的指針值,直接崩潰。所以用原子操作解決原子性問題。
class Singleton
{
public:static Singleton* getInstance() {Singleton* tmp = instance.load(std::memory_order_acquire); // 讀操作if (tmp == nullptr) {std::lock_guard<std::mutex> lock(mtx);tmp = instance.load(std::memory_order_relaxed);if (tmp == nullptr) {tmp = new Singleton();// 寫操作:禁止重排,保證構造完成后再賦值instance.store(tmp, std::memory_order_release); }}return tmp;}
private:// 用 atomic 修飾指針,禁止指令重排static std::atomic<Singleton*> instance; static std::mutex mtx;
};
std::atomic<Singleton*> Singleton::instance(nullptr);
std::mutex Singleton::mtx;
關于這里的內存屏障、原子操作只需要大致認識即可,后續會有文章詳細講解。
? ? ? ? 雖然這種方式已經足夠,但寫起來太過繁瑣。
五、版本五:局部靜態變量線程安全性
? ? ? ? 在C++11后規定magic static的特性:
- 局部靜態變量(如?
static Singleton instance
)的初始化是線程安全的。若多個線程同時首次調用?GetInstance()
,編譯器會保證只有一個線程執行變量初始化,其他線程會阻塞等待初始化完成后再訪問,無需手動加鎖。即保證了線程安全性,又保證了可見性問題。 - 自動銷毀:程序結束時,局部靜態變量會按構造的逆序自動銷毀,調用?
~Singleton()
?釋放資源。 - 注意:只有局部靜態變量才能這么做,如果是全局靜態變量則未被標準保證,仍需使用之前的方式。
#include <iostream>
// 如需線程安全驗證,可包含此頭文件(C++11及以上環境)
#include <thread> class Singleton {
public:// 核心:局部靜態變量,C++11后保證線程安全初始化//并使用&來保證訪問效率 static Singleton& GetInstance() {static Singleton instance; // 第一次調用時初始化,后續直接返回引用return instance;}// 示例:單例的業務方法void DoSomething() const {std::cout << "Singleton is working, address: " << this << std::endl;}private:// 1. 私有構造:禁止外部直接創建Singleton() {std::cout << "Singleton constructed." << std::endl;}// 2. 私有析構:禁止外部直接銷毀(由系統自動調用)~Singleton() {std::cout << "Singleton destructed." << std::endl;}// 3. 禁用拷貝語義:防止對象復制Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;// 4. 禁用移動語義:防止對象移動Singleton(Singleton&&) = delete;Singleton& operator=(Singleton&&) = delete;
};
????????這種方式也是我們最推薦的寫法,他即不用考慮無法自動析構導致資源泄露的問題,也不用考慮線程安全,最后甚至不需要考慮CPU編譯器的指令重排,可以說局部靜態變量的標準出現,讓單例模式得到了顯著的進步。
????????但有人說,這樣你每寫一個單例類就需要手動禁用一下其構造函數等等,還是稍顯麻煩,那么我們下面的寫法則將他封裝成了一個基類。
六、版本六:模板提高復用率
????????當父類的各種構造被禁用了,子類想要調用對應的構造,首先會調用父類的,然后發現錯誤,實現單例模式,且不需要在子類手動禁用。
// 單例模式基類模板
template <typename T>
class Singleton {
public:// 禁用拷貝構造Singleton(const Singleton&) = delete;// 禁用拷貝賦值Singleton& operator=(const Singleton&) = delete;// 禁用移動構造Singleton(Singleton&&) = delete;// 禁用移動賦值Singleton& operator=(Singleton&&) = delete;// 獲取單例實例static T& getInstance() {// 靜態局部變量,C++11后保證線程安全初始化static T instance;return instance;}protected:// 保護的構造函數,允許子類構造Singleton() = default;// 保護的析構函數,允許子類析構virtual ~Singleton() = default;
};
當你使用的時候,只需要繼承于該基類,然后重寫其中的構造函數、析構函數即可,舉個例子:
// 1. 日志管理器 - 單例應用場景
class Logger : public Singleton<Logger> {friend class Singleton<Logger>;
private:// 私有構造函數,初始化日志系統Logger() {std::cout << "Logger initialized. Starting to log messages..." << std::endl;}// 私有析構函數,清理日志系統~Logger() {std::cout << "Logger shutting down. Finalizing log files..." << std::endl;}public:// 日志級別enum class Level { INFO, WARNING, ERROR };// 記錄日志的方法void log(const std::string& message, Level level = Level::INFO) {// 簡單的線程安全處理std::lock_guard<std::mutex> lock(mtx);// 根據級別輸出不同前綴std::string prefix;switch(level) {case Level::INFO: prefix = "[INFO] "; break;case Level::WARNING: prefix = "[WARNING]"; break;case Level::ERROR: prefix = "[ERROR] "; break;}// 輸出日志信息std::cout << prefix << message << std::endl;}private:std::mutex mtx; // 確保日志輸出線程安全
};
????????這里可以看到他引入了一個友元類,讓基類可以訪問到子類的私有構造、析構函數。在之前的設計模式中由于子類重寫的函數都是public的,所以不需要友元。這一點需要注意一下。