【C++面试】单例模式:懒汉式与饿汉式的实现、区别与线程安全

一、单例模式到底要解决什么问题

假设程序中有一个日志管理器:

复制代码
class Logger {
public:
    void log(const char* msg) {}
};

如果所有模块都自己创建:

复制代码
Logger log1;
Logger log2;
Logger log3;

那么整个程序可能存在多个日志对象。

但有些资源我们希望:

复制代码
整个程序
   ↓
只有一个实例

例如:

复制代码
日志管理器
配置管理器
资源管理器
线程池管理器

这就是:

复制代码
Singleton
单例模式

它需要满足三个核心条件:

复制代码
1. 外部不能随便创建对象

2. 外部不能复制出新的对象

3. 提供统一接口获取唯一对象

所以代码设计思路就是:

复制代码
构造函数 private
      ↓
禁止外部构造

拷贝构造 delete
      ↓
禁止复制对象

静态 getInstance()
      ↓
返回唯一实例

二、面试最推荐手写的单例模式

现代 C++ 中,最适合现场手写的是:

复制代码
Meyers Singleton

代码可以写得非常短:

复制代码
class Singleton {
private:
    Singleton() {}                                      // 构造函数私有,外部不能创建对象
    ~Singleton() {}                                     // 析构函数私有,由类内部管理

public:
    Singleton(const Singleton&) = delete;               // 禁止拷贝构造
    Singleton& operator=(const Singleton&) = delete;    // 禁止拷贝赋值

    static Singleton& getInstance() { static Singleton instance; return instance; } // 返回唯一实例
};

使用:

复制代码
Singleton& s1 = Singleton::getInstance();               // 获取单例对象
Singleton& s2 = Singleton::getInstance();               // 再次获取的还是同一个对象

这里:

复制代码
static Singleton instance;

是整个代码最核心的地方。

第一次调用:

复制代码
Singleton::getInstance();

时:

复制代码
instance不存在
      ↓
创建instance

以后再次调用:

复制代码
instance已经存在
      ↓
直接返回原对象

所以:

复制代码
s1
 ↓
Singleton对象
 ↑
s2

s1 和 s2 实际上访问的是:

复制代码
同一个Singleton实例

可以验证:

复制代码
#include <iostream>

class Singleton {
private:
    Singleton() { std::cout << "构造\n"; }              // 私有构造
    ~Singleton() { std::cout << "析构\n"; }             // 私有析构

public:
    Singleton(const Singleton&) = delete;               // 禁止拷贝
    Singleton& operator=(const Singleton&) = delete;    // 禁止赋值

    static Singleton& getInstance() { static Singleton instance; return instance; } // 唯一入口
};

int main() {
    Singleton& s1 = Singleton::getInstance();           // 第一次获取
    Singleton& s2 = Singleton::getInstance();           // 第二次获取

    std::cout << &s1 << '\n';                           // 输出地址
    std::cout << &s2 << '\n';                           // 两个地址相同
}

运行时可以发现:

复制代码
&s1 == &s2

所以只有一个对象。

三、为什么构造函数要private,拷贝还要delete

先看:

复制代码
private:
    Singleton() {}

如果构造函数是:

复制代码
public

那么外部就可以:

复制代码
Singleton s1;
Singleton s2;

这就直接创建两个对象了。

单例模式就失效。

所以:

复制代码
构造函数private
       ↓
只能类内部创建对象

但是只把构造函数设为 private 还不够。

例如已经拿到:

复制代码
Singleton& s1 = Singleton::getInstance();

如果允许拷贝:

复制代码
Singleton s2 = s1;

又会产生一个新的:

复制代码
Singleton对象

所以必须禁止:

复制代码
Singleton(const Singleton&) = delete;

也就是:

复制代码
禁止拷贝构造

同时还要:

复制代码
Singleton& operator=(const Singleton&) = delete;

禁止:

复制代码
拷贝赋值

因此一个单例类基本都会看到:

复制代码
Singleton(const Singleton&) = delete;               // 禁止拷贝构造
Singleton& operator=(const Singleton&) = delete;    // 禁止拷贝赋值

