跳转至
C++ 进阶手册 工程与互操作

C++ 面试高频题

AI Infra 岗位的面试里,C++ 通常以两种形式出现:基础知识的连续追问(对象模型、内存、并发),以及现场手写一个组件(智能指针、线程安全队列、内存池)。这里按主题整理了高频问题和答题要点,每题都链接到正文里展开讲解的位置;标 ★ 的是出现频率最高的。

怎么用

  • 先只看问题,口头回答 2~3 分钟,再展开要点对照;
  • 答题结构:是什么 → 为什么需要 → 怎么实现 / 怎么用 → 代价与坑。能说出"在推理系统里哪里用到"是加分项;
  • 手写题见文末,大多在练习题里有可以本地判题的版本。

一、对象模型与语言基础

1. ★ 指针和引用有什么区别?

要点

引用必须初始化、不能重新绑定、没有"空引用",语义上是对象的别名;指针是一个存地址的对象,可以为空、可以改指向、可以做算术。 现代 C++ 里:必定存在的参数用引用,"可能没有"的用指针或 std::optional;裸指针和引用都表示不拥有,拥有用智能指针。见智能指针与所有权。

2. ★ 虚函数是怎么实现的?为什么基类的析构函数要是虚的?

要点

每个有虚函数的类有一张虚函数表(vtable),每个对象里有一个指向它的指针(vptr);调用虚函数时通过 vptr 查表间接跳转。代价:每个对象多一个指针、调用多一次间接跳转、通常不能内联。 通过基类指针 delete 派生类对象时,如果析构函数不是虚的,只会调用基类的析构函数,派生类的成员不会被析构(未定义行为,实际表现为资源泄漏)。 构造和析构函数里调用虚函数,调用的是当前正在构造 / 析构的那一层的版本,不会分派到派生类。 热路径上的多态用模板,粗粒度的用虚函数,见模板还是虚函数。

3. ★ 什么是 RAII?异常安全有哪几个等级?

要点

构造时获取资源、析构时释放,析构在正常返回、提前返回和异常时都会执行,所以资源不会泄漏。 异常安全的三个等级:基本保证(出异常后对象仍然有效、不泄漏)、强保证(出异常后状态回到调用之前,像事务一样,"拷贝再交换"是典型写法)、不抛出保证(noexcept,析构函数、移动操作、swap 应该做到)。见值语义与 RAII。

4. ★ 讲讲三法则 / 五法则 / 零法则。

要点

直接管理资源的类,如果需要自定义析构函数,几乎一定也要自定义或删除拷贝构造、拷贝赋值(三法则),C++11 起再加上移动构造、移动赋值(五法则);否则默认的逐成员拷贝会让两个对象释放同一个资源。 零法则:让类不直接管理资源,把资源交给 vector、unique_ptr 这些 RAII 成员,一个特殊成员函数都不写。见零法则与五法则。

5. ★ std::move 做了什么?被移动的对象处于什么状态?

要点

std::move 只是把左值转换成右值引用,本身不移动任何东西;真正转移资源的是被选中的移动构造 / 移动赋值。被移动的对象"有效但未指定",只能析构或重新赋值。 追问:移动构造为什么要 noexcept?——vector 扩容时只有移动不抛异常才用移动,否则退回到拷贝以保证强异常安全。见移动语义。

6. 左值、右值、转发引用分别是什么?std::forward 解决什么问题?

要点

有名字、能取地址的是左值;临时的是右值。模板参数的 T&& 是转发引用,传左值时推导成左值引用(引用折叠)。函数体内有名字的参数总是左值,std::forward<T>(x) 恢复调用者原来的值类别,这就是完美转发,emplace_back、make_unique 靠它实现。见完美转发。

7. const 有哪些用法?mutable 呢?

要点

const T*(指向常量的指针)、T* const(常量指针)、const 成员函数(不修改对象的可观察状态,this 是指向常量的指针)、const T& 参数(只读、可以绑定临时对象)。 mutable 成员可以在 const 成员函数里修改,用于缓存、互斥锁这类不影响可观察状态的成员(比如 const 的 getter 里要加锁,锁就是 mutable std::mutex)。

