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,它的构造函数也会在那时被调用。嗯,这里要注意——如果容器的构造函数依赖某些全局状态,那就要小心初始化顺序的问题了。我在项目中遇到过,一个全局配置管理器在构造时读取配置文件,结果另一个单例的静态成员先初始化了,导致配置还没加载就访问了空数据。解决办法?把依赖拆开,或者用局部静态变量替代。
懒汉式:用的时候才创建
懒汉式就是懒加载——第一次调用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失效问题。
std::atomic配合内存序来解决:static std::atomic<Singleton*> instance_;,并在加载和存储时使用memory_order_acquire和memory_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的配合点:
总结
单例模式本身不复杂,但跟多线程、STL容器一结合,坑就多了。我给你的建议是:
- 能用饿汉就别用懒汉——简单就是美,线程安全不用操心。
- 懒汉首选C++11局部静态变量——代码最少,编译器帮你搞定线程安全。
- STL容器做成员时注意线程安全——单例的
getInstance()是线程安全的,但容器本身的读写操作不是。如果多个线程同时读写std::map,记得加锁。 - 避免单例之间的循环依赖——析构顺序不可控,容易崩溃。
嗯,单例模式就聊到这。代码不多,但每个细节都值得推敲。你写的时候多想想:这个实例真的需要全局唯一吗?多线程下会不会出问题?想清楚了,代码自然就稳了。