7、C++高频面试真题|61-74

厨子大约 42 分钟编程语言原创技术面试题解析C++面试程序员求职程序厨

C++ 面试真题(61-74)

61. C++ 什么场景用线程?什么场景用协程?

回答重点

线程和协程是两种不同的并发编程方式,各有其适用的场景。

1)线程使用场景:

  • **CPU 密集型任务:**线程适合处理需要大量计算的任务,如矩阵运算、复杂算法的并行处理。
  • **I/O 密集型任务:**线程更适用于处理需要频繁与外部系统进行数据交换的任务,如网络请求、文件读写。
  • **多核处理器充分利用:**当你希望充分利用多核处理器的优势,进行真正的并行计算时,线程非常适合。

2)协程使用场景:

  • **轻量级任务切换:**协程适用于需要轻量级任务切换的场景,像是大量小任务需要被并发执行时,比如异步的任务处理、网络服务器等等。
  • **高并发处理:**在处理大量高并发请求时,协程更合适,因为它的开销相对于线程更小,如高并发的 web 服务器处理请求时。
  • **复杂控制流:**协程能够方便地暂停和恢复执行,在处理需要复杂状态机或多步骤操作时显得更加便捷。

扩展知识

结合应用场景,可以更深入地探讨线程和协程的使用:

1)线程的优势:

  • 真正并行:线程能够在多核处理器上实现真正的并行执行,充分利用硬件资源。
  • 适用广泛的库支持:比如 C++ 标准库提供了 std::thread,非常易于使用。

2)线程的劣势:

  • 资源开销大:线程创建和上下文切换的开销较大。
  • 复杂的同步机制:面对竞争条件时,需要且不容易处理好各种同步机制,如锁、互斥量等,容易产生死锁等问题,或其他线程安全问题。

3)协程的优势:

  • 高效的任务切换:协程是用户态的轻量级任务切换,创建和切换开销非常小。
  • 更简单的逻辑控制:协程的暂停和恢复机制使编写异步代码更加直观、易读,避免了回调地狱。

4)协程的劣势:

  • 单线程执行:协程本质上是单线程的,因此无法真正实现并行,需要与线程结合使用才能扩展到多核。
  • 库支持欠缺:截至目前,C++20 才引入了较为标准的协程库支持,应用和生态相对较新,相比线程仍有待成熟。

5)实际应用示例:

  • 多线程应用:如视频渲染、数据压缩解压、科学计算等需要占用多个 CPU 资源的场景。
  • 协程应用:如高并发网络服务器、多任务调度器、大量异步 I/O 的处理等。

C++20 引入的协程库提供了更强大的工具来简化异步编程。在特定场景下,比如高并发请求或复杂异步操作,使用协程能够显著简化代码并提高性能,相较于传统的线程方案,优势明显。

特性
线程(Thread)
协程(Coroutine)
适用场景
- CPU 密集型任务(如矩阵运算、科学计算)
- 需要真正并行(多核 CPU)
- 阻塞式 I/O 操作(如文件读写、网络请求)
- 高并发轻量级任务(如 Web 服务器)
- 异步 I/O 操作(如非阻塞网络请求)
- 需要复杂控制流(如状态机、多步骤异步逻辑)
并行能力
是(多核并行)
否(单线程协作式调度,需结合线程池实现多核并行)
创建/切换开销
高(内核态切换,MB 级栈空间)
极低(用户态切换,KB 级栈空间)
同步机制
需锁、条件变量等复杂同步原语
无竞争(单线程内协作调度,天然避免数据竞争)
代码复杂度
高(需处理竞态条件和死锁)
低(线性异步代码,避免回调地狱)
C++ 标准支持
C++11(`std::thread`)
C++20(协程框架,需编译器支持)
资源占用
每个线程占用独立栈和内核资源
数千协程可共享同一线程栈
选择建议
优先选择线程
优先选择协程
- 需要多核 CPU 并行计算
- 处理阻塞式长耗时任务
- 高并发 I/O 密集型任务
- 需要简化异步代码逻辑
混合使用场景
方案
说明
线程池 + 协程
用线程池处理 CPU 密集型任务,协程处理 I/O 密集型任务(如 Nginx 架构)
多线程调度协程
每个线程运行独立协程调度器,平衡并行与并发(如 Go 语言的 Goroutine)
补充说明
  1. 协程的局限性

    • 协程依赖异步 I/O 框架(如 io_uringlibuv)才能发挥性能优势。
    • C++20 协程为无栈协程(Stackless),需通过 co_await/co_yield 显式切换。
  2. 线程的优化方向

    • 使用线程池(如 std::async)避免频繁创建/销毁线程。
    • 结合无锁数据结构减少同步开销。

62. 用过哪些 C++ 日志框架?都有什么优缺点?

回答重点

C++ 的日志框架,流行的主要有:log4cpp、Boost.Log、spdlog、glog。

各自的优缺点如下:

1)log4cpp:

  • **优点:**功能强大,支持多种日志输出和格式化方式;配置灵活,支持外部配置文件。
  • **缺点:**配置相对复杂,文档不是太完善,比较老的库了,现在用的不是很多。

2)Boost.Log:

  • **优点:**属于 Boost 库的一部分,方便与 Boost 的其他库集成;功能全面,支持多线程。
  • **缺点:**编译时间较长,配置略显复杂。

3)spdlog:推荐使用。

  • **优点:**非常快,效率很高;易于使用,接口简洁;支持 header-only 形式的接入,不需要进行复杂的编译配置。
  • **缺点:**功能相对简单,不如 log4cpp 和 Boost.Log 复杂功能全面。