8. static 有哪几种含义?局部静态变量的初始化是线程安全的吗?

要点

全局 / 命名空间作用域:内部链接(只在本翻译单元可见);类成员:属于类而不属于对象;函数内的局部变量:静态存储期,第一次执行到时初始化。 C++11 起局部静态变量的初始化是线程安全的("magic statics"),常用来实现线程安全的单例。 头文件里的 static 变量会在每个翻译单元各有一份,见inline 与单一定义规则。

9. new / delete 和 malloc / free 有什么区别?placement new 是什么?

要点

new 分配内存并调用构造函数,失败时抛 std::bad_alloc,类型安全;malloc 只分配原始内存,失败返回空指针。两者不能混用。 placement new(new (ptr) T(args))在已有的内存上构造对象,内存池、vector 内部、arena 都用它;对应地要手动调用析构函数 p->~T()。见分配器与内存池。

二、内存与未定义行为

10. ★ 说几种常见的未定义行为,以及怎么发现。

要点

越界访问、释放后使用、重复释放、容器扩容后使用旧的引用或迭代器、有符号整数溢出、违反严格别名(用 reinterpret_cast 读别的类型)、未对齐访问、未初始化读取、数据竞争。 UB 的危险在于编译器会假设它不发生并据此优化。发现手段:ASan(内存错误)、UBSan(溢出、对齐等)、TSan(数据竞争)、-D_GLIBCXX_ASSERTIONS、-Wall -Wextra。见编译模型与未定义行为。

11. ★ 结构体的大小怎么算?为什么有填充?

要点

每个成员放在自己对齐要求的整数倍地址上,成员之间补填充;结构体的对齐要求是成员中最大的,总大小补齐到它的整数倍。按对齐要求从大到小排字段可以减少填充。 追问"为什么要对齐":未对齐的访问在某些平台上出错或变慢、不能用向量化指令;GPU 的 128 位加载要求 16 字节对齐。见对象布局。

12. ★ unique_ptr 和 shared_ptr 的区别?shared_ptr 是线程安全的吗?

要点

unique_ptr 独占、只能移动,默认删除器下零开销;shared_ptr 共享,有一个控制块存引用计数。 shared_ptr 的引用计数是原子的,多个线程各自拷贝、销毁自己的副本是安全的;但多个线程同时读写同一个 shared_ptr 变量不安全,指向的对象本身也不因此线程安全。 make_shared 把对象和控制块放在一次分配里,缺点是只要还有 weak_ptr,整块内存(包括对象的存储)就不能释放。循环引用用 weak_ptr 打破。见智能指针与所有权。

13. 怎样定位内存泄漏?

要点

开发阶段:ASan 自带的 LeakSanitizer,进程退出时报告泄漏的分配点;macOS 用 leaks。线上:heaptrack、jemalloc / tcmalloc 的堆分析,比较两个时间点的堆快照。 C++ 服务里"内存一直涨"更常见的原因往往不是泄漏,而是缓存没有上限、分配器不把内存还给系统(碎片)、或者 shared_ptr 的循环引用。

三、模板与标准库

14. 为什么模板的实现通常写在头文件里?模板对编译时间有什么影响?

要点

模板在被使用时才实例化,编译器必须看到完整定义;每个用到它的翻译单元都要实例化一遍(链接时去重)。组合爆炸会让编译时间和二进制都变大,控制手段:只实例化需要的组合、显式实例化、按需 JIT。见模板就是代码生成器。

15. SFINAE 和 concepts 是什么?

要点

SFINAE:模板参数代入失败不是错误,只是把这个候选从重载集合里去掉,老代码用 std::enable_if 实现"只对某些类型启用"。C++20 的 concepts 用 requires 直接写出对模板参数的要求,报错清楚、代码可读。见 concepts。

16. ★ vector 的扩容机制?哪些操作会让迭代器失效?

要点

