94、STL与单例模式:全局唯一、线程安全、懒汉与饿汉

单例模式,说白了就是一个类在整个程序生命周期里只能有一个实例。你想想看,有些东西确实没必要搞出多个来——比如日志管理器、配置中心、线程池。搞两个日志实例?那日志写到哪去?我早年接手过一个遗留项目,日志系统没做单例,结果三个模块各写各的文件,排查问题的时候简直想骂人。

那STL跟单例模式有什么关系?关系大了。单例里经常要存全局数据,比如一个全局的std::map做配置缓存,或者一个std::vector做任务队列。STL容器用得好,单例的实现就稳;用得不好,线程安全就崩给你看。

饿汉式:简单粗暴,启动即创建

饿汉式的思路很简单:程序一启动,实例就new好。你还没用呢,它已经在那等着了。好处是啥?线程安全天然保证——因为实例在main函数之前就初始化好了,根本不存在多线程竞争的问题。

class Singleton {
public:
    static Singleton& getInstance() {
        return instance_;
    }
    void addLog(const std::string& msg) {
        logs_.push_back(msg);
    }
    void printLogs() const {
        for (const auto& log : logs_) {
            std::cout << log << std::endl;
        }
    }
private:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton instance_;
    std::vector<std::string> logs_;
};

// 在cpp文件中定义
Singleton Singleton::instance_;

这里有个细节:instance_是静态成员变量,它在程序启动时就被构造。如果你用STL容器,比如std::vector,它的构造函数也会在那时被调用。嗯,这里要注意——如果容器的构造函数依赖某些全局状态,那就要小心初始化顺序的问题了。我在项目中遇到过,一个全局配置管理器在构造时读取配置文件,结果另一个单例的静态成员先初始化了,导致配置还没加载就访问了空数据。解决办法?把依赖拆开,或者用局部静态变量替代。

饿汉式适合那些启动时就必须就绪的服务,比如日志系统、内存池。如果实例创建很重(比如加载几百MB的词典),那启动时间会变长,这时候就要考虑懒汉式了。

懒汉式:用的时候才创建

懒汉式就是懒加载——第一次调用getInstance()时才创建实例。好处是启动快,坏处是线程安全得自己操心。

最原始的写法是这样的:

class LazySingleton {
public:
    static LazySingleton* getInstance() {
        if (instance_ == nullptr) {
            instance_ = new LazySingleton();
        }
        return instance_;
    }
private:
    static LazySingleton* instance_;
};

LazySingleton* LazySingleton::instance_ = nullptr;

这段代码在单线程下没问题。但多线程下呢?两个线程同时进入getInstance(),都发现instance_ == nullptr,然后各自new了一个对象——单例就破了。我当年第一次写多线程程序时就踩过这个坑,调试了一下午才发现是单例被创建了两次。

线程安全的懒汉式:双检锁 + 内存屏障

解决多线程问题,经典方案是双检锁(Double-Checked Locking):

#include <mutex>

class ThreadSafeLazySingleton {
public:
    static ThreadSafeLazySingleton& getInstance() {
        if (instance_ == nullptr) {
            std::lock_guard<std::mutex> lock(mutex_);
            if (instance_ == nullptr) {
                instance_ = new ThreadSafeLazySingleton();
            }
        }
        return *instance_;
    }
private:
    static ThreadSafeLazySingleton* instance_;
    static std::mutex mutex_;
};

ThreadSafeLazySingleton* ThreadSafeLazySingleton::instance_ = nullptr;
std::mutex ThreadSafeLazySingleton::mutex_;

这里用了两层检查:外层检查避免每次调用都加锁,内层检查保证只有一个线程执行new。但你以为这就完了?C++里还有个坑——指令重排。编译器或CPU可能把new操作拆成三步:分配内存、调用构造函数、赋值给instance_。如果第三步先于第二步执行,另一个线程看到instance_非空就直接返回了,但对象还没构造完。这就是经典的DCL失效问题。

C++11之后,可以用std::atomic配合内存序来解决:static std::atomic<Singleton*> instance_;,并在加载和存储时使用memory_order_acquirememory_order_release。但说实话,我更推荐直接用局部静态变量——C++11保证了它的线程安全初始化。