4)glog:

  • **优点:**Google 出品,性能和稳定性有保证;支持多线程,适合大型项目。
  • **缺点:**库的体积相对较大,不支持 header-only 接入,接入起来比较麻烦。

扩展知识

为了让大家更好地理解,我再补充一些背景和使用体验吧。

1)log4cpp: 我发现 log4cpp 在一些老旧的 C++ 项目中很常见,因为它出现的时间较长,功能也相对全面。通过 external 配置文件(如 XML 或 properties 文件),可以精确地控制日志的输出格式和目录,然而,新手在配置时可能会有点吃力,需要认真研读官方文档,新项目就不太建议使用 log4cpp 了。

2)Boost.Log: Boost.Log 是 Boost 库的一个模块,提供了相当全面的日志功能。由于库的复杂性以及编译时间较长,项目启动时间可能会有所增加。不过,如果你的项目已经使用了 Boost 库,添加 Boost.Log 可能会是一个不错的选择,因为它们之间的集成会比较顺畅。

3)spdlog:** 如果只推荐一个日志库的话,我推荐使用 spdlog。**spdlog 可以说是一个新时代的日志框架,它采用了 C++11/14 的新特性,使得整体效率非常高。我非常喜欢它的 header-only 设计,让你不用担心额外的库文件。对于性能有极高要求的项目,spdlog 是一个理想的选择,但要注意的是,它的功能相对简单,如果你需要非常复杂的日志功能,可能需要自己进行二次开发。

4)glog: glog 是 Google 的一个 C++ 日志库,通常用在一些需要高可靠性的项目中。它支持多线程,这让我在构建一些大型项目时非常放心。不过,相对于 spdlog,它在接入便捷性和性能上都略逊一筹。

日志框架
优点
缺点
适用场景
接入方式
log4cpp

- 功能全面(支持多种输出格式)
- 灵活配置(XML/Properties文件)
- 配置复杂
- 文档不完善
- 社区活跃度低
遗留项目维护
需编译链接
Boost.Log
- 与Boost生态无缝集成
- 多线程安全
- 支持高级过滤和格式化
- 编译时间长
- 学习曲线陡峭
已使用Boost的中大型项目
需编译链接
spdlog
- 性能极致(异步模式可达百万条/秒)
- 头文件-only
- 简洁易用API
- 功能较基础
- 复杂需求需二次开发
高性能应用/快速原型开发
头文件-only
glog
- Google背书稳定性高
- 崩溃日志自动记录
- 多线程支持
- 体积较大
- 接口风格老旧
- 定制化困难
大型分布式系统
需编译链接
详细功能对比
特性
log4cpp
Boost.Log
spdlog
glog
异步日志
✔️
✔️
✔️

多线程安全
✔️
✔️
✔️
✔️
日志分级
✔️
✔️
✔️
✔️
文件滚动
✔️
✔️
✔️

头文件-only


✔️

崩溃日志记录



✔️

63. 🌟 介绍下 socket 的多路复用?epoll 有哪些优点?

回答重点

多路复用这个术语在网络编程中非常重要,尤其是在涉及 I/O 操作的时候。所谓 socket 的多路复用,指的是在单个线程或进程中可以同时处理多个 socket 的 I/O 事件,可以提高整体效率和资源利用率。

常见的多路复用机制包括 selectpollepoll,在 Linux 平台上这种机制主要依赖于 epoll,因为它在大多数情况下性能更好。epoll 是 Linux 内核针对大量并发连接进行高效管理的系统调用接口。

编程过程中,建议使用 epollepoll 相比于 selectpoll,主要有以下几个优点:

**1)效率高:**epoll 使用事件通知的方式能够解决轮询(polling)带来的性能瓶颈,对大量文件描述符的处理效率高。

**2)不受描述符数量限制:**select 有文件描述符数量的上限(通常是 1024),而 epoll 没有这种限制。

**3)内存拷贝少:**epoll 的系统调用仅在需要数据时进行内存拷贝,减少了系统开销。

**4)支持边沿触发:**相比于 select 和 poll 的水平触发(level-triggered),epoll 还支持边沿触发(edge-triggered),能够适应更多的应用场景。

扩展知识

可以从几个方面进一步展开:

1)select 和 poll 的缺点: select 和 poll 都是 I/O 多路复用的早期实现,但它们有一些不足。例如,select 在每次调用时都需要重新传递所有文件描述符集合,并进行内存拷贝,而 poll 则需要传递整个文件描述符数组,这在文件描述符特别多的情况下,性能开销很大。此外,select 还有一个描述符数量的限制。

2)epoll 的工作机制: epoll 使用两个系统调用来操作:epoll_create 创建一个 epoll 实例,epoll_ctl 增加、修改或删除要控制的文件描述符。epoll_wait 则是用于等待事件的发生。与 select 不同的是,epoll 每次只需传递发生的事件,不需要传递所有文件描述符,极大提高了效率。

3)边沿触发与水平触发: 水平触发(Level-triggered, LT)是默认的触发模式,处理器只要发现事件有未处理的数据就会再次通知,在传统的 select 和 poll 中也是这种方式。而边沿触发(Edge-triggered, ET)是更高效的一种方式,它只会在状态变化(例如从无数据到有数据)时通知一次,开发难度稍大但可以减少系统调用次数,提高性能。

通过上面介绍,可以看出 epoll 是如何更高效率地进行 I/O 多路复用的。在实际工作中,在大规模高并发 I/O 操作时,建议优先选择 epoll

64. Socket 多路复用机制对比

