23.单例模式实现区别

01.单例模式实现区别

一、什么是单例模式

需要应用单例模式的对象通常只能有一个实例存在。比如我们每个人只能有一条生命,太阳系只有一个太阳,每个人只有一个身份证号。

单例模式的实现方式是,隐藏构造方法,在内部初始化一次,并提供一个全局的访问点。

通过单例模式,我们可以保证系统中只有一个实例,从而在某些特定的场合下达到节约或者控制系统资源的目的。如下是一些单例使用的场景:

  • 需要频繁创建的一些类,使用单例能够降低系统内存使用率,减少内存分配与回收开销;
  • 某些类在创建实例时会消耗较多资源,或者耗时也比较长,且经常使用,那么使用单例能一直保持这个单例,提升系统资源的使用效率;
  • 频繁访问数据库或者文件的IO对象;
  • 对于一些硬件级别的实例或者逻辑上需要单例的场景;

单例模式有很多实现方式,我们来逐一看下都有哪些,以及它们之间有什么区别和优缺点。

1.1 饿汉模式的单例

class Wife1 {
public:
static Wife1& getWife() {
return wife;
}
private:
// 将构造函数私有化,避免外部程序调用创建对象
Wife1() {}
private:
static Wife1 wife; // 静态实例,程序启动时(静态初始化阶段)即创建
};
// 类外定义静态成员:饿汉式的关键——一开始就创建好唯一实例
Wife1 Wife1::wife;

上面这种模式在程序启动进行静态初始化的时候就创建了唯一的单例对象,因此这也是饿汉名字的来源。它的好处就是绝对的线程安全,在需要获取单例实例的时候执行效率较高,因为早就准备好了。比较适合在系统中单例个数不多的情况下,否则会造成内存浪费。

1.2 懒汉模式的单例

class Wife2 {
public:
static Wife2* getWife() {
if (wife == nullptr) {
wife = new Wife2();
}
return wife;
}
private:
Wife2() {}
static Wife2* wife; // 初始为 nullptr,使用时才创建
};
Wife2* Wife2::wife = nullptr;

懒汉模式的示例对象只有在真正需要的时候才会被初始化,但由此也产生一个问题,它不是线程安全的,多线程情况下,可能会同时创建出多个实例对象。这是如何发生的呢?其实很简单:

我们假设有两个线程A和B同时调用getWife方法,A线程在判断完if(wife == null)之后为true,然后被阻塞了(阻塞方式有很多),此时轮到B线程判断if(wife == null),结果也为true,那么它们两个同时认为当前没有人创建过实例,因此都会在接下来创建实例对象。

1.3 同步模式的单例

#include <mutex>
class Wife3 {
public:
static Wife3* getWife() {
// 进入方法即加互斥锁,同一时间只有一个线程能进入方法内部
std::lock_guard<std::mutex> lock(mtx);
if (wife == nullptr) {
wife = new Wife3();
}
return wife;
}
private:
Wife3() {}
static Wife3* wife;
static std::mutex mtx; // 互斥锁,对应 Java 的 synchronized
};
Wife3* Wife3::wife = nullptr;
std::mutex Wife3::mtx;

虽然我们在方法的调用上确保了同步,即同一时间只能有一个线程进入到方法的内部进行执行,但是这却带来了方法执行低效的问题,这是为什么呢?

如果说,实例对象却是从来没有被创建过,那么当前执行该方法的线程理所当然需要走if(wife == null)的逻辑,但是如果已经有实例对象被创建好了,每个线程每次调用都得走这个判断逻辑,而且还是同步的方式,效率当然低了。这就好比千军万马本来只需要走一个1米的独木桥(即例子中不需要判断if(wife == null)逻辑的情况)现在却不得不走2米的独木桥。

1.4 双重检查锁模式的单例

#include <mutex>
class Wife4 {
public:
static Wife4* getWife() {
if (wife == nullptr) { // 第一次检查(无锁快速路径)
std::lock_guard<std::mutex> lock(mtx);
if (wife == nullptr) { // 第二次检查(防止重复创建)
wife = new Wife4();
}
}
return wife;
}
private:
Wife4() {}
static Wife4* wife;
static std::mutex mtx;
};
Wife4* Wife4::wife = nullptr;
std::mutex Wife4::mtx;