C++11的终极方案:局部静态变量

C++11标准规定:函数内的静态变量初始化是线程安全的。编译器会自动加锁保证只有一个线程执行初始化。代码简洁到令人发指:

class ModernSingleton {
public:
    static ModernSingleton& getInstance() {
        static ModernSingleton instance;
        return instance;
    }
    void setConfig(const std::string& key, const std::string& value) {
        config_[key] = value;
    }
    std::string getConfig(const std::string& key) const {
        auto it = config_.find(key);
        return it != config_.end() ? it->second : "";
    }
private:
    ModernSingleton() = default;
    ~ModernSingleton() = default;
    ModernSingleton(const ModernSingleton&) = delete;
    ModernSingleton& operator=(const ModernSingleton&) = delete;

    std::map<std::string, std::string> config_;
};

你看,没有指针,没有new,没有delete,没有mutex。一个static搞定所有。而且std::map作为成员变量,生命周期和单例完全一致——程序结束时自动析构。我个人习惯用这种方式,除非有特殊需求(比如需要手动控制析构时机)。

局部静态变量的唯一缺点是:你无法控制它的析构顺序。如果两个单例互相依赖对方的析构函数,那程序退出时可能崩溃。解决办法?尽量让单例之间没有循环依赖,或者用std::shared_ptr管理生命周期。

STL容器在单例中的实战:全局配置中心

我做一个项目时,需要一个全局配置中心。所有模块启动时从配置文件读取参数,存到单例里,后续直接查询。用std::unordered_map做存储,查询效率O(1):

class ConfigManager {
public:
    static ConfigManager& getInstance() {
        static ConfigManager instance;
        return instance;
    }

    void loadFromFile(const std::string& path) {
        std::ifstream file(path);
        std::string line;
        while (std::getline(file, line)) {
            auto pos = line.find('=');
            if (pos != std::string::npos) {
                std::string key = line.substr(0, pos);
                std::string value = line.substr(pos + 1);
                config_[key] = value;
            }
        }
    }

    template<typename T>
    T get(const std::string& key, const T& defaultVal = T{}) const {
        auto it = config_.find(key);
        if (it != config_.end()) {
            std::istringstream iss(it->second);
            T val;
            iss >> val;
            return val;
        }
        return defaultVal;
    }

private:
    ConfigManager() = default;
    std::unordered_map<std::string, std::string> config_;
};

使用的时候:

int main() {
    ConfigManager::getInstance().loadFromFile("config.ini");
    int timeout = ConfigManager::getInstance().get<int>("timeout", 30);
    std::string host = ConfigManager::getInstance().get<std::string>("host", "localhost");
    return 0;
}

这里有个小技巧:get<T>模板函数支持任意可流式化的类型,int、double、string都能直接转。我曾经在项目里用这个模式管理了上百个配置项,配合std::thread做热加载,运行得很稳。

知识体系图

下面这张图总结了单例模式的核心分支和STL的配合点:

单例模式 饿汉式 懒汉式 静态成员变量初始化 线程安全(main之前) 启动慢,运行时快 第一次调用时创建 需处理线程安全 双检锁 / 局部静态 STL容器作为成员 std::map / std::vector / std::unordered_map

总结

单例模式本身不复杂,但跟多线程、STL容器一结合,坑就多了。我给你的建议是:

  • 能用饿汉就别用懒汉——简单就是美,线程安全不用操心。
  • 懒汉首选C++11局部静态变量——代码最少,编译器帮你搞定线程安全。
  • STL容器做成员时注意线程安全——单例的getInstance()是线程安全的,但容器本身的读写操作不是。如果多个线程同时读写std::map,记得加锁。
  • 避免单例之间的循环依赖——析构顺序不可控,容易崩溃。

嗯,单例模式就聊到这。代码不多,但每个细节都值得推敲。你写的时候多想想:这个实例真的需要全局唯一吗?多线程下会不会出问题?想清楚了,代码自然就稳了。


公众号:蓝海资料掘金营,微信deep3321