容量不够时按倍数(libstdc++ 是 2 倍)分配新缓冲区,把元素移动(移动构造是 noexcept 时)或拷贝过去,push_back 均摊 O(1)。扩容会让所有指针、引用、迭代器失效;insert / erase 让插入 / 删除点之后的失效。reserve 避免扩容,clear 不释放容量。见vector。

17. unordered_map 是怎么实现的?和 map 怎么选?

要点

std::unordered_map 是链式哈希表,每个元素一个节点、一次分配;负载因子超过阈值时 rehash,迭代器失效但元素地址不变。map 是红黑树,有序,查找 O(log n)。 需要有序或范围查询用 map;只按键查找用 unordered_map,性能要求高时用开放寻址的扁平哈希表(Abseil flat_hash_map)。见关联容器。

18. std::string_view 有什么风险?

要点

它不拥有数据,只是指针加长度。绑定到临时 std::string 上会立刻悬垂;存进成员变量或容器里要保证被引用的数据活得更久。适合做函数参数,不适合做存储。见视图。

四、并发

19. ★ 数据竞争和竞态条件有什么区别?

要点

数据竞争:两个线程无同步地访问同一内存位置、至少一个是写——是未定义行为,TSan 能检测。竞态条件:结果依赖于执行时序,比如"检查后再行动"之间状态被别人改了——没有数据竞争也可能发生,只能靠设计(把检查和行动放进同一个临界区)。见线程与数据竞争。

20. ★ 条件变量怎么用?为什么 wait 要带谓词?

要点

持有 unique_lock 检查条件,不满足就 cv.wait(lock, pred):原子地释放锁并睡眠,醒来后重新加锁并再次检查谓词。要带谓词是因为有虚假唤醒,以及醒来时条件可能已经被别的线程消费掉。通知方修改数据后 notify_one / notify_all;关闭队列时要 notify_all 让所有等待者退出。见条件变量与阻塞队列。

21. ★ 讲讲内存序。memory_order_relaxed 什么时候可以用?

要点

原子性只保证单个变量不被撕裂,内存序决定其他读写的可见顺序。release 写与读到它的 acquire 读配对,写方在 release 之前的所有写对读方可见——"先写数据、再发布标志"。 relaxed 只保证原子性,适合只关心最终值的计数器、统计量。拿不准就用默认的 seq_cst。见 atomic 与内存序。

22. 死锁的条件是什么?怎么避免?

要点

四个必要条件:互斥、持有并等待、不可抢占、循环等待。实践中主要破坏"循环等待":固定全局的加锁顺序、用 std::scoped_lock 一次锁多把、持锁时不调用外部代码(回调、虚函数);更根本的是减少共享,用消息传递。TSan 能检测锁顺序反转。见死锁。

23. 什么是伪共享?怎么避免?

要点

不同线程频繁写的不同变量落在同一个缓存行里,缓存一致性协议让这一行在核之间来回失效,性能下降一个数量级。按缓存行(64 字节,部分 ARM 是 128)用 alignas 隔开。见伪共享。

24. ★ 单生产者单消费者无锁队列怎么实现?

要点

环形数组,容量取 2 的幂;tail 只由生产者写、head 只由消费者写。生产者写元素后 release 更新 tail,消费者 acquire 读 tail 后读元素、再 release 更新 head。两端指针放在不同缓存行,各自缓存对方的指针减少跨核读取。见单生产者单消费者环形队列。

25. 什么是 ABA 问题?

要点

CAS 只比较值:线程读到 A,期间被改成 B 又改回 A,CAS 仍然成功,中间的变化被忽略。无锁栈里表现为同一地址的节点被释放后重新分配。解决:带版本号的 CAS、危险指针、基于 epoch 的回收。见 CAS 循环。

26. 设计一个线程池要考虑哪些问题?

要点

任务队列(锁 + 条件变量)、submit 返回 future(packaged_task 传递返回值和异常)、任务在锁外执行、优雅关闭(做完剩余任务还是丢弃)、成员析构顺序(工作线程最后声明、最先 join)。 坑:任务里等待同一个池的任务导致饥饿死锁、阻塞 I/O 占住线程、嵌套并行导致超额订阅、任务太小时调度开销占大头(切块或工作窃取)。见线程池。

