Skip to content

C++ 对象模型、智能指针、并发与内存序面试问答

约 5630 个字 5 行代码 1 张图片 预计阅读时间 28 分钟

使用说明

这篇文档不是泛泛的 C++ 八股,而是把你仓库里的几条主线揉成一套面试回答骨架:

  • 对象模型主线:来自《深度探索 C++ 对象模型》对应笔记,重点看 vptr/vtable、对象布局、构造/析构语义。
  • 并发主线:来自本仓库的原子、锁、memory order、futex/MESI 笔记。
  • 高频题信号:截至 2026-03-11,公开面经里反复出现 虚表虚析构智能指针CASmemory_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 理解为什么“有一致性协议还需要内存屏障”

高频题地图

面试里这些题通常不是孤立的,而是按一条链追问:

  1. 对象模型:类大小怎么算,虚表/虚表指针在哪,多继承和虚继承怎么变复杂。
  2. 构造析构:默认构造什么时候被合成,初始化列表为什么重要,构造/析构里能不能调虚函数。
  3. 继承多态:多态底层怎么实现,为什么基类析构函数常写成虚函数,对象切片是什么。
  4. 资源管理:为什么现代 C++ 强调 RAII,unique_ptr/shared_ptr/weak_ptr 分别怎么选。
  5. 并发语义:锁和原子有什么边界,CAS 为什么会有 ABA,memory_order 到底控制了什么。
  6. 硬件视角: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?

答法:

  • 一个典型信号是:函数各个返回分支都返回 同一个具名局部对象
  • 例如:
C++
T f() {
    T result;
    if (...) return result;
    return result;
}
  • 这是最适合讲 NRVO 的例子。

18. 为什么说“不要依赖析构次数”?

答法:

  • 因为 copy elision、NRVO、移动优化会改变临时对象与局部对象的出现次数。
  • 同一段代码在不同编译器、优化级别下,构造/析构调用次数可能不同。
  • 所以资源管理要依赖 语义,不是依赖“我以为这里一定会析构几次”。

四、智能指针与 RAII

19. unique_ptrshared_ptrweak_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-beforesynchronizes-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_cstacquire-releaserelaxed 的核心区别是什么?

答法:

  • 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 是什么?

答法:

  • 它是经典缓存一致性协议的四状态模型:
  • M Modified
  • E Exclusive
  • S Shared
  • I Invalid
  • 核心目标是:多个核各自有缓存时,尽量保证同一 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 题

  1. 类里只有虚函数没有数据成员,sizeof 为什么不是 0?
    因为主流实现里对象仍要放 vptr,再加上对象大小不能为 0。

  2. 为什么静态成员不算进对象大小?
    因为它不属于某个对象实例,而属于类级别存储。

  3. 为什么构造函数中不要调用可被派生类覆盖的虚函数?
    因为这时派生部分还没构造好,拿不到你以为的完整多态语义。

  4. 初始化列表和构造函数体赋值有什么差异?
    前者是直接构造,后者往往是先构造再赋值。

  5. 为什么基类析构函数经常需要 virtual?
    因为要支持通过基类指针正确销毁派生对象。

  6. shared_ptr 为什么可能比你想象中贵?
    因为有控制块、原子引用计数、潜在环引用和更复杂的生命周期。

  7. shared_ptr 的引用计数线程安全,为什么对象本身还不安全?
    因为控制块安全不等于对象内部状态访问安全。

  8. CAS 为什么会 ABA?
    因为它只比较“当前值是否等于旧值”,看不见中间历史。

  9. 为什么 relaxed 不能拿来做发布-订阅?
    因为它不建立跨线程可见顺序。

  10. 为什么 volatile 不能替代 atomic
    因为它不保证原子性,也不建立同步关系。

  11. 为什么有 MESI 还需要 barrier?
    因为一致性和顺序是两件事,CPU 内部还有 buffer 与乱序。

  12. NRVO 是不是语言保证?
    对具名局部对象的 NRVO 通常仍是优化;不要把它当作逻辑前提。


九、面试表达模板

如果面试官让你快速解释一个底层机制,可以尽量按下面这个顺序回答:

  1. 先给定义:它解决什么问题。
  2. 再给机制:编译器/运行时/硬件大概怎么做。
  3. 再给边界:什么时候不成立,或者哪里最容易踩坑。
  4. 最后给工程判断:实际代码里什么时候该用、什么时候别用。

比如答 shared_ptrmemory_order虚表MESI,都能套这个框架。

参考资料

本仓库材料

  • pdf_notes/The Senmantics of Data.md
  • pdf_notes/The Senmantics of Constructors.pdf
  • pdf_notes/C++内存序.md
  • pdf_notes/MESI.pdf
  • docs/notes/C++Concurrency/2-Memory Model.md
  • docs/notes/c++primer/12_动态内存.md

技术事实参考

公开面经 / 讨论帖(只用于判断高频,不作为技术结论依据)