这种模式就是上面所说的,走独木桥的方法被优化了,第一个走的人需要走2米,后面其他人都只要走1米了,这边有2个疑问:

  1. 为什么在获取锁之后还要加上一个非空判断呢?

    答案和上面的同步模式的单例情况差不多。同样假设有两个线程A和B同时进入了wife为空的判断逻辑,它们只是在竞争互斥锁时分出先后,然后依然会依次执行完临界区中创建对象的代码,只是谁先谁后执行的问题。所以,必须在创建对象前,再次进行判断,确认还未有人创建才可以继续创建。

  2. Java 版本为什么要把静态变量wife声明为volatile?C++ 也需要吗?

    Java 中 new Wife4() 不是原子操作(分配内存、执行构造、赋值引用三步可能被重排),volatile 用于保证可见性并禁止指令重排。而 C++ 的 volatile没有线程同步语义,不能解决这个问题;C++11 起的正确做法是改用 std::atomic 指针,或直接采用下面 1.5 节的局部静态变量写法——标准保证其初始化线程安全,无需任何手动同步。

1.5 局部静态变量模式的单例(Meyers’ Singleton)

class Wife5 {
public:
static Wife5& getWife() {
// C++11 起,局部静态变量的初始化由标准保证线程安全(Magic Statics)
// 且只在第一次调用 getWife() 时才构造,天然懒加载
static Wife5 wife;
return wife;
}
private:
Wife5() {}
};

我们可以看到局部静态变量 wife 定义在 getWife() 函数内部,除了 getWife() 之外没有其它方式可以访问到wife实例对象,而且只有在调用getWife方法时,才会真正构造该静态变量,做到了懒加载。

至于线程安全方面,C++11 标准明确规定:如果多个线程同时首次进入初始化局部静态变量的语句,其中一个线程执行初始化,其余线程会等待其完成(俗称 Magic Statics)。所以当调用getWife()方法的时候,wife对象的构造是线程安全的,无需手动加锁。

但是这样的写法看似简单、高效、完美,但是如果没有禁用拷贝,就会在复制对象时出现问题:

int main() {
Wife5& wife1 = Wife5::getWife(); // 拿到唯一实例的引用,没问题
Wife5 wife2 = wife1; // 拷贝构造:绕过私有构造函数,"制造"出了第二个实例!
Wife5 wife3 = wife2; // 还可以继续复制
return 0;
}

如此,单例的写法就会被拷贝构造所破坏,我们可以对拷贝构造与赋值运算符做下禁用,来防止这种情况。

class Wife5 {
public:
static Wife5& getWife() {
static Wife5 wife;
return wife;
}
// 禁用拷贝构造与拷贝赋值,防止复制出多个实例
Wife5(const Wife5&) = delete;
Wife5& operator=(const Wife5&) = delete;
private:
Wife5() {}
};

1.6 枚举模式的单例(Java 特有)及 C++ 的等价写法

#include <string>
// C++ 没有枚举单例的惯用法,与 Java 枚举单例地位等价的
// 是"Meyers 单例 + 禁用拷贝赋值"的完整写法:
class EnumWifeSingleton {
public:
static EnumWifeSingleton& getWife() {
static EnumWifeSingleton instance; // 线程安全 + 懒加载
return instance;
}
// 对应 Java 枚举"天然防反射、防反序列化破坏"的能力
EnumWifeSingleton(const EnumWifeSingleton&) = delete;
EnumWifeSingleton& operator=(const EnumWifeSingleton&) = delete;
void setName(const std::string& name) { this->name = name; }
const std::string& getName() const { return name; }
private:
EnumWifeSingleton() {}
std::string name;
};
#include <iostream>
int main() {
EnumWifeSingleton& marry = EnumWifeSingleton::getWife();
marry.setName("marry");
std::cout << "wife's name is :" << marry.getName() << std::endl;
EnumWifeSingleton& lily = EnumWifeSingleton::getWife();
std::cout << "marry is lily?:" << (&marry == &lily) << std::endl;
return 0;
}