五、系统、性能与互操作

27. ★ 从源码到可执行文件经过哪些步骤?静态库和动态库有什么区别?

要点

预处理(展开头文件和宏)→ 编译(每个翻译单元独立编译)→ 汇编(目标文件和符号表)→ 链接(把引用对上定义)。 静态库在链接时被拷贝进可执行文件;动态库在运行时加载,多个进程共享,可以单独升级,但有 ABI 兼容和符号冲突的问题。跨语言接口用 extern "C" 关掉名字修饰。见从源码到可执行文件。

28. ★ 为什么数组的结构体(SoA)有时比结构体的数组(AoS)快?

要点

CPU 按 64 字节缓存行读内存。只访问少数字段时,AoS 每个缓存行里大部分字节没用;SoA 读进来的都是有用的,还能向量化。反过来,每次都用一个对象的全部字段时 AoS 更好。GPU 的合并访存是同一个道理。见数组的结构体还是结构体的数组。

29. 怎样定位一个 C++ 服务的性能问题?

要点

先确认是 CPU、内存、I/O 还是锁等待;perf stat 看 IPC 和缓存未命中做分类,perf record -g + 火焰图定位热点函数;用 Release + 调试信息 + -fno-omit-frame-pointer 构建。卡住(CPU 为 0)时看所有线程的调用栈(gdb -p、py-spy dump --native)。见 CMake、sanitizer 与 perf。

30. ★ 设计一个 KV Cache 的块分配器。

要点

启动时按显存预算一次性分配,切成固定大小的块;空闲块号放在栈里,分配释放 O(1)、没有碎片;引用计数支持前缀共享和分叉,写之前对共享块做写时复制;"全部成功或全部不分配"的批量分配接口给调度器做准入控制;只在调度器线程访问,所以计数不用原子操作。见块分配器。

31. 用 pybind11 写扩展时,GIL 有哪些坑?

要点

长时间的纯 C++ 计算要释放 GIL(py::gil_scoped_release),释放后不能碰 Python 对象;C++ 线程回调 Python 前要拿 GIL;持有 C++ 锁时去拿 GIL、而持有 GIL 的线程又在等这把锁,就会死锁。另外 STL 容器的自动转换每次都拷贝,大数组用 numpy 的缓冲区。见 pybind11 与 PyTorch C++ 扩展。

六、手写题

面试里的 C++ 手写题通常在 20~40 分钟内完成,要求正确处理边界、能通过 sanitizer:

题目 考察点 参考
实现 unique_ptr(含自定义删除器) 移动语义、RAII、五法则 智能指针
实现简化的 shared_ptr 控制块、原子引用计数、拷贝与移动 智能指针
实现一个 String 类 深拷贝、拷贝再交换、移动 移动语义练习 1
线程安全的有界阻塞队列 锁、两个条件变量、关闭 线程练习 1
单生产者单消费者无锁队列 acquire / release、伪共享 无锁队列
线程池 packaged_task、future、关闭顺序 线程池
固定大小的内存池 / 块分配器 空闲列表、对齐、引用计数 分配器与内存池
LRU 缓存 哈希表 + 链表、O(1) 练习题

写完之后主动说明:复杂度、线程安全的边界("这个类只能单线程用"也是一种回答)、异常安全等级,以及你会怎么测试它(sanitizer + 边界用例 + 并发压力测试)。

小结

  • 对象模型:引用与指针、虚函数、RAII 与异常安全、五法则与零法则、移动与转发。
  • 内存:常见 UB 与 sanitizer、对齐与布局、智能指针的线程安全边界。
  • 模板与标准库:实例化与编译时间、concepts、容器的扩容与迭代器失效、视图的风险。
  • 并发:数据竞争与竞态、条件变量、内存序、死锁、伪共享、无锁队列、线程池。
  • 系统:编译链接、SoA、性能分析、KV 块分配器、GIL。手写题要能通过 sanitizer 并讲清楚边界。

发现错误或有更好的写法?在 GitHub 上编辑此页提交问题