如果还想写得更完整,也可以把移动禁止:

复制代码
Singleton(Singleton&&) = delete;                    // 禁止移动构造
Singleton& operator=(Singleton&&) = delete;         // 禁止移动赋值

完整一点就是:

复制代码
class Singleton {
private:
    Singleton() {}                                      // 私有构造
    ~Singleton() {}                                     // 私有析构

public:
    Singleton(const Singleton&) = delete;               // 禁止拷贝构造
    Singleton& operator=(const Singleton&) = delete;    // 禁止拷贝赋值
    Singleton(Singleton&&) = delete;                    // 禁止移动构造
    Singleton& operator=(Singleton&&) = delete;         // 禁止移动赋值

    static Singleton& getInstance() { static Singleton instance; return instance; } // 获取唯一实例
};

不过面试时间比较紧时,通常写:

复制代码
Singleton(const Singleton&) = delete;               // 禁止拷贝
Singleton& operator=(const Singleton&) = delete;    // 禁止赋值

已经能够表达核心思想。

四、为什么这种写法是线程安全的

面试官很可能继续问:

多线程同时第一次调用 getInstance() 怎么办?

例如:

复制代码
Thread A
↓
getInstance()

Thread B
↓
getInstance()

Thread C
↓
getInstance()

如果三个线程同时第一次访问:

复制代码
static Singleton instance;

会不会创建三个对象?

在:

复制代码
C++11及以后

函数内部:

复制代码
局部静态变量

的初始化具有线程安全保证。

也就是说:

复制代码
多个线程同时进入
      ↓
static instance只会初始化一次

所以:

复制代码
static Singleton& getInstance() { static Singleton instance; return instance; }

既实现:

复制代码
懒加载

又解决:

复制代码
第一次初始化的线程安全问题

这也是为什么现代 C++ 面试中,通常优先推荐这种写法。

可以直接这样回答:

C++11 开始,函数局部静态变量的初始化由语言保证线程安全,因此多个线程同时第一次调用 getInstance() 时,instance 也只会构造一次。

注意这里说的是:

复制代码
单例对象初始化线程安全

并不等于:

复制代码
Singleton内部所有成员操作自动线程安全

例如:

复制代码
class Singleton {
private:
    int count_ = 0;

public:
    void add() { ++count_; }
};

多个线程:

复制代码
Singleton::getInstance().add();

同时执行:

复制代码
++count_;

还是可能出现:

复制代码
数据竞争

所以:

复制代码
单例创建线程安全
≠
单例内部业务线程安全

如果成员需要多线程访问,仍然可能需要:

复制代码
std::mutex
std::atomic

等同步方式。

五、懒汉式、饿汉式和面试怎么回答

单例模式还有两个常见概念:

复制代码
懒汉式
饿汉式

1. 懒汉式

核心:

复制代码
用的时候才创建

前面的 Meyers Singleton 就属于:

复制代码
懒加载

static Singleton& getInstance() { static Singleton instance; return instance; } // 第一次调用才创建

如果程序从来不调用:

复制代码
getInstance();

那么:

复制代码
instance就不会被创建

这种方式:

复制代码
按需创建

比较节省资源。


2. 饿汉式

饿汉式:

复制代码
程序启动阶段就提前创建对象

例如:

复制代码
class Singleton {
private:
    Singleton() {}                                      // 私有构造
    static Singleton instance_;                         // 静态唯一实例

public:
    Singleton(const Singleton&) = delete;               // 禁止拷贝
    Singleton& operator=(const Singleton&) = delete;    // 禁止赋值

    static Singleton& getInstance() { return instance_; } // 返回唯一对象
};

Singleton Singleton::instance_;                         // 程序启动阶段构造

可以简单理解:

复制代码
饿汉式
↓
提前创建

懒汉式
↓
需要时创建

以前还经常见到双重检查锁:

复制代码
if (instance_ == nullptr) {
    std::lock_guard<std::mutex> lock(mutex_);

    if (instance_ == nullptr) {
        instance_ = new Singleton;
    }
}

思路是:

