C++ 对象模型、智能指针、并发与内存序面试问答¶
约 5630 个字 5 行代码 1 张图片 预计阅读时间 28 分钟
使用说明¶
这篇文档不是泛泛的 C++ 八股,而是把你仓库里的几条主线揉成一套面试回答骨架:
- 对象模型主线:来自《深度探索 C++ 对象模型》对应笔记,重点看
vptr/vtable、对象布局、构造/析构语义。 - 并发主线:来自本仓库的原子、锁、memory order、futex/MESI 笔记。
- 高频题信号:截至
2026-03-11,公开面经里反复出现虚表、虚析构、智能指针、CAS、memory_order、锁和原子这些追问。
Note
技术结论优先以本地笔记、cppreference 与主流 ABI 文档为准;牛客等公开面经只用于判断“哪些题最常被问”,不作为技术正确性的唯一依据。
仓库内复习路径¶
| 主题 | 优先回看文件 | 用途 |
|---|---|---|
| 对象布局、类大小、虚函数、虚继承 | pdf_notes/The Senmantics of Data.md | 复习类大小、空类、vptr/vtable、多继承与虚继承布局 |
| 默认构造、拷贝构造、初始化列表、NRVO | pdf_notes/The Senmantics of Constructors.pdf | 复习编译器何时合成构造函数、初始化顺序、返回值优化 |
原子、六种内存序、happens-before | pdf_notes/C++内存序.md | 先快速建立 memory order 基本图景 |
acquire/release、seq_cst、futex、自旋锁 | docs/notes/C++Concurrency/2-Memory Model.md | 复习并发中的工程细节与例子 |
shared_ptr/unique_ptr/weak_ptr | docs/notes/c++primer/12_动态内存.md | 复习所有权、控制块、环引用、删除器 |
| 缓存一致性、store buffer、barrier | pdf_notes/MESI.pdf | 理解为什么“有一致性协议还需要内存屏障” |
高频题地图¶
面试里这些题通常不是孤立的,而是按一条链追问:
- 对象模型:类大小怎么算,虚表/虚表指针在哪,多继承和虚继承怎么变复杂。
- 构造析构:默认构造什么时候被合成,初始化列表为什么重要,构造/析构里能不能调虚函数。
- 继承多态:多态底层怎么实现,为什么基类析构函数常写成虚函数,对象切片是什么。
- 资源管理:为什么现代 C++ 强调 RAII,
unique_ptr/shared_ptr/weak_ptr分别怎么选。 - 并发语义:锁和原子有什么边界,CAS 为什么会有 ABA,
memory_order到底控制了什么。 - 硬件视角:MESI、store buffer、invalidate queue 为什么让你“感觉代码明明先写了,另一个核却没看到”。
一、对象模型、虚表、虚表指针¶
1. 什么决定一个类对象的大小?¶
答法:
- 主要看 非静态数据成员、对齐填充,以及主流实现里为了支持多态/虚继承额外放进去的隐藏指针。
- 普通成员函数、静态成员函数、静态数据成员通常 不占对象本身大小。
- 有虚函数时,主流 ABI 往往给对象放一个
vptr。 - 有虚继承时,布局里通常还要有支持定位虚基类的额外信息。
面试加分句:
严格说,C++ 标准没有规定一定要有
vtable/vptr;但在主流 ABI 和编译器实现里,回答对象模型问题时通常按vptr + vtable来解释。
2. 虚表和虚表指针分别是什么?¶
答法:
vtable是编译器生成的一张表,里面放虚函数入口、RTTI、偏移调整等元信息。vptr是对象里的隐藏指针,运行时通过它找到对应的vtable。- 类共享虚表,对象各自持有自己的
vptr。
常见追问:虚函数调用为什么慢一点?
- 因为多了一次间接寻址,编译期往往不能像非虚调用那样直接静态绑定。
- 但现代编译器在去虚拟化(devirtualization)成立时也可能优化掉这层间接。
3. 一个类只要有虚函数,就一定只有一个 vptr 吗?¶
答法:
- 不一定。
- 单继承的多态类通常一个对象里只看到一个主
vptr。 - 多继承、虚继承 下可能出现多个与子对象对应的
vptr,因为不同基类子对象都可能需要独立支持多态分派和this调整。
4. 多继承和虚继承为什么更容易被追问?¶
答法:
- 因为它们直接把“C++ 不是 Java 的单一对象头模型”暴露出来了。
- 多继承的关键点是:一个最派生对象内部可能含有多个基类子对象,每个子对象都有自己的布局入口。
- 虚继承的关键点是:虚基类只保留一份,但这也带来额外的偏移计算与布局复杂度。
安全答法:
具体布局是 ABI 相关的;面试里重点不是背某个编译器的字节图,而是说明“多继承需要解决多基类子对象定位,虚继承需要解决共享虚基类定位”。
5. 为什么空类大小通常是 1?¶
答法:
- 因为标准要求不同对象要有不同地址,对象大小不能为 0。
- 所以空类常见
sizeof是 1。 - 但空基类优化(EBO)下,空基类那 1 个字节常常可以不真正体现在派生类对象大小里。
6. 多态底层到底怎么实现?¶
答法:
- 本质是 “基类指针/引用 + 运行时类型 + 虚函数分派表”。
- 当你写
Base* p = new Derived; p->f();,编译器会把它变成“通过p当前指向对象里的vptr找到对应函数槽位,再间接调用”。 - 如果是对象本身调用而不是指针/引用调用,往往仍然是静态绑定。
二、构造函数、析构函数、继承、多态¶
7. 编译器会在什么情况下合成默认构造函数?¶
答法:
- 一个类自己没写构造函数,但它的 成员对象 或 基类 需要默认构造时,编译器可能为它合成默认构造函数。
- 如果类有虚函数或虚基类,编译器也需要借助构造过程完成相关隐藏字段初始化。
- 但合成出来的默认构造函数 不是“帮你初始化所有内置类型成员”;比如裸指针、普通内置类型不会自动变成你想要的值。
常见坑:
- “没写构造函数” ≠ “所有成员都自动安全初始化”。
8. 为什么初始化列表很重要?¶
答法:
const成员、引用成员、没有默认构造函数的成员对象、没有默认构造函数的基类,都必须在初始化列表里处理。- 初始化列表是 直接构造,不是“先默认构造再赋值”。
- 面试里一句话总结:能在初始化列表里完成的初始化,就别拖到构造函数体里做赋值。
9. 构造顺序和析构顺序怎么说最稳?¶
答法:
- 构造:先基类,后成员,最后执行当前类构造函数体。
- 多个成员的构造顺序看 声明顺序,不是看初始化列表书写顺序。
- 析构顺序完全反过来:先执行当前类析构函数体,再按成员逆序、基类逆序销毁。
10. 为什么构造函数不能是虚函数?¶
答法:
- 因为调用构造函数时,对象还没完整构造出来,运行时多态依赖的“完整动态类型”尚未稳定。
- 构造阶段
vptr还在一步步建立;让构造函数虚派发没有清晰语义。 - 更直接地说:你必须先知道对象是什么类型,才能去构造它;不能靠虚派发反过来决定构造谁。
11. 构造函数或析构函数里调用虚函数会发生什么?¶
答法:
- 不会发生你以为的“完整多态”。
- 在基类构造函数里调用虚函数,调用到的是 当前构造层级 的版本,不会分派到尚未构造完的更派生类版本。
- 析构时同理,已经析构掉的派生部分不能再参与多态。
面试加分句:
构造/析构期间对象的动态类型会被视为当前正在构造或析构的那个层级,所以不要在这两个阶段依赖跨层级虚派发。
12. 为什么基类析构函数经常要写成虚函数?¶
答法:
- 如果你打算 通过基类指针删除派生类对象,基类析构函数必须是虚函数,否则行为未定义。
- 因为没有虚析构时,
delete base_ptr;可能只执行到基类析构,导致资源泄漏或部分销毁。
反问式总结:
只要一个类“可能被当作基类并通过基类指针释放”,就应该给它虚析构函数。
13. 什么是对象切片(object slicing)?¶
答法:
- 派生类对象按值赋给基类对象时,只保留基类那一部分,派生类额外状态被“切掉”。
- 所以多态一般通过 指针或引用 承载,而不是按值传递基类对象。
三、拷贝控制、移动语义、NRVO¶
14. 拷贝构造什么时候不能简单按位拷贝?¶
答法:
- 有资源所有权时不能位拷贝,否则容易双删。
- 含虚函数/虚继承的类型也不能用“我自己脑补的 memcpy 语义”去理解,因为对象里不只是裸数据,还可能有隐藏布局信息。
- 面试里最稳的答法是:有资源所有权、对象语义复杂、多态层次存在时,都应该谈拷贝控制而不是按位复制。
15. 什么是 Rule of Three / Five / Zero?¶
答法:
- Rule of Three:如果你自定义了析构、拷贝构造、拷贝赋值中的一个,通常要认真考虑另外两个。
- Rule of Five:C++11 后把移动构造、移动赋值也一起纳入。
- Rule of Zero:更推荐把资源交给 RAII 成员管理,自己不手写这些特殊成员函数。
16. RVO 和 NRVO 是什么?面试里最容易被卡在哪?¶
答法:
- RVO:返回临时对象时直接构造到目标位置。
- NRVO:返回 具名局部对象 时,编译器把这个局部对象直接构造到调用方结果对象里。
- 面试里最容易卡的点是:
1. C++17 起很多 prvalue 场景是保证省略拷贝的;
2. NRVO 对具名局部对象仍然通常是“可选优化”,不能把它当成语言强制保证;
3. 不要依赖构造/拷贝/析构次数去写逻辑。
17. 怎么判断一个返回场景更像 NRVO?¶
答法:
- 一个典型信号是:函数各个返回分支都返回 同一个具名局部对象。
- 例如:
- 这是最适合讲 NRVO 的例子。
18. 为什么说“不要依赖析构次数”?¶
答法:
- 因为 copy elision、NRVO、移动优化会改变临时对象与局部对象的出现次数。
- 同一段代码在不同编译器、优化级别下,构造/析构调用次数可能不同。
- 所以资源管理要依赖 语义,不是依赖“我以为这里一定会析构几次”。
四、智能指针与 RAII¶
19. unique_ptr、shared_ptr、weak_ptr 怎么选?¶
答法:
- 默认优先
unique_ptr:表达独占所有权,语义最清楚,开销最小。 - 共享所有权才用
shared_ptr:它引入控制块和引用计数,不要滥用。 - 观察者关系或打破环引用用
weak_ptr。
一句话版:
先问“有没有唯一所有者”,有就
unique_ptr;没有再考虑shared_ptr;不拥有对象只观察,就weak_ptr。
20. shared_ptr 的核心开销是什么?¶
答法:
- 控制块内的强/弱引用计数维护。
- 引用计数修改往往要用原子操作,跨线程频繁拷贝会有额外同步成本。
- 此外,
shared_ptr让对象生存期变成“共享协商”,排查循环引用和延迟释放也更麻烦。
21. make_shared 为什么通常优于 shared_ptr<T>(new T)?¶
答法:
- 通常能把对象和控制块放在一次分配里,减少分配次数和碎片。
- 异常安全更自然。
但别说绝对:
- 如果你需要自定义删除器、特殊分配策略,或者对象很大且希望控制块与对象生命周期更可分离,就要具体分析。
22. weak_ptr 到底解决什么问题?¶
答法:
- 它不增加强引用计数,所以可以 打破
shared_ptr环。 - 同时它表达“我知道这个对象可能存在,但我不拥有它”。
- 使用时通过
lock()临时拿一个shared_ptr,对象没了就拿不到。
23. shared_ptr 是线程安全的吗?¶
答法:
- 控制块的引用计数变更 是线程安全的。
- 但 被管理对象本身不是自动线程安全的。
- 所以多个线程各自持有一份
shared_ptr没问题;但如果它们并发改对象内部状态,仍然要自己加锁或做同步。
24. 为什么现代 C++ 面试总把智能指针和析构函数绑在一起问?¶
答法:
- 因为本质都在问“谁拥有资源、谁负责释放、释放时机是否明确”。
- 这背后就是 RAII:让资源生命周期绑定到对象生命周期。
- 只要你把“所有权”讲清楚,智能指针、析构、异常安全其实是一套题。
五、锁、原子操作、CAS¶
25. 锁和原子操作的边界是什么?¶
答法:
- 原子操作适合维护 单个共享状态 或少量可组合的无锁协议。
- 锁适合保护 一组不变量,把多个操作打包成一个原子临界区。
- 当问题从“一个标志位”变成“多个字段必须一起满足约束”时,往往就更偏向锁。
26. std::atomic 一定是 lock-free 吗?¶
答法:
- 不一定。
- 标准只保证原子语义,不保证一定用硬件无锁实现。
- 某些类型、某些平台上,
std::atomic<T>可能退化成内部带锁实现。 - 可以用
is_lock_free()看运行平台上的实现情况。
27. CAS 的优点和问题分别是什么?¶
答法:
- 优点:不进入内核,适合构建无锁数据结构,低竞争时非常高效。
- 问题:
1. 高竞争下会大量自旋重试;
2. 可能有 ABA 问题;
3. 算法复杂度和验证难度远高于锁。
28. 什么是 ABA?¶
答法:
- 线程 1 看到值是 A,准备 CAS 改成 B。
- 在线程 1 CAS 前,线程 2 把 A 改成了 C,又改回 A。
- 线程 1 会误以为“值一直没变”,CAS 成功,但中间状态变化已经发生过。
常见应对:
- 版本号/tagged pointer、hazard pointer、epoch reclamation 等。
29. 自旋锁什么时候合适,什么时候不合适?¶
答法:
- 临界区极短、竞争不高、线程不会被长时间挂起时,自旋锁才可能合适。
- 如果临界区里有阻塞、IO、系统调用,或者竞争很重,自旋就会浪费 CPU。
- 面试里一句保守表述:自旋锁不是更高级的锁,只是把等待方式从睡眠换成忙等。
30. 原子能不能完全替代锁?¶
答法:
- 不能。
- 原子保证的是某个操作的原子性;锁保护的是一段临界区和一组状态关系。
- 无锁不等于更简单、更快,也不等于更适合业务代码。
六、C++ 内存序¶
31. happens-before 和 synchronizes-with 怎么区分?¶
答法:
happens-before是更总的先行发生关系。synchronizes-with是跨线程同步的一种关键建立方式,例如一个线程的release store被另一个线程的acquire load读到。- 面试里稳妥说法:
synchronizes-with建立后,结合线程内顺序,就能推出一系列happens-before。
32. 六种 memory_order 应该怎么讲?¶
答法:
relaxed:只保证原子性,不提供同步顺序。consume:理论上只约束依赖链,实际工程里几乎不用,通常直接按acquire理解更稳。acquire:后面的读写不能重排到它前面。release:前面的读写不能重排到它后面。acq_rel:读改写操作同时具备 acquire/release 语义。seq_cst:最强,除了 acquire/release 效果外,还给出一个全局一致的单一总顺序视角。
33. seq_cst、acquire-release、relaxed 的核心区别是什么?¶
答法:
seq_cst:最容易推理,跨线程观察顺序更强,但限制也最强。acquire-release:适合发布-订阅、锁、flag 同步等模式,工程上最常见。relaxed:只在你只关心原子性、不关心跨线程可见顺序时使用,比如统计计数器。
34. 一个典型的 release-acquire 场景怎么答?¶
答法:
- 线程 A 先写普通数据,再
flag.store(true, release)。 - 线程 B 看到
flag.load(acquire)为真后,就能看到线程 A 在 release 之前写入的那些普通数据。 - 这就是典型的“发布数据 + 发布标志”模式。
35. 为什么 volatile 不能替代原子和锁?¶
答法:
volatile主要解决的是“不要把这次访问优化掉”。- 它不保证复合操作原子性,也不建立线程同步关系,更不能替代互斥。
- 所以拿
volatile bool ready做线程同步是经典误区。
36. memory_order_relaxed 典型用在哪?¶
答法:
- 只关心数值最终正确,不关心线程间顺序传播时。
- 最常见例子就是计数器、某些引用计数增加路径。
- 但一旦这个计数变化要和对象析构、发布数据等生命周期事件关联起来,就往往需要更强语义。
37. 为什么说 memory_order_consume 面试里最好别展开?¶
答法:
- 因为它在标准与实现层面长期都比较尴尬,很多实现直接按
acquire处理。 - 你只要知道它比
acquire更弱、依赖链相关、工程上基本不用,就够了。
七、MESI、缓存一致性与内存屏障¶
38. MESI 是什么?¶
答法:
- 它是经典缓存一致性协议的四状态模型:
MModifiedEExclusiveSSharedIInvalid- 核心目标是:多个核各自有缓存时,尽量保证同一 cache line 的读写不会长期彼此矛盾。
39. 既然有 MESI,为什么还需要内存屏障?¶
答法:
- 缓存一致性不等于内存顺序。
- MESI 解决的是“最终哪份 cache line 有效”;但 CPU 内部还有
store buffer、乱序执行、invalidate queue 等机制。 - 所以即使缓存协议保证一致,另一个核也可能暂时还没按你想象的顺序观察到写入。
一句话版:
一致性回答“最后大家会不会看到同一份值”,屏障回答“什么时候、按什么顺序看到”。
40. store buffer 为什么会让人误以为“我明明先写了”?¶
答法:
- 因为对当前 CPU 来说,写进
store buffer往往就像“写完了”。 - 但对其他 CPU 来说,真正可见通常还要等这次写入推进到 cache line,并完成一致性协议相关事务。
- 这就会产生“本核觉得已经写了,别的核却还没看到”的时间差。
41. 什么是读屏障、写屏障、全屏障?¶
答法:
- 读屏障约束读操作顺序。
- 写屏障约束写操作顺序。
- 全屏障同时约束读和写。
- 在 C++ 里你更常通过
atomic的 acquire/release/seq_cst 或显式atomic_thread_fence间接得到这些效果,而不是手写架构指令。
42. 伪共享(false sharing)怎么讲?¶
答法:
- 两个线程明明改的是不同变量,但这两个变量落在同一 cache line 上。
- 一个线程修改会让另一个线程所在核上的同一 cache line 失效,来回抖动。
- 结果是逻辑上没共享多少数据,硬件上却发生了剧烈一致性流量。
面试加分句:
伪共享不是锁竞争问题,而是 cache line 粒度共享导致的一致性抖动问题。
八、最容易被连环追问的 12 题¶
-
类里只有虚函数没有数据成员,
sizeof为什么不是 0?
因为主流实现里对象仍要放vptr,再加上对象大小不能为 0。 -
为什么静态成员不算进对象大小?
因为它不属于某个对象实例,而属于类级别存储。 -
为什么构造函数中不要调用可被派生类覆盖的虚函数?
因为这时派生部分还没构造好,拿不到你以为的完整多态语义。 -
初始化列表和构造函数体赋值有什么差异?
前者是直接构造,后者往往是先构造再赋值。 -
为什么基类析构函数经常需要 virtual?
因为要支持通过基类指针正确销毁派生对象。 -
shared_ptr为什么可能比你想象中贵?
因为有控制块、原子引用计数、潜在环引用和更复杂的生命周期。 -
shared_ptr的引用计数线程安全,为什么对象本身还不安全?
因为控制块安全不等于对象内部状态访问安全。 -
CAS 为什么会 ABA?
因为它只比较“当前值是否等于旧值”,看不见中间历史。 -
为什么
relaxed不能拿来做发布-订阅?
因为它不建立跨线程可见顺序。 -
为什么
volatile不能替代atomic?
因为它不保证原子性,也不建立同步关系。 -
为什么有 MESI 还需要 barrier?
因为一致性和顺序是两件事,CPU 内部还有 buffer 与乱序。 -
NRVO 是不是语言保证?
对具名局部对象的 NRVO 通常仍是优化;不要把它当作逻辑前提。
九、面试表达模板¶
如果面试官让你快速解释一个底层机制,可以尽量按下面这个顺序回答:
- 先给定义:它解决什么问题。
- 再给机制:编译器/运行时/硬件大概怎么做。
- 再给边界:什么时候不成立,或者哪里最容易踩坑。
- 最后给工程判断:实际代码里什么时候该用、什么时候别用。
比如答 shared_ptr、memory_order、虚表、MESI,都能套这个框架。
参考资料¶
本仓库材料¶
pdf_notes/The Senmantics of Data.mdpdf_notes/The Senmantics of Constructors.pdfpdf_notes/C++内存序.mdpdf_notes/MESI.pdfdocs/notes/C++Concurrency/2-Memory Model.mddocs/notes/c++primer/12_动态内存.md
技术事实参考¶
- cppreference: constructors / initialization
https://en.cppreference.com/w/cpp/language/constructor - cppreference: destructors
https://en.cppreference.com/w/cpp/language/destructor - cppreference: copy elision / NRVO
https://en.cppreference.com/w/cpp/language/copy_elision - cppreference: memory order
https://en.cppreference.com/w/cpp/atomic/memory_order - Itanium C++ ABI: virtual table layout
https://itanium-cxx-abi.github.io/cxx-abi/abi.html#vtable
公开面经 / 讨论帖(只用于判断高频,不作为技术结论依据)¶
- 阿里实习一面 C++ 面经:
https://www.nowcoder.com/discuss/353153575460503552 - 金山云 C++ 后台开发面经:
https://www.nowcoder.com/discuss/353159353113149440 - C++ 面试高频题整理:
https://www.nowcoder.com/discuss/732939873486508032 - C++ 社招面经整理:
https://www.nowcoder.com/feed/main/detail/5207e6f6954a4204a93e369f35f26f2e - CAS / 乐观锁 / 悲观锁讨论:
https://www.nowcoder.com/discuss/353148117460082688