特性
select
poll
epoll
时间复杂度
O(n) 轮询所有fd
O(n) 轮询所有fd
O(1) 事件通知
fd数量限制
1024 (FD_SETSIZE)
无硬限制
无硬限制
内存拷贝
每次调用需拷贝全部fd集合
需拷贝整个fd数组
仅返回就绪事件,无重复拷贝
触发模式
仅水平触发(LT)
仅水平触发(LT)
支持LT和边沿触发(ET)
适用场景
低并发兼容性场景
中低并发跨平台场景
高并发Linux专属场景

epoll 的核心优势详解

1. 高效事件驱动(O(1)复杂度)
  • 原理:通过内核事件表(红黑树 + 就绪链表)直接获取就绪事件
  • 优势:无需遍历所有 fd,性能随 fd 数量增长几乎不下降
2. 无文件描述符数量限制
  • select 限制:默认仅支持 1024 个 fd
  • epoll 突破:仅受系统内存限制,可支持数十万并发连接

65. 🌟C++ 中 vector 的 push_back 和 emplace_back 有什么区别?

回答重点

两者都是 vector 类的成员函数,用于在 vector 的末尾添加元素。它们之间的主要区别在于添加元素的方式:

  1. push_back:接受一个已存在的对象作为参数,进行拷贝或移动,将其添加到 vector 的末尾。这会触发一次拷贝或移动构造函数的调用,具体取决于传递的对象是否可移动。
  2. emplace_back:接受构造函数的参数,直接在 vector 的内存空间中调用该对象的构造函数,避免了额外的拷贝或移动操作。这可以提高效率,特别是在处理复杂对象时。

扩展知识

性能差异:

  • push_back 因为需要拷贝或移动已经存在的对象,较之 emplace_back 效率稍低,特别是对于大型或复杂对象,额外的拷贝或移动会显著影响性能。
  • emplace_back 直接在容器中构造对象,避免了不必要的对象构造和析构以及拷贝或移动,效率更高。

使用场景:

  • 如果需要将一个已经存在的对象添加到 vector 中,使用 push_back。
  • 如果希望直接在 vector 中构造对象,避免额外的拷贝或移动开销,使用 emplace_back。

代码示例:

#include <iostream>
#include <vector>
#include <string>

class MyClass {
public:
    MyClass(int a, std::string b) : a_(a), b_(b) {
        std::cout << "Constructor called\n";
    }
    MyClass(const MyClass& other) : a_(other.a_), b_(other.b_) {
        std::cout << "Copy Constructor called\n";
    }
    MyClass(MyClass&& other) noexcept : a_(other.a_), b_(std::move(other.b_)) {
        std::cout << "Move Constructor called\n";
    }
private:
    int a_;
    std::string b_;
};

int main() {
    std::vector<MyClass> v;
    v.reserve(16);
    
    std::cout << "Using push_back:\n";
    MyClass obj1(1, "example1");
    v.push_back(obj1);  // 会调用拷贝构造
    v.push_back(std::move(obj1)); // 会调用移动构造
    
    std::cout << "\nUsing emplace_back:\n";
    v.emplace_back(2, "example2"); // 直接在 vector 内存空间中构造,无需拷贝或移动
        std::cout << "\nover \n";
    return 0;
}

输出:
Using push_back:
Constructor called
Copy Constructor called
Move Constructor called

Using emplace_back:
Constructor called

over

在上面的示例中(输出结果也贴在了代码中),当使用 push_back 时,会调用拷贝构造函数或移动构造函数。而使用 emplace_back 时,直接构造对象,避免了额外的构造和析构开销。

**线程安全性:**无论是 push_back 还是 emplace_back,它们在多线程环境下都不是线程安全的。因此,必须考虑同步机制(如互斥锁)来避免数据竞争。

**二者选择:**建议无脑选择 emplace_back。

特性
`push_back`
`emplace_back`
参数类型
接受对象(左值/右值)
接受构造参数(可变参数模板)
构造方式
调用拷贝/移动构造函数
直接在容器内存中构造对象
性能开销
额外1次拷贝/移动操作
零额外拷贝/移动
适用场景
已有对象需要插入
需要直接构造新对象
代码示例
```vec.push_back(obj);vec.push_back(std::move(obj))```
```vec.emplace_back(args...)```

66. 🌟C++ 成员变量的初始化顺序是固定的吗?

回答重点

成员变量的初始化顺序是固定的。成员变量总是按照它们在类中出现的顺序进行初始化,而不是在构造函数中的初始化列表顺序。

例如:

class MyClass {
public:
    MyClass(int a, int b) : b(b), a(a) {}
  
private:
    int b;
    int a;
};

即使在构造函数的初始化列表中先初始化 b,后初始化 a,成员变量仍然会按照 int b; int a; 的顺序来初始化。

扩展知识

当涉及到成员变量的初始化时,有几个重要的点需要注意:

1)依赖关系(重点): 如果类的成员变量之间存在依赖关系,需要特别注意初始化顺序。例如,当一个成员变量的初始值取决于另一个成员变量的值时,应确保这些依赖关系在声明顺序中得到正确处理。

2)静态成员变量: 静态成员变量的初始化与普通成员变量不同。静态成员变量的初始化通常在类定义体外进行,并且只初始化一次。

3)初始化列表的使用: 使用初始化列表对于效率和可读性通常是有益的,因为它避免了成员变量在构造函数体内先被默认初始化然后再被赋值的开销。

4)继承时的初始化顺序: 在类的继承体系中,基类的构造函数会先于派生类的构造函数执行,因此基类的成员变量也会先于派生类的成员变量进行初始化。