复制代码
第一次判断
↓
避免对象已经存在时每次都加锁

加锁
↓
防止多线程同时创建

第二次判断
↓
防止两个线程都通过第一次判断

但是如果面试只是让你:

手写线程安全单例

现代 C++ 最简洁的答案还是:

复制代码
class Singleton {
private:
    Singleton() {}                                      // 私有构造

public:
    Singleton(const Singleton&) = delete;               // 禁止拷贝
    Singleton& operator=(const Singleton&) = delete;    // 禁止赋值

    static Singleton& getInstance() { static Singleton instance; return instance; } // C++11线程安全
};

这个版本非常适合现场手写。

如果面试官问:

单例模式怎么实现?

可以回答:

首先把构造函数设为 private,防止外部直接创建对象;然后删除拷贝构造和拷贝赋值,防止复制出新的实例;最后提供一个 static 的 getInstance() 接口,在函数内部定义一个局部 static 对象并返回它。C++11以后局部静态变量初始化具有线程安全保证,所以这种 Meyers Singleton 是比较常见的实现。

如果继续问:

为什么返回引用,不直接返回对象?

因为如果:

复制代码
static Singleton getInstance();

按值返回,就可能涉及:

复制代码
复制一个Singleton

与单例设计目标冲突。

所以通常:

复制代码
static Singleton& getInstance();

返回:

复制代码
唯一对象的引用

也可以返回指针:

复制代码
static Singleton* getInstance();

但 Meyers Singleton 使用引用更加直接。

如果继续问:

单例模式有什么缺点?

可以回答:

单例本质上引入了全局状态,使用过多会增加模块耦合,让依赖关系不明显,也会增加单元测试和替换实现的难度,所以只有确实需要全局唯一实例时才适合使用。

最后把手写单例需要记的代码压缩一下:

复制代码
class Singleton {
private:
    Singleton() {}                                      // 1. 私有构造

public:
    Singleton(const Singleton&) = delete;               // 2. 禁止拷贝
    Singleton& operator=(const Singleton&) = delete;    // 3. 禁止赋值

    static Singleton& getInstance() { static Singleton instance; return instance; } // 4. 唯一实例
};

真正需要记住的其实只有四步:

复制代码
构造函数private
       ↓
禁止外部new对象

拷贝构造delete
       ↓
不能复制

拷贝赋值delete
       ↓
不能赋值复制

static getInstance()
       ↓
返回唯一static实例

如果是在笔试中要求:

手写一个线程安全单例模式。

直接写下面这版基本就够:

复制代码
class Singleton {
private:
    Singleton() {}                                      // 构造私有

public:
    Singleton(const Singleton&) = delete;               // 禁止拷贝
    Singleton& operator=(const Singleton&) = delete;    // 禁止赋值

    static Singleton& getInstance() { static Singleton obj; return obj; } // C++11线程安全单例
};

这也是单例模式最值得优先掌握的面试写法。

0voice · GitHub

相关推荐
某不知名網友1 小时前
ROS2入门:命令行启动节点与Launch程序
c++·机器人
anew___1 小时前
《从零手写操作系统 (29):管道与重定向进阶——命名管道、Here Document与Shell语法扩展》
java·开发语言·前端·javascript·网络
小米里的大麦2 小时前
C++ 移动语义
c++·后端
jimy12 小时前
虚函数和 RTTI实现运行时多态
开发语言·c++
一个有温度的技术博主2 小时前
凌晨两点的死锁:当线程“互相等死“
开发语言·后端·场景
Είναι η κοπέλα2 小时前
llama.cpp 与 GGUF 格式:本地大模型的“裸引擎“
开发语言·人工智能·pytorch·python·conda
今夜有雨.2 小时前
图像运算、掩膜/ROI 与绘制交互
c语言·数据结构·c++·qt·算法·计算机视觉
UIU1142 小时前
P1028 [NOIP 2001 普及组] 数的计算
c++·算法
IT大白鼠2 小时前
图数据库系列 · 第 08 篇——面试收官:高频题与全景总结
数据库·面试·职场和发展·nosql·图数据库