网易 C++ 研发岗 二面 面经
1. C++ 的内存模型中,happens-before 关系是什么?
答:happens-before 是 C++11 内存模型中定义的一种偏序关系,用来描述操作间的可见性保证:如果操作 A happens-before 操作 B,那么 A 的结果对 B 一定可见。
建立 happens-before 的方式:
- 同一线程内,代码顺序靠前的操作 happens-before 靠后的操作(sequenced-before)
mutex的 unlock happens-before 下一次 lockatomic的 release 写 happens-before 另一个线程对同一变量的 acquire 读thread::join()使被等待线程的所有操作 happens-before join 返回后的操作
没有 happens-before 关系的并发读写就是数据竞争(data race),属于未定义行为,编译器和 CPU 可以做任意优化。
2. std::unique_ptr 的实现原理是什么?为什么它的大小通常和裸指针一样?
答:unique_ptr 内部持有一个裸指针和一个删除器(deleter)。析构时调用删除器释放资源,禁用拷贝构造和拷贝赋值,只允许移动。
大小和裸指针相同的原因:
- 默认删除器
std::default_delete<T>是一个空类,编译器对其做了空基类优化(EBO) unique_ptr内部通常用std::tuple或直接继承删除器来存储,空删除器不占额外空间- 所以
sizeof(unique_ptr<T>)==sizeof(T*)== 8(64位)
如果使用自定义有状态的删除器(比如 lambda 捕获了变量),则 unique_ptr 的大小会增加。
3. 编译器是如何实现虚函数调用的?虚函数调用比普通函数调用慢在哪里?
答:虚函数调用的汇编过程:
- 从对象内存起始位置取出 vptr
- 通过 vptr 找到 vtable
- 根据虚函数的固定偏移量从 vtable 中取出函数指针
- 通过函数指针间接调用
比普通函数调用慢的原因:
- 多了两次内存间接寻址(取 vptr、取函数指针),普通函数调用地址在编译期确定,直接 call
- 间接调用导致 CPU 分支预测失效,流水线可能被打断
- vptr 和 vtable 的访问可能造成 cache miss,尤其是多态容器中对象类型多样时
实际影响:单次调用差距在纳秒级,性能敏感的热路径(如游戏每帧调用百万次)才需要考虑,可以用 CRTP(奇异递归模板模式)实现编译期多态来规避。
4. 什么是 ABA 问题?如何解决?
答:ABA 问题出现在无锁编程的 CAS(Compare-And-Swap)操作中:
- 线程1读取变量值为 A,准备 CAS
- 线程2将值从 A 改为 B,再改回 A
- 线程1执行 CAS,发现值还是 A,认为没有变化,操作成功
但实际上值已经被修改过了,在某些场景下(如链表节点被删除后重新分配到同一地址)会导致逻辑错误。
解决方案:
- 带版本号的 CAS(Tagged Pointer):将指针和版本号打包在一起,每次修改版本号递增,即使值相同版本号也不同。C++ 中可以用
std::atomic<std::pair<T*, int>>或利用指针高位存版本号 std::atomic的compare_exchange配合版本号- 使用 Hazard Pointer 或 RCU(Read-Copy-Update)等无锁内存回收机制,从根本上避免地址复用
5. 解释一下 C++ 的模板元编程,举一个实际用途。
答:模板元编程(TMP)是利用 C++ 模板在编译期进行计算的技术,本质上是图灵完备的。编译期计算的结果直接嵌入生成的代码,零运行时开销。
核心机制:模板特化作为"if-else",递归实例化作为"循环"。
实际用途——编译期类型检查和条件分发:
// 编译期判断类型,选择不同实现
template<typename T>
void serialize(T val) {
if constexpr (std::is_integral_v<T>) {
// 整数序列化
} else if constexpr (std::is_floating_point_v<T>) {
// 浮点序列化
}
}
另一个典型用途是 std::tuple 的实现、std::variant 的访问、以及各种 type traits。在游戏引擎中常用 TMP 实现零开销的组件系统(ECS)。
6. 进程间通信有哪些方式?共享内存为什么是最快的?
答:主要方式:
- 管道(pipe):单向,只能父子进程间使用;命名管道(FIFO)可以无关进程使用
- 消息队列:内核维护的消息链表,支持按类型读取,有大小限制
- 共享内存:多个进程映射同一块物理内存,直接读写
- 信号量:用于进程间同步,通常配合共享内存使用
- Socket:支持跨机器通信,最通用但开销最大
- 信号(signal):只能传递信号编号,信息量极少
共享内存最快的原因:
- 其他 IPC 方式(管道、消息队列、socket)都需要将数据从用户空间拷贝到内核空间,再从内核空间拷贝到另一个进程的用户空间,至少两次拷贝
- 共享内存直接映射到两个进程的虚拟地址空间,读写不需要系统调用,也没有数据拷贝,只有一次内存访问
代价:需要自己实现同步(用信号量或互斥锁),否则有竞争条件。
7. 说一下 Linux 的虚拟内存机制,一个进程的地址空间是如何布局的?
答:虚拟内存让每个进程以为自己独占整个地址空间,通过页表将虚拟地址映射到物理地址,由 MMU 硬件完成转换。
64位 Linux 进程地址空间布局(从低到高):
0x0000000000000000 ← NULL(不可访问)
↓
.text段 ← 代码段(只读)
.rodata段 ← 只读数据(字符串常量等)
.data段 ← 已初始化全局/静态变量
.bss段 ← 未初始化全局/静态变量(不占文件空间)
↓
堆(向上增长) ← malloc/new
↓
内存映射区 ← mmap、动态库、共享内存
↓
栈(向下增长) ← 函数调用栈
内核空间 ← 用户态不可直接访问
页表缺页中断:访问虚拟地址时,如果页表项不存在(页不在物理内存中),触发缺页中断,内核将对应页从磁盘换入内存,更新页表,再重新执行指令。
8. 什么是 false sharing?如何避免?
答:False sharing(伪共享)是多核 CPU 缓存一致性导致的性能问题:
CPU 缓存的最小单位是缓存行(Cache Line),通常64字节。如果两个线程分别操作不同的变量,但这两个变量恰好在同一个缓存行内,那么一个线程修改自己的变量时,会导致另一个线程的缓存行失效,另一个线程必须重新从内存加载,即使它根本没有访问被修改的变量。
// 有 false sharing 的问题
struct Counter {
int a; // 线程1使用
int b; // 线程2使用
// a 和 b 在同一缓存行,互相干扰
};
// 解决:填充到缓存行大小
struct Counter {
alignas(64) int a;
alignas(64) int b;
};
避免方法:
- 用
alignas(64)将不同线程访问的变量对齐到不同缓存行 - 将线程私有数据放在线程本地存储(
thread_local) - 设计数据结构时,将同一线程频繁访问的数据放在一起(数据局部性)
9. 解释一下 B+ 树,为什么数据库索引用 B+ 树而不用红黑树?
答:B+ 树是一种多路平衡搜索树,所有数据都存在叶子节点,叶子节点之间用链表相连,内部节点只存键值用于导航。
为什么数据库用 B+ 树而不用红黑树:
磁盘 IO 是瓶颈:数据库索引存在磁盘上,每次读取一个节点就是一次磁盘 IO,耗时约10ms。红黑树每个节点只存一个键值,树高为 O(log₂ n),1亿条数据树高约27层,需要27次 IO。B+ 树每个节点可以存几百个键值(一个磁盘页4KB),树高只有3-4层,IO 次数极少。
B+ 树 vs B 树:B+ 树内部节点不存数据,可以存更多键值,树更矮;叶子节点链表支持高效范围查询(BETWEEN、ORDER BY),B 树做范围查询需要中序遍历,效率低。
红黑树适合内存中的有序数据结构(如 std::map),不适合磁盘场景。
10. 手撕题:实现一个 LRU 缓存,要求 get 和 put 都是 O(1)。
答:哈希表 + 双向链表:哈希表实现 O(1) 查找,双向链表维护访问顺序(最近访问的在头部,最久未访问的在尾部)。
class LRUCache {
int cap;
list<pair<int,int>> lst; // {key, val}
unordered_map<int, list<pair<int,int>>::
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
本专栏系统梳理C++技术面试核心考点,涵盖语言基础、面向对象、内存管理、STL容器、模板编程及经典算法。从引用指针、虚函数表、智能指针等底层原理,到继承多态、运算符重载等OOP特性从const、static、inline等关键字辨析,到动态规划、KMP算法、并查集等手写实现。每个知识点以面试答题形式呈现,注重原理阐述而非冗长代码,帮助你快速构建完整知识体系,从容应对面试官提问,顺利拿下offer。