具体地说:

  • 基类的成员变量按声明顺序初始化。
  • 派生类的成员变量按声明顺序初始化。在这个过程中,派生类的初始化顺序紧随基类之后。

**5)成员对象的初始化顺序: **如果类中包含其他类的对象作为成员,这些成员对象也会按照它们的声明顺序优先于自身的构造函数体内的代码进行初始化。

67. C++ 中未初始化和已初始化的全局变量放在哪里?全局变量定义在头文件中有什么问题?

回答重点

1)未初始化的全局变量放在 BSS 段,而已初始化的全局变量放在数据段(Data Segment)。

2)将全局变量定义在头文件中会引发多重定义的问题,如果头文件可能会被多个源文件包含,导致编译时同一个变量被多次定义,进而编译失败。

扩展知识

下面深入探讨一下这些段和多重定义的问题。

1)BSS 段和数据段

BSS 段全称 "Block Started by Symbol" 或 "Block Storage Segment",用于存放程序中未初始化或初始化为零的全局变量和静态变量。因为这些变量在程序加载时会被自动初始化为零,所以在编译好的程序中只占用很少的空间(只是需要在运行时期占用内存)。

数据段则包含初始化过的全局变量和静态变量。这个段在程序加载到内存时也被加载,并包含那些已经有初始值的变量,它们在程序运行期间保持这个初始值。

2)头文件中的全局变量定义问题

头文件中的全局变量定义会造成重复定义的问题。假设你在一个头文件 example.h 中这样定义了一个全局变量:

int globalVar;

然后你在两个源文件 file1.cppfile2.cpp 中都包含了 example.h。结果是编译器会在链接的时候发现 globalVar 被定义了两次,导致编译错误。

解决这一问题的方法,可以使用 static 修饰变量,或者使用 extern 关键字声明全局变量,然后在一个源文件中进行定义,例如:

// example.h
extern int globalVar;

// file1.cpp
#include "example.h"
int globalVar = 0; // 这里只定义一次

// file2.cpp
#include "example.h"// 
可以直接使用 globalVar,但不需要再次定义

通过这个方式,在头文件中的 extern 声明不会实际分配内存,而是在一个特定的源文件中定义一次,其他源文件包含头文件时就共享这个变量的定义。这样不仅能避免多重定义的问题,还能确保每个文件都能访问同一个全局变量。这是很多项目标准的做法,能有效管理全局变量及其作用范围。

开发建议:不要使用全局变量,可以考虑做好代码设计,非必要不使用全局变量。

68. 🌟 什么情况下会出现内存泄漏?如何避免内存泄漏?

回答重点

申请了内存,但未释放,就是内存泄漏,有几个经典的场景:

1)对象创建后却没有释放。

2)智能指针的循环引用,两者互相持有,导致引用计数永不为 0,内存无法释放。

3)集合类容器中,删除元素后未释放内存。

4)在外面手动申请的内存,但进入了异常处理,手动分配的内存未释放。

5)静态成员或全局变量持有动态分配的对象。

避免内存泄漏的方法:

1)使用智能指针(比如 std::unique_ptrstd::shared_ptr),自动管理内存。

2)用 RAII 原则,通过构造函数分配资源并在析构函数中释放。

3)执行静态分析工具(如 cppcheck)和内存检测工具(如 Valgrind)来检测代码质量和内存泄漏。

4)规范编码,确保每个 new 对应一个 delete,每个 malloc 对应一个 free。但在函数中也要注意对 if-else-return 分支的处理,确保每个 return 之前都能释放对应的内存。

5)避免循环引用,使用 std::weak_ptr 解决循环引用的问题。

扩展知识

探讨一下智能指针的相关知识点。

C++11 标准引入了三种主要的智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr

1. std::unique_ptr

std::unique_ptr 是一种独占所有权的智能指针,意味着同一时间内只能有一个 unique_ptr 指向一个特定的对象。当 unique_ptr 被销毁时,它所指向的对象也会被销毁。

使用场景:

  • 当你需要确保一个对象只被一个指针所拥有时。
  • 当你需要自动管理资源,如文件句柄或互斥锁时。

示例代码:

#include <iostream>
#include <memory>

class Test {
public:
    Test() { std::cout << "Test::Test()"; }
    ~Test() { std::cout << "Test::~Test()"; }
    void test() { std::cout << "Test::test()"; }
};

int main() {
    std::unique_ptr<Test> 
    ptr(new Test());
    ptr->test();
    // 当ptr离开作用域时,它指向的对象会被自动销毁
    return 0;
}
2. std::shared_ptr

std::shared_ptr 是一种共享所有权的智能指针,多个 shared_ptr 可以指向同一个对象。内部使用引用计数来确保只有当最后一个指向对象的 shared_ptr 被销毁时,对象才会被销毁。

使用场景:

  • 当你需要在多个所有者之间共享对象时。
  • 当你需要通过复制构造函数或赋值操作符来复制智能指针时。

示例代码:

#include <iostream>
#include <memory>

class Test {
public:
    Test() { std::cout << "Test::Test()"; }
    ~Test() { std::cout << "Test::~Test()"; }
    void test() { std::cout << "Test::test()"; }
};

int main() {
    std::shared_ptr<Test> ptr1(new Test());
    std::shared_ptr<Test> ptr2 = ptr1;
    ptr1->test();
    // 当ptr1和ptr2离开作用域时,它们指向的对象会被自动销毁
    return 0;
}
3. std::weak_ptr

std::weak_ptr 是一种不拥有对象所有权的智能指针,它指向一个由 std::shared_ptr 管理的对象。weak_ptr 用于解决 shared_ptr 之间的循环引用问题。