Java 中的枚举单例利用了枚举默认线程安全、防反射、防反序列化的特性。C++ 没有对应的枚举机制,但 C++ 中破坏单例的途径只有拷贝与赋值,因此上面”Meyers 单例 + = delete”的完整写法就是 C++ 中与之地位等价的推荐实现。

这种写法还是懒加载的,不存在饿汉模式提前创建实例的内存浪费问题,因此同样适用于需要大量单例的场景。

1.7 容器注册式的单例

在实际应用场景中,如果有大规模的单例需要创建,肯定需要有线考虑实现懒加载,那么就推荐使用容器注册式的单例:

#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
class ContainerRegistrySingleton {
public:
/**
* 注册"类型名 -> 创建函数"
* C++ 没有反射,无法像 Class.forName 那样按类名实例化,
* 因此改为显式注册工厂函数
*/
static void registerBean(const std::string& className,
std::function<std::shared_ptr<void>()> creator) {
std::lock_guard<std::mutex> lock(mtx);
if (ioc.find(className) == ioc.end()) {
ioc[className] = creator(); // 首次注册时创建(也可改为 getBean 时懒创建)
}
}
static std::shared_ptr<void> getBean(const std::string& className) {
std::lock_guard<std::mutex> lock(mtx);
auto it = ioc.find(className);
return it != ioc.end() ? it->second : nullptr;
}
private:
/**
* 私有化构造函数
*/
ContainerRegistrySingleton() {}
/**
* ioc容器,集中存放管理所有单例
*/
static std::unordered_map<std::string, std::shared_ptr<void>> ioc;
static std::mutex mtx;
};
std::unordered_map<std::string, std::shared_ptr<void>> ContainerRegistrySingleton::ioc;
std::mutex ContainerRegistrySingleton::mtx;

其中的ContainerRegistrySingleton就相当于是Bean的单例容器,从它这里获取的各个Bean确保都是单例的,这种写法也正是各类 IoC / 依赖注入容器的雏形(Java Spring 中对应 DefaultSingletonBeanRegistry 的实现思路)。

1.8 thread_local 实现线程单例

C++11 引入的 thread_local 关键字的作用就是为每个线程保存一份自己独有的变量副本,线程内首次访问时构造、线程结束时销毁。

class ThreadLocalSingleton {
public:
static ThreadLocalSingleton& getInstance() {
// thread_local:每个线程一份独立实例,线程内首次访问时构造
static thread_local ThreadLocalSingleton instance;
return instance;
}
private:
/**
* 私有化构造函数
*/
ThreadLocalSingleton() {}
};
#include <iostream>
#include <thread>
void customerThread() {
std::cout << &ThreadLocalSingleton::getInstance() << std::endl;
}
int main() {
// 主线程就一份该变量,即使调用三次,获取的都是同一个
std::cout << &ThreadLocalSingleton::getInstance() << std::endl;
std::cout << &ThreadLocalSingleton::getInstance() << std::endl;
std::cout << &ThreadLocalSingleton::getInstance() << std::endl;
// 每个线程获取的是单例,线程之间获取的不是相同的对象实例
std::thread t1(customerThread);
std::thread t2(customerThread);
t1.join();
t2.join();
return 0;
}
000001F3A2C05470
000001F3A2C05470
000001F3A2C05470
000001F3A2C05D10
000001F3A2C05F60

前三次输出(主线程)地址相同,后两次(两个子线程)地址各不相同。

使用 thread_local 的如上方式,在线程内获取实例getInstance能确保是单例的,这是由于 thread_local 变量由编译器和运行时保证每个线程拥有独立的存储,线程内首次访问时构造、线程结束时销毁;但是不同线程各自获取到的实例是不同的,此时就不是(全局意义上的)单例了。

1.9 总结

实现方式懒加载线程安全性能
饿汉模式
懒汉模式
同步模式低——互斥锁
双重检查锁
局部静态变量(Meyers 单例)
枚举模式(Java 特有)
容器注册模式低——互斥锁
thread_local 单线程模式

Thanks for reading!

23.单例模式实现区别

2026-01-22
2878 字 · 14 分钟

已复制链接

评论区

目录