Appearance
网易 C++ 技术一面回顾
面试官提问清单
C++
- 在一个大型 MMO 游戏中,开发者大量使用了类似
std::vector、std::vector<std::vector<...>>的结构,导致可执行文件体积急剧增加。从底层原理解释为什么会膨胀?如何利用 C++ 底层特性进行架构级优化? - C++ 为什么引入
static_cast、dynamic_cast、const_cast、reinterpret_cast四种转换替代 C 风格强转? - 追问:
const_cast的作用是什么?什么场景会用到它? - 为什么不能在析构函数中抛出异常?
final和override关键字的作用是什么?基类析构函数为什么要声明为虚函数?
操作系统与容器
- 在容器里跑游戏服务,若宿主机调度器给容器限了 CPU 配额,游戏进程的“公平”会受什么影响?
syscall和老的int 0x80方式在进入内核的机制上有什么不同?为什么新的架构多用syscall?- 在生产环境对运行中进程做
strace有什么风险?有什么更轻量的替代方案?
网络
select、poll、epoll在监听大量 fd 时,为什么epoll更省 CPU?- QUIC 为什么选择在 UDP 而非 TCP 之上进行设计?说明 QUIC 相比 TCP/TLS 的优势,并讨论这种“在应用层/运输层交界处进行创新”的设计思想对网络架构演进的影响。
现场编程
- 有一批贴纸,每张贴纸有一个编号,编号不同的贴纸款式不同,编号相同的款式相同。每个小朋友拿 3 张贴纸,且 3 张贴纸款式互不相同。给定 N 和每个编号,求最多能分给几个小朋友。
知识点回顾
模板实例化与代码膨胀
可执行文件体积增加要区分代码段膨胀与数据段膨胀:
std::vector<T>是类模板,每出现一组新的模板实参组合(元素类型、分配器),编译器就实例化一整份成员函数代码(构造、push_back、迭代器、析构等);嵌套容器叠加展开,vector<vector<T>>各层都生成近似但不相同的代码;- 链接器只能合并完全相同的符号(COMDAT/弱符号),不同实例化的代码无法合并;模板又常内联展开,代码段进一步膨胀;
- 数据段膨胀(如全局大数组非零初始化存
.data、零初始化存.bss)是另一回事,不要把两者混为一谈。
优化方向:extern template 显式实例化(头文件抑制隐式实例化,单一 .cpp 提供唯一实例化);类型擦除(统一存储 + 类型安全访问器);扁平结构避免 vector<vector<T>> 深嵌套;pmr 统一分配器减少分配器维度实例化;重型实现不要全放头文件。详见 模板与 C++20。
四种类型转换
| 转换 | 作用 | 失败行为 |
|---|---|---|
static_cast | 编译期可检查的相关类型转换 | 不相关类型编译报错 |
dynamic_cast | 多态继承体系的运行期安全向下/交叉转换(需 RTTI) | 指针失败返回 nullptr,引用失败抛 std::bad_cast |
const_cast | 添加/移除 const(或 volatile)限定 | 不加限定符时编译失败 |
reinterpret_cast | 位级重解释,不检查、不转换值 | 几乎总在编译期允许,危险 |
C 风格强转是上述行为的任意组合且无检查。原则:优先最窄语义的转换;“转换失败返回空指针”只适用于 dynamic_cast 的指针形式,不能泛化。
析构函数为什么不能抛异常
- C++11 起析构函数默认 noexcept(true),异常逃逸出析构函数直接调用
std::terminate,不会被外层 catch 接住; - 栈展开期间析构函数再抛异常(双重异常)同样立即终止;
- 析构函数职责是释放资源,抛异常会中断清理并泄漏;
- 正确做法:析构内 try/catch 吞掉并记录,不让异常逃逸;可能失败的清理留给显式接口(如
flush())。
final / override / 虚析构
override:写在重写函数后,签名不匹配时编译报错而不是静默隐藏;final:修饰类 = 禁止继承;修饰虚函数 = 禁止继续覆盖;- 基类虚析构保证通过基类指针/引用
delete派生对象时走完整析构链,否则是未定义行为;凡是被多态继承的基类都应写虚析构。
系统调用进入机制:int 0x80 与 syscall
| 路径 | 机制 | 特点 |
|---|---|---|
int 0x80(软件中断/中断门) | 经 IDT 转内核入口,特权级变化时压栈保存 SS/ESP/EFLAGS/CS/EIP 等上下文 | 通用但重:查 IDT 并走完整中断门路径;它不经过外部 PIC/APIC 中断控制器 |
syscall/sysret | 专用指令直读 MSR(IA32_LSTAR)跳转内核入口 | 轻量、延迟低,适合高频系统调用 |
新架构(x86-64)多用 syscall:省去全套压栈/弹栈与 IDT 查找,指令路径短,现代 CPU 有专门优化;32 位时代还有 sysenter/sysexit(类似思路但需 VDSO 辅助)。vDSO 进一步让 gettimeofday 等无状态调用不进内核。详见 进程、线程与调度。
CFS 与容器 CPU 配额
- CFS 按 vruntime 维护长期吞吐公平:任务按权重获得比例化 CPU 时间,睡眠任务醒来获得补偿;
- 容器 CPU 配额 = CFS 带宽控制:
cpu.cfs_quota_us / cpu.cfs_period_us(如 400ms/100ms = 最多 4 核),超配额任务被 throttle 限流,即使系统空闲也要等下一周期; - 对延迟敏感型服务的影响:限流导致瞬时 CPU 饥饿、逻辑帧/心跳/Tick 抖动;vruntime 公平不保证延迟公平;
- 对策:绑核 + 预留独占核(isolcpus)、配额设整核、监控
cpu.stat的nr_throttled。
strace 的风险与轻量替代
- strace 基于
ptrace:每次系统调用都要停下目标进程,系统调用密集进程可能慢几倍到几十倍;目标被暂停改变时序(Heisenbug);可能被 ptrace_scope/seccomp 拒绝;输出可能打满磁盘; - 轻量替代:
perf(采样 + 硬件计数器,不改写目标行为)、eBPF/bpftrace(内核内探针、按需插桩)、ftrace、应用层埋点。
select / poll / epoll
select:fd_set 位图,默认 1024 上限;每次调用全量拷贝 fd 集合到内核、线性遍历(O(n))、结果拷回;poll:pollfd 数组无 1024 上限,仍是全量拷贝 + 全量遍历(O(n));epoll:内核红黑树管理 fd + 就绪链表,fd 就绪时回调挂入就绪链表,epoll_wait只需取就绪项(O(k));增量epoll_ctl无需全量拷贝;- 适用:连接数大、活跃连接少(C10K)时 epoll 优势巨大;支持 LT(水平触发,默认)/ ET(边缘触发)。
完整事件循环、背压和 io_uring 对比见 网络服务器 I/O。
QUIC 为什么基于 UDP
- TCP 的可靠传输、拥塞控制、连接状态固化在内核协议栈,修改要等内核/OS 升级;QUIC 把可靠性/拥塞/连接逻辑搬到用户态,可随应用快速迭代、可插拔(可 A/B 实验不同拥塞算法);
- 相比 TCP/TLS 的优势:
- 握手 RTT 少:首次 1-RTT,重连 0-RTT(TCP+TLS1.2 需 1.5–2 RTT);
- 消除队头阻塞:单连接内多条独立流,流间互不阻塞(HTTP/2 在 TCP 上仍会头阻塞);
- TLS 1.3 内建:加密内置于协议,避免分层可干预性;
- 连接迁移:连接 ID 不绑定 4 元组,切换网络不中断;
- 拥塞控制可迭代:用户态部署 BBR 等新算法无需改内核;
- 设计思想:把传输层创新从内核拉回应用层,符合端到端原则,中间设备保持简单;HTTP/3 = QUIC 落地。
QUIC 的包/帧/流、0-RTT、防放大、连接迁移与 DATAGRAM 边界见 TCP、UDP 与 QUIC。
编程题:发贴纸
模型化:每种编号数量为 c_i,分给 K 个小朋友 ⇔ 每款最多被使用 K 次(每组每款至多一张)且总需求 3K ≤ N。K 可行当且仅当 Σ min(c_i, K) ≥ 3K,可行性关于 K 单调,二分答案:
cpp
#include <iostream>
#include <unordered_map>
#include <algorithm>
using namespace std;
int main() {
int N; cin >> N;
unordered_map<int, int> cnt;
for (int i = 0; i < N; ++i) { int m; cin >> m; ++cnt[m]; }
int lo = 0, hi = N / 3, ans = 0;
while (lo <= hi) {
int K = (lo + hi) / 2;
long long usable = 0;
for (auto& [id, c] : cnt) usable += min(c, K); // 每款最多用 K 张
if (usable >= 3LL * K) { ans = K; lo = K + 1; }
else hi = K - 1;
}
cout << ans << endl;
}自测要点:N < 3 输出 0;只有 1~2 种款式输出 0(如 1 1 2 2 → 0);数量不均衡(如 4,4,2 → 2);恰好三种各 3 张 → 3。不要用“出现次数最多的款”直接套简单公式,先验证边界。
本次回答中需要改进的地方
C++ 主题
- 问题:模板体积膨胀题答成了
.data/.bss数据段初始化,与题目要求的模板实例化代码膨胀完全不是一回事。 - 改进:先抓题干主语(“可执行文件体积” + “std::vector” → 代码段/模板实例化);把 extern template、类型擦除、扁平结构背成优化清单。
- 问题:四种 cast 把“转换失败返回空指针”错误泛化到所有转换,且漏掉
const_cast的作用(追问直接未答上)。 - 改进:背四行对比表(作用 + 失败行为);
dynamic_cast指针失败返回 nullptr、引用失败抛 bad_cast,其余转换不存在“运行期失败返回值”。 - 问题:析构函数抛异常答成“栈展开逐级出栈直到被 catch”,机制说反了。
- 改进:三句话——析构默认 noexcept;逃逸即 terminate;栈展开中再抛即双重异常;修复方式是 catch 住不抛出。
- 问题:final/override 只能挤牙膏式回答,停顿 30 秒以上,未提 final 修饰虚函数和 override 的编译期校验价值。
- 改进:按“定义 → 用途 → 场景 → 反例”四句结构,8 句以内讲完,录音自练。
操作系统与容器
- 问题:容器 CPU 配额、syscall 与 int 0x80、strace 三题全部未作答,直接放弃。
- 改进:补 CFS/配额/throttle 与延迟敏感场景;补中断门 vs 快速系统调用路径差异;补 ptrace 原理与 perf/eBPF 替代方案。不会的题给“相关方向 + 不确定点 + 请求提示”的诚实框架,不要直接沉默。
- 问题:epoll 只答出“就绪队列 vs 轮询”,没有复杂度、上限、拷贝次数、LT/ET。
- 改进:背 select/poll/epoll 三列对比表,量化 O(n) vs O(k)。
网络
- 问题:QUIC 答出了用户态、连接迁移、流量控制、HTTP/3,但“相比 TCP/TLS 的优势”只说出用户态一点。
- 改进:背 QUIC 五条优势(握手 RTT、队头阻塞、TLS 内建、连接迁移、拥塞控制可迭代),并准备“传输层创新拉回应用层”的演进论述。
现场编程
- 问题:只处理了“恰好 3 种款式”的特殊分支,一般情形全错;对
unordered_map::erase迭代器语义不熟,现场反复查 API;耗时约 22 分钟且最终未提交;全程未向面试官口述思路。 - 改进:先做模型化(约束翻译)再写码;写码前口述思路与复杂度;提前熟悉面试平台“切换语言/运行/提交”流程;提交前跑 2–3 个自造边界用例。
面试表达
- 问题:回答开头频繁出现长停顿和填充词,熟悉的知识点因表述犹豫而显得没有掌握;反问环节答“暂时没有什么”。
- 改进:固定“结论 → 原理 → 边界”结构;每场面试准备 2–3 个反问问题(技术栈、业务方向、成长路径)。