使用场景:

  • 当你需要访问但不拥有由 shared_ptr 管理的对象时。
  • 当你需要解决 shared_ptr 之间的循环引用问题时。
  • 注意 weak_ptr 肯定要和 shared_ptr 搭配使用。

示例代码:

#include <iostream>
#include <memory>

class Test {
public:
    Test() { std::cout << "Test::Test()"; }
    ~Test() { std::cout << "Test::~Test()"; }void test() { std::cout << "Test::test()"; }
};

int main() {
    std::shared_ptr<Test> sharedPtr(new Test());
    std::weak_ptr<Test> weakPtr = sharedPtr;
    
    if (auto lockedSharedPtr = weakPtr.lock()) {
        lockedSharedPtr->test();
    }// 当sharedPtr离开作用域时,它指向的对象会被自动销毁
    return 0;
}

这三种智能指针各有其用途,选择哪一种取决于你的具体需求。

内存泄漏常见场景

image-20250511163054924
image-20250511163054924

智能指针对比

防范措施对比

方法
实现手段
优点
局限性
RAII
构造函数获取资源,析构函数释放
自动管理,异常安全
需设计资源管理类
智能指针
make_shared/unique
零开销抽象,线程安全
循环引用需配合weak_ptr
静态分析工具
cppcheck/Clang-Tidy
提前发现潜在泄漏
可能有误报
动态检测工具
Valgrind/ASan
运行时精确检测
性能开销大
编码规范
每个new对应delete
简单直接
难以保证异常安全

最佳实践示例

1. 智能指针使用
// 推荐做法
auto ptr = std::make_shared<Resource>(); // 替代 new+shared_ptr
std::weak_ptr<Resource> observer = ptr; // 避免循环引用

// 危险做法
Resource* raw_ptr = new Resource();
std::shared_ptr<Resource> p1(raw_ptr);
std::shared_ptr<Resource> p2(raw_ptr); // 导致双重释放!

69. 🌟C++ 中为什么要引入 make_shared?它有什么优点?

回答重点

C++ 中创建 shared_ptr 有两种方式,一种是直接把裸指针传递进去,一种是使用 make_shared

class A {
public:
    A() {}
};

std::shared_ptr<A> sp1 = std::shared_ptr<A>(new A());
std::shared_ptr<A> sp2 = std::make_shared<A>();

既然推出了 make_shared,肯定是有优点,它的优点包括:

1)简化代码:使用 make_shared 可以简化创建 shared_ptr 实例的代码,代码更加清晰。

2)性能更好:make_shared 在内存分配时只需要一次分配,而直接使用 shared_ptr 构造函数可能需要两次内存分配。

3)降低内存泄漏风险:使用 make_shared 可以避免由于异常导致的部分已分配内存未释放的问题。

扩展知识

详细说一说 make_shared 相关的知识点:

1)shared_ptr 基础:与之对应的是 unique_ptr,, 但使用它有限制,比如不能共享所有权。而 std::shared_ptr 是一个可以共享控制权的智能指针,可以自动管理动态分配的对象生命周期。

2)newshared_ptr :在没有 make_shared 之前,我们通常这样创建 shared_ptr: std::shared_ptr<int> sp(new int(5));。这个过程其实做了两个动作:创建一个临时对象,又创建一个 shared_ptr 对象。如果第一步的内存分配成功,但第二步抛出异常,那么就会发生内存泄漏。

3)make_shared 内部原理:make_shared 将对象的动态内存和控制块内存(存储引用计数的那块内存)一次性分配,减少了内存分配的次数。例如:auto sp = std::make_shared<int>(5);,这种方式比前一种方式高效,并且更加安全。

4)性能更好:单次内存分配意味着分配器只调用一次,这比多次调用(可能导致的内存碎片问题)更加高效。此外,这种方式在多线程环境中也有一定优势,减少了分配内存时的竞争。

5)异常安全:使用 make_shared,如果在创建过程中抛出异常,因为它是“全有或全无”的过程,所以不需要担心部分资源分配成功导致的内存泄漏。例如,make_shared 可以保证在对象和控制块都构建成功之后才开始使用它们。

对比维度
直接使用 shared_ptr 构造函数 (new)
使用 make_shared
代码简洁性
需要显式使用 new,代码冗余
直接传递参数,代码更简洁
内存分配次数
两次分配(对象内存 + 控制块内存)
单次分配(对象和控制块内存连续)
性能
可能因多次分配导致内存碎片,效率较低
减少内存碎片,分配效率更高
异常安全性
若构造 shared_ptr 时抛出异常,可能内存泄漏
原子化操作,完全成功或失败,无内存泄漏风险
内存布局
对象和控制块内存可能不连续
对象和控制块内存连续,缓存局部性更好
适用场景
需自定义删除器或分离内存分配时使用
默认推荐方式,尤其适用于高频分配场景
多线程优势
无特殊优化
单次分配减少竞争,潜在性能提升

70. 如何理解 C++ 中的 atomic?

回答重点

std::atomic 用于实现原子操作,它也是 C++11 引入的新特性。

多个线程可以对同一个变量进行读写操作,不会导致数据竞争或中间状态,也不需要锁的保护,一定程度上简化了代码编写,性能也会有提高。

扩展知识

这个话题其实挺有深度的,这里展开说说:

**1)什么是原子操作:**原子操作指的是一个不可分割的操作,要么完全执行,要么完全不执行,不会被中途打断。举个例子,假设两个线程要同时增加一个变量,如果没有原子操作,可能会导致未预期的结果。而使用 std::atomic 后,这个操作就十分安全了。

2)如何使用 std::atomic:在 C++ 中,可以用 std::atomic 来修饰基本的数据类型,如 int, bool,甚至指针。示例如下:

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter(0);

void increment(int n) {
    for (int i = 0; i < n; ++i) {
        ++counter; // 原子操作,线程安全
    }
}

int main() {
    std::thread t1(increment, 1000);
    std::thread t2(increment, 1000);
    
    t1.join();
    t2.join();
   
    std::cout << "Final counter value: " << counter << std::endl; // 预期输出2000
    return 0;
}

3)底层实现:std::atomic 通过 CPU 提供的原子指令来实现这些不可分割的操作。现代 CPU 会提供一组指令,比如 CMPXCHG, XADD 等来实现原子的读或写。

4)内存序约束:C++ 提供了多种内存序约束,比如 memory_order_relaxed, memory_order_acquire, memory_order_release 等。这些约束让你可以更好地控制程序的内存可见性和行为。

例如,memory_order_relaxed 只保证原子性,但不提供任何同步或顺序保证,而 memory_order_acquirememory_order_release 则提供更严格的同步机制。

atomic 默认使用的是 memory_order_seq_cst,也就是最严格的内存序约束,既保证原子性,又提供了同步顺序保证。详见 cppreferenceopen in new window

5)和锁比较:虽然 std::atomic 可以在某些场景下替代锁,但它并不是万能的。锁在某些复杂场景下仍然是不可替代的。原子操作更适合一些基本的计数器或标志位,而对于复杂的数据结构,锁的使用仍是较优选择。

6)性能:使用原子操作通常比使用锁要快,因为锁涉及到上下文切换和操作系统调度,而原子操作都是硬件级别的操作。经过优化的原子操作可以使得你的程序在多线程环境下有更好的性能表现。

image-20250511163419971
image-20250511163419971

71. 什么情况下会出现死锁?如何避免死锁?

回答重点

本题主要考察死锁出现的四大必要条件。

什么情况下会发生死锁?一般来说,多个线程相互等待对方持有的资源且都不释放自己的资源,这种现象称为死锁。

具体有四个必要条件,必须同时满足才会发生死锁:

**1) 互斥条件:**线程对分配的资源有排他性访问,即每一个资源要么分配给一个线程,要么是可用的。

**2) 占有且等待:**一个线程已经占有至少一个资源,但又在等待另一个资源,而此时该资源被其他线程占有。

**3) 不可剥夺:**线程占有的资源不能被剥夺,资源只能在使用完后由线程自行释放。

**4) 环路等待:**存在一种资源等待的环形链,即线程 A 在等待线程 B 占有的资源,而线程 B 在等待线程 C 占有的资源,....,直到最后一个线程等待线程 A 占有的资源,从而形成一个等待环路。

这四大必要条件,只要能够破坏其一,就能避免死锁,可以采取以下几种措施:

**1) 避免互斥条件:**尽量减少资源的独占性,使用非阻塞同步机制。

**2) 破坏占有且等待:**采用资源预分配策略,即进程一次性请求所需的所有资源。

**3) 破坏不可剥夺:**如果一个进程得不到所需的资源,应释放它所持有的资源,或者使用优先级来剥夺资源。

**4) 破坏环路等待:**对系统中的资源进行排序,每个线程按序请求资源,避免形成环路。

扩展知识

下面详细解释下这几个关键点。

**1)互斥条件:**资源同一时间只能被一个线程所占有,可以通过使用锁(如 std::mutex)来确保互斥。

std::mutex mtx;
void critical_section() {
 std::lock_guard<std::mutex> lock(mtx);  // 确保互斥访问
 // 临界区代码
}

2)占有且等待:

  • 一个线程可能需要在持有资源 A 的情况下再去请求资源 B,这样就满足了占有且等待的条件。
  • 避免这种情况可以用资源一次性分配,确保一个线程在开始执行时已经获得了所有所需资源。
std::mutex mtxA, mtxB;
void thread_func() {
  std::unique_lock<std::mutex> lockA(mtxA, std::defer_lock);std::unique_lock<std::mutex> lockB(mtxB, std::defer_lock);
  std::lock(lockA, lockB);  // 脱离单独锁定合并为原子操作,避免死锁
  // 临界区代码
}

**3)不可剥夺:**如果一个线程得不到它所需的所有资源,可以释放已占用的资源,然后过一段时间再尝试重新获取。

std::mutex mtx1, mtx2;
void thread_func() {
while (true) {
      mtx1.lock();
      if (mtx2.try_lock()) {
          // 获得所需资源,进行处理
          mtx2.unlock();
          mtx1.unlock();
          break;
      } else {
          mtx1.unlock();
          std::this_thread::yield();  // 让出处理器一段时间再重试
      }
  }
}

4)环路等待:通过对所有资源进行排序,确保按序请求资源,这样就避免了环形等待。

std::mutex mtx1, mtx2;
void thread_func1() {
  std::lock(mtx1, mtx2);  // 遵守资源请求顺序
  std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
  std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
  // 临界区代码
}
void thread_func2() {
  std::lock(mtx1, mtx2);  // 遵守资源请求顺序
  std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
  std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
  // 临界区代码
}

通过这些方法就可以有效地避免死锁的发生。需要注意的是,避免死锁是一项非常重要也非常复杂的工作,需要仔细的设计和检查。

破坏环路等待条件很常见,一般开发过程中,只要我们可以保证资源加锁的顺序是一致的,基本都可以避免死锁的发生。

72. C++ 中如何实现一个单例模式?

回答重点

现在最常见的实现单例模式的方法就是使用 static 静态局部变量的懒汉模式了,可以归纳为以下几点:

  1. 将构造函数、拷贝构造函数和赋值操作符设为 private,防止外部模块通过它们创建对象。
  2. 在类中提供一个静态的、返回类实例的 public 方法。
  3. 使用局部静态变量初始化类实例,确保线程安全的懒汉模式。

下面是一个具体的示例代码:

class Singleton {
private:
    // 私有构造函数
    Singleton() {}
    
    // 禁止拷贝构造函数和赋值操作符
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

public:
    // 获取唯一实例的静态方法
    static Singleton& getInstance() {
        static Singleton instance;
        return instance;
    }
    
    // 其他成员函数
    void someMemberFunction() {
        // TODO: 实现功能
    }
};

这个实现利用了 C++11 局部静态变量的线程安全性,确保多线程环境下仅创建一个实例。

扩展知识

饿汉模式与懒汉模式:

  • 饿汉模式:实例在程序开始运行时就被创建,常见的做法是直接在类中初始化静态成员。
  • 懒汉模式:实例在首次使用时才被创建,节省资源。

上面的代码属于懒汉模式,而且线程安全。

线程安全: C++11 之前的局部静态变量并不保证线程安全。在 C++11 及之后,局部静态变量的初始化是线程安全的。此外,也可以使用互斥锁(如 std::mutex)来显式保证线程安全。

class Singleton {
private:
    Singleton() {}
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    
    static std::mutex mutex_;

public:
    static Singleton& getInstance() {
        std::lock_guard<std::mutex> lock(mutex_); // C++11 之后可以不用锁
        static Singleton instance;
        return instance;
    }
};

std::mutex Singleton::mutex_;

双重检测机制: 在某些情况下,双重检测机制也是实现单例模式的常用方法,尤其是针对一些旧版本的多线程系统。

class Singleton {
private:
    Singleton() {}
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    
    static Singleton* instance_;
    static std::mutex mutex_;

public:
    static Singleton* getInstance() {
        if (instance_ == nullptr) {
            std::lock_guard<std::mutex> lock(mutex_);
            if (instance_ == nullptr) { // 双重检测
                instance_ = new Singleton();
            }
        }
        return instance_;
    }
};

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

内存管理和实例销毁: 在需要明确销毁单例对象的情况下,可以使用智能指针(如 std::unique_ptr)来管理单例对象的生命周期,或者在程序结束时手动清理。

单例模式的应用场景: 单例模式通常用于需要全局访问的系统配置、日志记录器、线程池管理、数据库连接池等场景。

73. 🌟 请介绍下 C++ 中的 std::sort 算法?

回答重点

std::sort 非常高效,它不单纯是快速排序,而是使用了一种名为 introspective sort(内省排序)的算法。

内省排序是快速排序、堆排序和插入排序的结合体,它结合这些算法优点的同时避免它们的缺点,特别是快速排序在最坏情况下的性能下降问题。

注意:本题介绍,仅限于 GCC 的源码实现。

扩展知识

快速排序:内省排序首先使用快速排序算法。利用快速排序分而治之的特点,通过选取一个 pivot 元素,将数组分为两个子数组,一个包含小于 pivot 的元素,另一个包含大于 pivot 的元素,然后递归地对这两个子数组进行快速排序。快速排序在平均情况下非常高效,时间复杂度为 O(n log n)。

/// This is a helper function for the sort routine.
template <typename _RandomAccessIterator, typename _Size, typename _Compare>
_GLIBCXX20_CONSTEXPR void
__introsort_loop(_RandomAccessIterator __first, _RandomAccessIterator __last,
                 _Size __depth_limit, _Compare __comp) {
  while (__last - __first > int(_S_threshold)) {
    if (__depth_limit == 0) {
      std::__partial_sort(__first, __last, __last, __comp);
      return;
    }
    --__depth_limit;
    _RandomAccessIterator __cut =
        std::__unguarded_partition_pivot(__first, __last, __comp);
    std::__introsort_loop(__cut, __last, __depth_limit, __comp);
    __last = __cut;
  }
}

// sort

template <typename _RandomAccessIterator, typename _Compare>
_GLIBCXX20_CONSTEXPR inline void __sort(_RandomAccessIterator __first,
                                        _RandomAccessIterator __last,
                                        _Compare __comp) {
  if (__first != __last) {
    std::__introsort_loop(__first, __last, std::__lg(__last - __first) * 2,
                          __comp);
    std::__final_insertion_sort(__first, __last, __comp);
  }
}

内省排序:通过限制快速排序递归深度,避免其最坏情况的性能问题。递归深度的限制基于输入数组的大小,通常是对数组长度取对数然后乘以一个常数(在 GCC 实现中是 2 * log(len))。如果排序过程中递归深度超过了这个限制,算法会切换到堆排序。

/// This is a helper function for the sort routine.
template <typename _RandomAccessIterator, typename _Size, typename _Compare>
_GLIBCXX20_CONSTEXPR void
__introsort_loop(_RandomAccessIterator __first, _RandomAccessIterator __last,
                 _Size __depth_limit, _Compare __comp) {
  while (__last - __first > int(_S_threshold)) {
    if (__depth_limit == 0) {
      std::__partial_sort(__first, __last, __last, __comp);
      return;
    }
    --__depth_limit;
    _RandomAccessIterator __cut =
        std::__unguarded_partition_pivot(__first, __last, __comp);
    std::__introsort_loop(__cut, __last, __depth_limit, __comp);
    __last = __cut;
  }
}

堆排序:当快速排序的递归深度超过限制时,内省排序会切换到堆排序,保证最坏情况下的时间复杂度为 O(n log n)。堆排序不依赖于数据的初始排列,因此它的性能无论在最好、平均和最坏情况下都是稳定的。

template <typename _RandomAccessIterator, typename _Compare>
_GLIBCXX20_CONSTEXPR inline void
__partial_sort(_RandomAccessIterator __first, _RandomAccessIterator __middle,
               _RandomAccessIterator __last, _Compare __comp) {
  std::__heap_select(__first, __middle, __last, __comp);
  std::__sort_heap(__first, __middle, __comp);
}

插入排序:最后,当数组的大小减小到一定程度时,内省排序会使用插入排序来处理小数组。插入排序在小数组上非常高效,尽管它的平均和最坏情况时间复杂度为 O(n^2),但在数据量小的情况下,这种复杂度不是问题。此外,插入排序是稳定的,可以保持等值元素的相对顺序。

/// This is a helper function for the sort routine.
template <typename _RandomAccessIterator, typename _Compare>
_GLIBCXX20_CONSTEXPR void __final_insertion_sort(_RandomAccessIterator __first,
                                                 _RandomAccessIterator __last,
                                                 _Compare __comp) {
  if (__last - __first > int(_S_threshold)) {
    std::__insertion_sort(__first, __first + int(_S_threshold), __comp);
    std::__unguarded_insertion_sort(__first + int(_S_threshold), __last,
                                    __comp);
  } else
    std::__insertion_sort(__first, __last, __comp);
}
排序算法
平均时间复杂度
最坏时间复杂度
最好时间复杂度
空间复杂度
稳定性
适用场景
关键特点
冒泡排序
O(n²)
O(n²)
O(n)
O(1)
稳定
小规模数据或基本有序数据
相邻元素比较交换,每一轮将最大元素“冒泡”到末尾。
选择排序
O(n²)
O(n²)
O(n²)
O(1)
不稳定
小规模数据
每次选择最小(或最大)元素放到已排序区间末尾。
插入排序
O(n²)
O(n²)
O(n)
O(1)
稳定
小规模或基本有序数据
将未排序元素插入已排序区间的正确位置。
希尔排序
O(n log n)
O(n²)
O(n log n)
O(1)
不稳定
中等规模数据
分组插入排序,通过增量序列逐步缩小间隔。
归并排序
O(n log n)
O(n log n)
O(n log n)
O(n)
稳定
大规模数据、外部排序
分治法,递归拆分后合并有序子序列。
快速排序
O(n log n)
O(n²)
O(n log n)
O(log n)
不稳定
大规模数据(默认最优选择)
分治法,通过基准值分区,递归排序左右子序列。
堆排序
O(n log n)
O(n log n)
O(n log n)
O(1)
不稳定
大规模数据、需要原地排序
利用堆结构(大顶堆/小顶堆)进行选择排序。
计数排序
O(n + k)
O(n + k)
O(n + k)
O(k)
稳定
非负整数且范围k较小的数据
统计元素出现次数,直接计算输出位置。
桶排序
O(n + k)
O(n²)
O(n)
O(n + k)
稳定
均匀分布的数据
将数据分到有限数量的桶内,每个桶单独排序后合并。
基数排序
O(n × k)
O(n × k)
O(n × k)
O(n + k)
稳定
非负整数或定长字符串
按位数从低位到高位依次排序(依赖稳定的子排序算法,如计数排序)。

74. ReactorProactor 的区别?

回答重点

ReactorProactor 都是用于处理大量网络 IO 操作的编程模式。

它们的主要区别在于如何处理 IO 操作。

  • Reactor 模式,程序会先注册一些事件处理器,监听需要处理的 IO 事件,例如 socket 读写事件。当这些事件发生时,事件处理程序会通知相应的事件处理器来处理该事件。这种方式通常使用同步 I/O 操作,即程序需要等待 IO 操作完成才能进行下一步操作。
  • Proactor 模式中,程序也会先注册一些事件处理器来监听需要处理的 IO 事件。但是与 Reactor 不同的是,Proactor 使用异步 I/O 操作,即程序可以继续执行其他任务而不必等待 IO 操作完成。当 IO 操作完成后,事件处理程序会自动调用相关的回调函数来处理已经就绪的 IO 结果。

扩展知识

由于 Proactor 使用异步 I/O 操作,因此它比 Reactor 更适合处理大量数据或者需要进行复杂计算的场景。然而,它的实现可能会更加复杂,需要使用回调函数、协程或者异步框架等技术来支持。比如 asio

对比维度
Reactor 模式
Proactor 模式
核心机制
同步非阻塞 I/O(事件驱动)
异步 I/O(操作完成后回调)
工作流程
1. 注册事件处理器
2. 监听事件就绪
3. 程序主动完成 I/O 操作
1. 注册事件处理器和缓冲区
2. 系统完成 I/O 操作
3. 回调通知程序处理结果
I/O 操作主体
应用程序自身(需调用 `read/write`)
操作系统(内核完成 `read/write`,程序只需处理回调)
性能
高并发下上下文切换较多
减少上下文切换,适合高吞吐量场景
复杂度
实现简单,主流框架(如 Nginx、Redis)广泛使用
实现复杂,需依赖操作系统异步 I/O 支持(如 Windows IOCP,Linux AIO 不完善)
典型应用
- Nginx
- Redis
- Java NIO
- Boost.Asio(跨平台)
- Windows IOCP
- 高性能文件/网络服务
适用场景
- 连接数多但数据量小
- 需要跨平台兼容性
- 数据量大或计算密集
- 需极致性能(如金融交易系统)