用法:考前 3 天每天手机过一遍;考前 30 分钟只看第一节。每条一行,卡壳就点回对应章节。
覆盖 02~13 章 + 15 章题库,所有结论与正文一致。
- 多态只对指针/引用生效,按值传参会对象切片,调回基类版本。
- 基类析构必须 virtual,否则
delete 基类指针是 UB:常见表现只调 ~Base、派生资源泄漏。
- 构造/析构里调虚函数不发生动态绑定,走当前正在构造/析构那一类的版本。
- 构造顺序 = 基类 → 成员(按声明顺序)→ 函数体,跟初始化列表书写顺序无关。
- sizeof 答“占地多大”,alignof 答“住址要求”;结构体总大小必须是最大对齐数的整数倍。
- 空类 sizeof == 1;64 位下含虚函数的类 sizeof == 8(一个 vptr)。
- new = malloc + 构造;
new[] 配 delete 是 UB;new int 不初始化、new int() 才是 0。
- shared_ptr 管“指针”不管“对象”:引用计数原子(安全),对象内容读写仍要自己加锁。
- 循环引用把一方改 weak_ptr;
lock() 提升时才临时把强计数 +1,保证提升期间对象不死。
std::move 不移动任何东西,它只是 static_cast<T&&>;搬资源的是移动构造函数。
- 移动构造不标 noexcept,vector 扩容会退化为拷贝(
move_if_noexcept);类不可拷贝时仍会移动。
- vector 扩容 GCC ×2、MSVC ×1.5,摊还 O(1);扩容后迭代器/指针/引用全部失效。
- unique_ptr 不能拷贝,但能放进 vector —— vector 只要求元素可移动。
- 函数模板不能偏特化,用“重载 + 全特化”替代;模板也不能是虚函数。
- map 的
operator[] 会插入默认值;只想查存在用 find / contains。
- wait 必须带谓词(内部就是 while 循环),这样才免疫虚假唤醒;条件变量必须配 unique_lock。
- 死锁四条件破一个就够,最常用“所有线程按固定顺序加锁”。
- 内存序三档:relaxed 只保证原子性;acquire/release 配对做发布-消费;seq_cst 默认、最强也最慢。
- 缓存行 64 字节,主内存比 L1 慢 100 倍以上;SoA 让缓存行里全是“要用的数据”。
- DrawCall 贵在 CPU 提交 + GPU 状态切换,不是 GPU 算不过来;减少靠合批、图集、实例化。
| 变量 |
段 |
生命周期 / 初始化 |
| 全局变量 |
.data(已初始化)/ .bss(未初始化) |
程序启动 → 结束,默认 0 |
| static 全局变量 |
.data / .bss |
程序启动 → 结束,默认 0 |
| static 局部变量 |
.data / .bss |
首次执行到声明处初始化,活到程序结束(作用域仅函数内) |
| 局部变量 |
栈 |
函数调用期间,默认垃圾值 |
new / malloc 的对象 |
堆 |
直到 delete / free,不自动清零 |
字符串常量 "hello" |
.rodata(只读) |
程序周期,改写会段错误 |
| const 全局常量 |
.rodata |
程序周期 |
| const 局部变量 |
栈(编译期可优化) |
同局部变量 |
| 虚函数表 vtable |
.rodata |
程序周期,每类一份、同类对象共享 |
| 函数指令 |
.text |
程序周期 |
默认初始化规则:全局/static 默认 0;栈和 new int 默认不初始化;new int() 才是 0。
| 判据 |
结论 |
| 每个成员的起始偏移 |
必须是 alignof(成员) 的整数倍;pack(n) 时取 min(n, alignof) |
| 结构体对齐值 |
= 成员中最大的 alignof |
| 结构体总大小 |
必须是结构体对齐值的整数倍(尾部补 padding) |
{char, int, double} |
16 |
{double, int, char} |
16 |
{char, char, int} |
8 |
{char, double, int} |
24 |
{int, char, char} |
8 |
| 省 padding 口诀 |
按成员大小从大到小排 |
| 空类 / 空结构体 |
1(对象地址必须不同) |
| 含虚函数的类 |
64 位下 8(一个 vptr) |
#pragma pack(push,1) 的 {char,int} |
5 |
小端上 int x = 0x12345678 |
内存低→高是 78 56 34 12(低位字节存低地址) |
| 判大小端 |
int x=1; *(char*)&x == 1 → 小端;union 法同(严格说是 UB,规范写法 memcpy / bit_cast) |
| 项 |
一句话 |
预处理 .cpp → .i |
展开 include、替换宏、条件编译、删注释 |
编译 .i → .s |
词法 → 语法 → 语义 → 优化 → 汇编 |
汇编 .s → .o |
机器码 + 符号表 + 重定位表 |
链接 .o + 库 → 可执行 |
符号解析(找定义)+ 重定位(填真实地址) |
静态库 .a/.lib |
链接期把代码拷进可执行文件:体积大、部署独立、启动快 |
动态库 .so/.dll |
运行期加载:体积小、可共享、可热更,缺文件运行时报错 |
inline 函数 / 变量 |
头文件里放定义合法:编译期各生成一份,链接期按 COMDAT 合并成 1 份 |
| 模板 |
实例化时才生成代码,定义必须在使用处可见(放头文件) |
extern "C" |
关掉 C++ 名字修饰(name mangling),让 C++ 能链 C 的符号 |
| static 全局变量 / 函数 |
内部链接,只在本 .cpp 可见,避免跨文件符号冲突 |
#pragma once vs #ifndef |
前者编译器按文件去重、更快;后者靠宏名、跨平台 |
undefined reference |
只声明没定义 / 源文件没编译 / 忘了链库 / 符号名不一致 |
| 编译错误 vs 链接错误 |
前者报 .cpp:行号(语法/类型);后者每个文件都能编,连起来找不到符号 |
| 对比项 |
栈 |
堆 |
| 分配方式 |
编译器自动分配/释放 |
程序员手动 new/delete |
| 速度 |
快(只移动栈指针) |
慢(查空闲块、可能系统调用) |
| 容量 |
默认约 1~8MB,递归太深栈溢出 |
大,可达 GB 级 |
| 碎片 |
无(严格 LIFO) |
有内存碎片 |
| 增长方向 |
向下(高地址 → 低地址) |
向上 |
| 越界后果 |
段错误/崩溃 |
破坏堆管理结构,可能延迟崩溃 |
| 对比项 |
指针 |
引用 |
| 本质 |
存放地址的变量 |
变量的别名(绑定到对象) |
| 是否占空间 |
是(64 位 8 字节) |
概念上不占(实现上常是常量指针,优化后常为 0) |
| 必须初始化 |
否(但最好置空) |
必须 |
| 能否改指向 |
能 |
不能(绑定后不可换) |
| 能否为空 |
能(nullptr) |
不能(没有 NULL 引用) |
| 多级 |
支持 int** |
不支持 |
sizeof |
恒为指针宽度 |
等于所引用对象的大小 |
| 函数重载 |
f(int*) 和 f(int&) 是不同函数 |
同 |
使用建议:能引用就不用指针;必须可空/可换绑/要存进容器时用指针。
野指针 = 没初始化;悬垂指针 = 指向已释放对象;空指针 = nullptr。
避免三件套:① 初始化即置空/绑定 ② delete 后置空 ③ 优先用智能指针。
|
malloc / free |
new / delete |
| 分配 |
只分配裸内存 |
分配内存 + 调用构造函数 |
| 释放 |
只释放内存 |
调用析构函数 + 释放内存 |
| 返回类型 |
void*(需强转) |
类型化指针 T* |
| 大小参数 |
自己算字节数 |
编译器按类型算 |
| 失败处理 |
返回 NULL |
抛 std::bad_alloc |
| 运算符重载 |
不支持(库函数) |
可重载 operator new/delete(引擎自定义分配器) |
| 数组 |
malloc(n*sizeof(T)) |
new T[n] / delete[] |
new[] 必须配 delete[](常见实现会在数组头记录元素个数);混用是 UB,可能只析构一个元素。
| 场景 |
选 |
理由 |
| 默认独占所有权 |
unique_ptr |
禁拷贝可移动;默认删除器下通常与裸指针同大小、零额外开销 |
| 多个持有者共享同一对象 |
shared_ptr |
引用计数,控制块在堆上 |
| 打破循环引用 / 观察者 |
weak_ptr |
只加弱计数;lock() 提升为 shared_ptr |
| 包装非 new 资源(FILE*、贴图 ID) |
智能指针 + 自定义删除器 |
默认删除器只会 delete |
| 用基类指针持有派生对象 |
unique_ptr<Base> |
多态基类必须有虚析构,否则 delete 基类指针是 UB |
| 多线程共享 shared_ptr |
计数可放心,对象内容要加锁 |
shared_ptr 管“指针”不管“对象” |
| 对象很大 + 有长期 weak_ptr + 想尽早释放 |
shared_ptr(new T) |
强计数归零就释放对象内存 |
|
unique_ptr |
shared_ptr |
| 所有权 |
独占 |
共享 |
| 拷贝 |
禁止(=delete) |
允许,计数 +1 |
| 移动 |
支持,转移所有权 |
支持 |
| 额外开销 |
无(默认删除器) |
对象指针 + 堆上控制块指针 |
| 计数线程安全 |
— |
计数增减是原子的 |
| 放进 vector |
能(vector 只要求可移动) |
能 |
| 适用 |
默认首选、性能热路径 |
真需要多所有者时(原子计数比裸指针慢) |
|
make_shared |
shared_ptr(new T) |
| 内存分配次数 |
1 次(对象 + 控制块一起) |
2 次 |
| 缓存友好 |
好(对象和控制块挨着) |
差 |
| 异常安全 |
安全 |
有泄漏窗口(f(shared_ptr(new T), g()) 里 g 先抛) |
| 自定义删除器 |
不支持(只能用默认) |
支持 |
| 对象内存何时释放 |
强计数归零只析构对象,整块内存要等弱计数也归零 |
强计数归零即释放对象内存 |
| 结论 |
能用就用 |
对象大 + 有 weak_ptr + 想尽早释放对象内存时用 |
| 时机 |
顺序 |
| 构造 |
基类(按继承顺序)→ 成员(按声明顺序)→ 构造函数体 |
| 初始化列表写反 |
没用,实际顺序仍按声明顺序,编译器会警告 |
| 析构 |
自身析构体 → 成员(逆声明顺序)→ 基类 |
| 虚基类场景 |
A → B → C → D;A 由最派生类 D 负责构造,B/C 构造里的 A(1) 被忽略 |
| 必须用初始化列表 |
const 成员、引用成员、没有默认构造的成员对象 |
| 初始化列表为什么快 |
直接以目标值构造(一次构造);体内 m = 42 要先构造临时对象再赋值 |
| 析构函数 |
无参数无返回值不可重载;不要抛异常(栈展开时抛 → std::terminate) |
| 代码 |
触发 |
S b = a; / S c(a); |
拷贝构造(有新对象) |
b = c;(b 已存在) |
拷贝赋值 |
void f(S s) 按值传参 |
拷贝构造 |
S g(){ S t; return t; } |
一般被 RVO/拷贝省略,退化为移动,最后才是拷贝 |
参数是右值(临时对象、std::move(x)) |
优先移动构造 |
push_back(std::move(x)) / emplace_back(args) |
移动 / 原地构造 |
| 类管理裸资源但没写拷贝构造 |
默认浅拷贝 → 两次析构同一块内存 → double free |
| 移动之后的源对象 |
“有效但未指定”:可析构、可重新赋值,内容不可依赖 |
| 规则 / 关键字 |
一句话 |
| 六个默认函数 |
默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值 |
| Rule of 0 |
成员都用标准库类型(string/vector/智能指针),一个都不用写 |
| Rule of 3 |
自定义析构 / 拷贝构造 / 拷贝赋值中任意一个 → 三个都要写 |
| Rule of 5 |
涉及动态资源 + 移动语义 → 五个都写(或都 delete) |
=default |
要回编译器默认实现(自定义了别的构造后默认构造被吞掉) |
=delete |
禁用函数:禁拷贝、禁隐式转换、禁 new |
explicit |
禁单参构造的隐式转换:setPos(1.0f) 直接编译报错 |
| 空基类优化 EBO |
空类当基类时那 1 字节可被合并(sizeof 4 而非 8);当成员不行 |
| 空类 / 含虚函数的类 |
sizeof(Empty)==1;含虚函数 64 位下 8 |
| 场景 |
绑定方式 |
| 非虚函数 |
静态绑定(编译期) |
| 通过值传递调用虚函数 |
静态绑定(切片后调基类版本) |
| 通过指针/引用调用虚函数 |
动态绑定(运行期查虚表) |
| 构造函数/析构函数里调用虚函数 |
静态绑定(当前正在构造/析构的类) |
| 直接对对象调用(编译器能确定类型) |
静态绑定(devirtualization,可能内联) |
显式类名限定 d.Animal::speak() |
静态绑定 |
对象切片:void f(Animal a) + f(dog) → 输出基类版本,多态失效。多态只对指针/引用生效。
| 追问 |
一句话答案 |
| vtable 在哪 |
只读数据段 .rodata,每个类一份,同类对象共享 |
| vptr 在哪 |
对象内存偏移 0(单继承);多继承时每个含虚函数的基类子对象各带一个 |
| 有几个虚表 |
≈ 有几个“含虚函数的基类子对象”(纯数据基类不带 vptr) |
| 构造/析构里调用会怎样 |
不发生动态绑定,vptr 指向当前类的表;基类构造里调纯虚函数是 UB |
| 基类析构为什么要虚 |
非虚则 delete 基类指针是 UB:派生类析构不执行、资源泄漏 |
| 开销在哪 |
间接跳转(多 1~2 次内存访问)+ 无法内联 + 分支预测差 |
| 单次多贵 |
约 5~15ns(直接调用 1ns 量级);每帧 × 上万对象才明显 |
| 什么时候能内联 |
去虚拟化之后:直接对象、final 类/函数、构造析构内部、显式限定、LTO |
| 项 |
结论 |
| 多继承构造顺序 |
按继承列表顺序(先 A 后 B) |
C : A, B 布局(A/B 各有虚函数 + int,C 有 int cc) |
A 的 vptr@0、A::a@8、B 的 vptr@16、B::b@24、C::cc@28 → sizeof(C)=40 |
| 两个 vptr 挨着吗 |
通常不,中间隔着基类自己的成员;纯虚接口(无数据)才相邻 |
B* pb = pc; |
地址 = pc + 16(this 指针调整,是正确性的一部分) |
(void*)pa == (void*)pb |
false(比数值);要比“是否同一对象”先都转回 C* |
| 菱形继承两个问题 |
二义性 + 基类子对象被复制两份 |
| 虚继承怎么解 |
只保留一份共享虚基类子对象,位置由最派生类决定 |
| 虚继承的代价 |
访问虚基类成员要先查偏移表(vbase offset / vbtable),多一次间接寻址 |
| sizeof 对比(示例) |
普通继承 8 → 虚继承 24(多两个 8 字节偏移指针 + 对齐填充) |
| 谁构造虚基类 |
最派生类;虚基类最好提供默认构造函数 |
| RTTI 存在哪 |
type_info 挂在虚表前置槽(如 -1 项)→ dynamic_cast/typeid 只对多态类型可用 |
| dynamic_cast vs static_cast |
前者运行期检查(失败返回 nullptr/抛异常,无法内联);后者编译期、不检查、错了是 UB |
| 容器 |
底层 |
查找 |
插入/删除 |
随机访问 |
迭代器失效 |
| vector |
连续动态数组 |
O(n) |
尾 O(1)★,中间 O(n) |
✅ O(1) |
扩容全失效;insert/erase 使插入点/被删点及其后失效 |
| deque |
分段连续 + 中控器 |
O(n) |
头尾 O(1)★ |
✅ O(1) |
头尾插入:迭代器全失效、引用不失效;中间全失效 |
| list |
双向链表 |
O(n) |
O(1)(已知位置) |
❌ |
除被删元素外不失效 |
| forward_list |
单向链表 |
O(n) |
O(1) |
❌ |
同上 |
| set / multiset |
红黑树 |
O(log n) |
O(log n) |
❌ |
不失效(节点式) |
| map / multimap |
红黑树 |
O(log n) |
O(log n) |
❌ |
不失效 |
| unordered_map / unordered_set |
哈希表(链地址) |
平均 O(1) |
平均 O(1) |
❌ |
rehash 时全失效 |
| array |
固定大小内嵌数组 |
O(n) |
— |
✅ O(1) |
— |
★ = 摊还复杂度。选择金句:随机访问 + 尾部增删 → vector;头尾插入 → deque;大量中间插入删除 → list;有序 + 查找快 → map/set;只查快、不在乎顺序 → unordered_map;元素唯一 → set/map,可重复 → multiset/multimap。
| 问题 |
判据 |
| 三个指针 |
start / finish / end_of_storage,管 size 和 capacity |
| 扩容因子 |
GCC ×2,MSVC ×1.5 |
| 为什么指数增长 |
线性增长总拷贝 O(n²);指数增长摊还 O(1) |
| 扩容时元素怎么搬 |
优先移动(移动构造 noexcept 时),否则拷贝 |
| push_back 未触发扩容 |
已有元素的迭代器/引用不失效,但 end() 失效 |
| erase |
被删元素及之后全失效;用 it = v.erase(it),别 erase 后再 it++ |
| reserve vs resize |
reserve 只扩 capacity、不构造元素;resize 改 size 并默认构造 |
| emplace_back vs push_back |
emplace 转发参数原地构造,省一次临时对象构造 + 移动 |
| vector 比 list 遍历快 |
连续内存缓存命中率高 + CPU 顺序预取 |
|
map |
unordered_map |
| 底层 |
红黑树 |
哈希表(链地址法) |
| 复杂度 |
O(log n),稳定无最坏退化 |
平均 O(1),最坏 O(n) |
| 有序性 |
有序(中序遍历) |
无序 |
| key 要求 |
<(或比较器) |
hash 函数 + == |
| 迭代器指向 |
pair<const Key, T>,key 不可改 |
同 |
operator[] |
key 不存在会插入默认值(副作用) |
同 |
| rehash |
— |
负载因子(元素/桶数)超阈值(默认 1.0)时扩容重散列,全迭代器失效 |
| 元素少时 |
可能更快(无退化、常数稳定) |
哈希计算有开销 |
C++ 的 unordered_map 桶内不会转红黑树 —— 那是 Java 8 HashMap 的行为。
为什么用红黑树不用 AVL:平衡要求松(最长路径 ≤ 2×最短)→ 插入删除旋转少、常数小,整体统计性能好。
| 概念 |
一句话用途 |
| 实例化 |
编译期发生,用到哪种类型生成哪种;类模板成员函数惰性实例化 |
| 全特化 |
template<> + 具体类型,给某个类型专门实现 |
| 偏特化 |
限定一类类型(如 T*);类模板可以,函数模板不可以 |
| 函数模板的替代方案 |
重载 + 全特化(重载语义更清晰,特化不参与重载决议) |
| SFINAE |
替换失败不是错误:签名替换非法就跳过该候选,继续找别的重载 |
| enable_if |
enable_if_t<条件,T>:条件成立才有 type,这个重载才存在 |
| if constexpr |
C++17 在同一函数里裁分支;80% 的 enable_if 场景更直观的替代 |
| 可变参数模板 |
递归展开(剥第一个 + 空包出口)或 C++17 折叠表达式 |
| 折叠表达式 |
(args + ...) 右折叠求和;(cout << ... << args) 左折叠打印 |
| 非类型模板参数 |
template<typename T, size_t N>,N 必须是编译期常量 |
| 定义为什么放头文件 |
实例化在使用处的编译期发生,看不到定义 → undefined reference |
| 模板 vs 虚函数 |
模板不能是虚函数;类模板里可以有虚函数;模板可以调虚函数 |
| CRTP |
派生类把自己当模板参数传给基类,基类 static_cast<Derived*> 静态分发 |
| 编译期 vs 运行期多态 |
模板:可内联、零虚表开销,但代码膨胀、类型必须编译期确定 |
| 概念 |
一句话 |
| 值类别 |
左值 = 有名字可取地址;纯右值 = 临时无名;将亡值 = 可被偷资源的右值(move 结果) |
| 右值引用变量本身 |
int&& r = std::move(x); 里的 r 是左值(它有名字) |
| std::move |
只是 static_cast<T&&>,不移动任何东西;搬资源的是移动构造函数 |
| std::forward |
有条件的 move:T 推导为右值引用时才转右值;泛型转发必须用它 |
| 完美转发 |
T&& 转发引用接收 + std::forward<T>(arg) 转发;引用折叠 T&+&&→T&、T&&+&&→T&& |
| 移动后源对象 |
有效但未指定:可析构、可重新赋值,内容不可依赖 |
| 移动 + noexcept |
不标 noexcept 且类可拷贝时,vector 扩容会退化为拷贝 |
| lambda 底层 |
匿名仿函数类(重载 operator()),捕获的变量成为成员;空捕获 [] 可转函数指针 |
| 捕获方式 |
值捕获是快照(改外面不影响);引用捕获会看到外面,也容易悬垂 |
| auto vs decltype |
auto 按值推导、丢 const/引用;decltype 原样保留;decltype(auto) 兼得 |
| const vs constexpr |
const 只保证不可改;constexpr 要求编译期可求值(可做数组大小、模板参数) |
| nullptr vs NULL |
NULL 是整数 0,f(int)/f(char*) 会选错重载;nullptr 是 nullptr_t |
| 协程 vs 线程 |
协程用户态协作式、切换极小、无栈(状态在堆上 coroutine frame);线程内核抢占、适合多核 |
| concepts(C++20) |
给模板参数加约束,报错友好,替代复杂的 SFINAE 写法 |
- C++14:泛型 lambda
[](auto a, auto b)、初始化捕获 [x = expr]、返回类型推导、decltype(auto)、make_unique、constexpr 放宽(支持循环/if)。
- C++17:结构化绑定、
if constexpr、折叠表达式、inline 变量、string_view、optional/variant/any、filesystem、CTAD。
- C++20:concepts、协程、ranges、三向比较
<=>、std::format、consteval/constinit、span、模块。
| 项 |
一句话 |
| 条件① 互斥 |
资源同一时刻只能被一个线程占用 |
| 条件② 占有且等待 |
拿着一个资源等另一个 |
| 条件③ 不可剥夺 |
资源不能强行抢走 |
| 条件④ 循环等待 |
A 等 B 持有的,B 等 A 持有的 |
| 解法① 固定顺序加锁 |
所有线程都先 a 后 b → 循环等待消失(最常用) |
| 解法② 一次性拿所有锁 |
std::scoped_lock(a, b),内部用死锁避免算法(等价 std::lock) |
| 解法③ try_lock + 超时退让 |
拿不到就退让重试(std::try_lock) |
| 补充 |
减少锁粒度、避免嵌套锁;破坏任意一个必要条件即可 |
|
lock_guard |
unique_lock |
scoped_lock |
| 加锁方式 |
构造加锁 |
构造加锁 |
一次锁多个互斥量 |
| 手动 lock/unlock |
❌ |
✅ |
❌ |
| 可移动 |
❌ |
✅ |
❌ |
| 配条件变量 |
❌ |
✅(wait 必须用它) |
❌ |
| 场景 |
绝大多数加锁 |
需要手动控制 / 条件变量 |
同时锁多个(避免死锁) |
| 典型写法 |
lock_guard<mutex> lg(m); |
unique_lock<mutex> ul(m); |
scoped_lock lk(a, b); |
| 本质 |
RAII 封装 lock/unlock |
更灵活的 RAII 锁 |
C++17 多锁 RAII |
| 问题 |
判据 |
| 为什么必须 unique_lock |
wait 要原子地“释放锁 → 挂起 → 唤醒后重新上锁”,lock_guard 不能手动 unlock |
| condition_variable_any |
可用任何满足 BasicLockable 的锁类型 |
wait(lock, pred) 内部 |
等价 while (!pred()) wait(lock); |
| 虚假唤醒 |
被唤醒 ≠ 条件满足,操作系统可能空唤醒 |
| 正确写法 |
cv.wait(ul, []{ return !q.empty(); }); 带谓词 → 自动循环检查,免疫虚假唤醒 |
| 错误写法 |
不带谓词 wait,醒来后用 if 检查 |
| 数据竞争 |
两线程访问同一内存、至少一个写、无同步 → UB;读读不算 |
| std::thread 三条铁律 |
① 析构前必须 join 或 detach,否则 terminate ② 传引用要显式 std::ref ③ 引用对象生命周期要比线程长 |
| 内存序 |
语义 |
用在哪 |
relaxed |
只保证原子性,不保证顺序 |
单纯计数器(不关心顺序) |
acquire |
此操作之后的读写不能被重排到它前面 |
消费者读 |
release |
此操作之前的读写不能被重排到它后面 |
生产者发布 |
seq_cst |
全程序全局一致顺序(默认) |
默认选择,最安全也最慢 |
| 典型配对 |
生产者 release 写、消费者 acquire 读 → release 之前写的所有数据,acquire 之后都能看到 |
生产者-消费者 |
| atomic vs mutex |
atomic:单变量、通常无锁、快;mutex:任意临界区、慢 |
计数器 vs 多行代码 |
| 线程安全单例 |
Magic Static:局部 static 变量,C++11 保证首次初始化线程安全 |
零手写锁,比 DCLP 简单 |
| DCLP 的坑 |
instance = new T() 的赋值可能先于构造,别的线程拿到未构造完的对象 |
C++11 后不必再用 |
| 线程池 / job system |
固定工作线程 + 任务队列 + mutex + 条件变量 + stop 标志;job system 拆小任务按核调度(UE TaskGraph) |
复用线程,避免每系统一线程 |
| 模式 / 实践 |
游戏里怎么用 + 一句话 |
| 单例 |
全局唯一(渲染/资源/音频);C++11 用 Magic Static |
| 单例被诟病的原因 |
全局状态难追踪、隐藏依赖、难测试、生命周期不可控、并发加锁拖性能 |
| 单例的替代 |
依赖注入(构造函数传子系统);Engine 统一持有并分发子系统 |
| 工厂 |
创建逻辑集中;常用“工厂 + unordered_map<TypeId, CreateFunc> 注册表”替代 switch |
| 观察者 |
事件系统:UI/成就/音频订阅逻辑事件,解耦事件源与处理者 |
| 观察者四个坑 |
循环通知、回调里增删监听器(迭代器失效)、订阅者悬垂、热路径性能 |
| 状态 |
角色状态机(Idle/Attack/Dead),避免巨型 if-else;状态多了用转移表/行为树 |
| 命令 |
输入封装成对象:撤销/重做、回放、联机同步(只传命令)、按键重绑定、AI 与玩家共用执行逻辑 |
| 对象池 |
子弹/粒子/特效预分配 + 借还;归还只重置状态、不调析构;池空用扩容 |
| RAII |
构造获取资源、析构释放;lock_guard / unique_ptr / fstream 都是;异常安全根基 |
| Pimpl |
头文件只留不透明指针,改实现不用全量重编译;代价是一次间接访问 |
| 三/五法则 |
要么都写,要么都 =default / =delete,别写一半 |
| 对象池 vs 内存池 |
对象池管“对象生命周期复用”;内存池管“原始内存块复用”;常叠加使用 |
| 层级 / 项 |
数字 / 一句话 |
| 寄存器 |
~1 cycle |
| L1 缓存 |
~4 cycles(~1ns) |
| L2 缓存 |
~12 cycles |
| L3 缓存 |
~40 cycles |
| 主内存 |
~200+ cycles(~100ns),比 L1 慢 100 倍以上 |
| 缓存行 |
64 字节;访问一个 int 会把周围 64 字节一起搬进缓存 |
| 优化第一步 |
先测量(profiler):测量 → 定位(算法 vs 常数)→ 优化 → 复测 |
| 游戏特有指标 |
帧时间、P99 帧率(卡顿感来源)、每帧预算(60fps = 16.7ms) |
| 手段 |
一句话 |
| 缓存友好三招 |
连续内存 + 顺序遍历;字段按访问顺序排;alignas(64) 对齐缓存行 |
| 虚函数开销 |
间接跳转 + 无法内联 + 分支预测差;热路径用模板/CRTP/switch/ECS 替代 |
| 内存池 Pool |
预分配固定大小块 + 空闲链表,分配/释放 O(1)、无碎片 |
| 栈式/帧分配器 |
游标后移分配,帧末整体 reset,O(1) 释放,适合只活一帧的数据 |
| 内存池 vs 对象池 |
内存池管原始内存(不调构造析构);对象池管对象(借还重置状态) |
| 分支预测 |
猜错清空流水线,惩罚约 20 周期;排序数据、branchless、查找表、[[likely]] 缓解 |
| 内联 |
只是建议,编译器决定;大函数内联增大代码体积、可能伤缓存 |
| 其他 |
移动语义(配 noexcept)、SIMD 向量化、多线程并行、延迟计算、空间换时间 |
|
AoS(Array of Structures) |
SoA(Structure of Arrays) |
| 布局 |
每个对象一个结构体连续排 |
每个字段一个数组 |
| 代码样子 |
struct Particle{float x,y,z,vx,vy,vz;} 的 vector |
vector<float> x,y,z,vx,vy,vz; |
| 访问单个对象 |
方便 |
要跨数组 |
| 批量遍历单个字段 |
浪费带宽(把无关字段也拉进缓存行) |
完美(缓存行里全是目标数据) |
| 典型场景 |
少量对象、对象整体使用 |
海量对象、按字段批量处理(粒子、ECS) |
| 与 ECS 的关系 |
— |
ECS 的组件数组天然就是 SoA |
| 阶段 |
做什么 |
频率考虑 |
| 处理输入 |
收集键鼠/手柄/网络输入 |
越快越好(低延迟) |
| 更新 Update |
推进游戏逻辑:AI、物理、动画、状态 |
逻辑一般固定频率(30/60/120Hz) |
| 渲染 Render |
提交绘制命令给 GPU |
跟随显示器刷新率(60/120/144Hz) |
| 循环骨架 |
while (running) { 输入; 更新(dt); 渲染; } |
所有游戏/引擎的骨架 |
| 一帧预算 |
60fps = 16.7ms,渲染/逻辑/物理/AI/动画各分一块 |
超预算就掉帧 |
| 主流混合方案 |
渲染可变步长 + 逻辑/物理固定步长 + 插值 |
兼顾流畅与稳定 |
|
可变步长 |
固定步长 |
| 实现 |
简单直接(每帧用真实 dt) |
稍复杂(accumulator 累积 + 插值) |
| 物理稳定性 |
差:帧率波动 → 物理抖动 |
好:确定性强 |
| 跨机器一致性 |
差 |
好(同输入 → 同结果,联机必须) |
| 表现 |
帧率高时跑得快、低时慢 |
表现一致 |
| 用途 |
渲染(跟随刷新率) |
物理 / 逻辑 / 联机 / 回放 |
| 为什么必须固定 |
— |
确定性:联机同步、回放、物理测试都依赖“同输入同结果” |
| 项 |
一句话 |
| Entity |
只是一个 ID(整数),没有数据也没有行为(如 entity 1024) |
| Component |
纯数据 struct:Position{x,y}、Health{100}、Velocity{dx,dy} |
| System |
纯逻辑函数,处理一类组件:移动系统处理 Position + Velocity |
| 传统 OOP 的痛点 |
想加“会飞”就得新建类 → 多继承爆炸、脆弱的深继承树 |
| 优点① 缓存友好 |
同类组件连续存储(SoA),遍历快 |
| 优点② 灵活组合 |
加能力 = 加组件,不用改继承树 |
| 优点③ 易并行 |
不同系统处理不同数据,job system 直接调度 |
| 优点④ 无深继承 |
告别菱形继承和类爆炸 |
| 缺点 |
组件间通信要小心、调试时对象信息分散、上手门槛高、不适合“对象行为差异极大”的场景 |
| 阶段 |
谁执行 |
频率 |
干什么 |
| 顶点着色 |
GPU 并行 |
每顶点 |
模型 → 世界 → 相机 → 投影变换,算屏幕位置 |
| 图元装配 + 光栅化 |
GPU 固定单元 |
每三角形 |
三角形 → 像素片元(判断哪些像素被覆盖) |
| 片元着色 |
GPU 并行 |
每像素 |
光照、纹理采样,算像素颜色 |
| 输出合并 |
GPU 固定单元 |
每像素 |
深度测试 / 混合 / 模板测试 |
| 输出 |
— |
每帧 |
帧缓冲 → 屏幕 |
| 优化两大方向 |
— |
— |
减少着色器复杂度 + 减少绘制调用 |
| 项 |
一句话 |
| 是什么 |
CPU 向 GPU 提交一次“画这个物体”的命令;换材质/纹理/网格都要一次 |
| 为什么贵① |
CPU-GPU 通信:穿过总线、可能有同步等待 |
| 为什么贵② |
状态切换:换材质/纹理 = GPU 重新配置状态(很慢) |
| 后果 |
GPU 没事干等 CPU,利用率下降 |
| 性能量级 |
现代引擎几百到几千;移动端更敏感,几百个就吃力 |
| 减少① 合批 |
同材质物体合并成一个网格一次画完(静态合批;动态合批有顶点/材质限制) |
| 减少② 纹理图集 Atlas |
多张小图拼一张大图 → 同一纹理就能合批 |
| 减少③ 实例化 Instancing |
同一网格不同变换一次提交 N 个(10 万颗草 1 次 DrawCall) |
| 减少④ 减少状态切换 |
按材质/纹理排序渲染,减少切换次数 |
| 结构 / 概念 |
适用场景 |
| 网格 / 空间哈希 |
物体均匀分布(子弹、大量小物体),实现最简单 |
| 四叉树(2D) |
2D 游戏、地形、单位查询,稀疏分布也能自适应 |
| 八叉树(3D) |
3D 场景、体素、视锥剔除 |
| BVH |
动态物体(角色、刚体),实时更新友好,物理引擎核心 |
| BSP 树 |
室内场景(早期 FPS)、寻路,静态场景专用 |
| 两阶段流程 |
broad phase 用空间分区粗筛候选对 → narrow phase 用包围体精确求交 |
| 包围体 |
AABB 检测最便宜;OBB 更贴合但贵;角色常用胶囊体 |
| 收益 |
把 O(n²) 全对全检测降到接近 O(n) |
|
前向渲染 |
延迟渲染 |
| 思路 |
每个物体直接算光照 |
先渲 GBuffer(位置/法线/颜色/金属度),再统一算光照 |
| 灯光数量 |
灯光多时开销爆炸 |
支持大量光源 |
| 缺点 |
多光源贵 |
显存带宽大、MSAA 支持差、透明物体要回前向 |
| 适用 |
移动端 / 少光源 / 透明物体 |
PC / 主机、大量点光源场景 |
| 主流做法 |
延迟为主体 + 前向管透明 |
同 |
| 渲染优化手段 |
视锥剔除、遮挡剔除、LOD、合批、纹理图集/压缩 |
同 |
| 话题 |
一句话 |
| UE UObject |
引擎几乎所有对象的基类,提供反射、GC、序列化、编辑器集成、网络复制 |
| UE 反射 |
C++ 原生没有;靠宏(UCLASS/UPROPERTY/UFUNCTION)+ UHT 扫描头文件生成元数据 |
| UE GC |
可达性分析 Mark-Sweep:从根引用标记可达,未标记的回收 |
| UPROPERTY 的意义 |
没标记的引用 GC 不认,对象可能被当成垃圾回收掉 |
| UE GC vs shared_ptr |
GC 天然解决循环引用但有停顿;shared_ptr 无停顿但要 weak_ptr 破环 |
| Unity Mono |
IL 运行时 JIT:迭代快、支持热更;iOS 曾禁 JIT |
| Unity IL2CPP |
IL → C++ → AOT 原生码:性能好、平台全、难反编译;包大、难热更;发布正式包用它 |
| 四元数 |
w,x,y,z 四分量:无万向节死锁、slerp 平滑插值、乘法拼接稳定;欧拉角只给人看数值 |
| 状态同步 vs 帧同步 |
状态同步传对象状态(带宽大、反作弊强,MMO/射击);帧同步只传输入两端确定性模拟(带宽极小、难反作弊,RTS/格斗) |
| 帧率 vs 刷新率 |
帧率 = 引擎产帧数,刷新率 = 显示器 Hz;不匹配会撕裂/卡顿,VSync 对齐但增加延迟 |
| 引擎的本质 |
平台隔离 + 公共能力复用 |
要不要用虚函数
- 需要在运行期按对象实际类型选行为吗?
- 不需要 → 普通函数 / 模板,别上虚函数。
- 需要 → 调用点在热路径(每帧 × 上万对象)吗?
- 不是 → 直接用虚函数,可读性优先。
- 是 → 编译期能确定类型吗?
- 能 → 模板 / CRTP / 重载(零虚表开销、可内联)。
- 不能 → 把行为做成数据 + 统一处理(ECS / 数据驱动),或把叶子类标
final 帮编译器去虚拟化。
- 决定用虚函数之后:多态基类析构必须 virtual;重写一律写
override。
选哪个智能指针
- 需要共享所有权吗?
- 不需要 →
unique_ptr(默认首选)。
- 需要 → 会形成环吗?
- 会 → 一方改
weak_ptr(谁不依赖对方存活,谁 weak:子→父、资源→管理器)。
- 不会 →
shared_ptr;但“要自定义删除器”或“对象大 + 有 weak_ptr 想尽早释放对象内存” → 用 shared_ptr(new T),否则 make_shared。
选哪个容器
- 随机访问 + 频繁尾部增删 →
vector
- 头尾都要插(当队列/栈用) →
deque
- 大量中间插入删除、要迭代器稳定 →
list
- 要有序 + 查找快 / 范围查询 →
map / set
- 只求查得快、不在乎顺序、key 好哈希 →
unordered_map
- 元素很少(几十个) →
map 可能比 unordered_map 更快
- 编译期已知大小、要内嵌不开堆 →
array
- 元素很大、移动昂贵、要求地址稳定 →
vector 存指针而不是存对象
该拷贝、移动还是转发
- 形参只是读 →
const T&
- 形参要接管资源(存进成员/容器) → 按值传 +
std::move 进成员
- 泛型转发 →
T&& + std::forward<T>,绝不用 std::move(会误伤左值)
- 自己写移动构造 → 一定标
noexcept,否则 vector 扩容退化为拷贝
- 类管理裸资源 → 三/五法则成套写,别只写一个
AoS 还是 SoA
- 对象数量少、总是整体使用 → AoS(写起来直观)
- 海量同类对象、按字段批量处理(粒子 / ECS) → SoA
- 多线程各改不同字段 → 防伪共享:
alignas(64) 分开,或各自补齐到缓存行
- 问:虚函数怎么实现的? → 追:vtable 在哪、vptr 在哪、多继承几个 vptr、构造里调用会怎样、为什么基类析构必须 virtual?
- 问:shared_ptr 怎么实现的? → 追:控制块存哪、计数线程安全吗、所指对象安全吗、make_shared 为什么快?
- 问:循环引用怎么办? → 追:weak_ptr 原理、lock() 为什么要临时 +1、谁该用 weak?
- 问:unique_ptr 为什么不能拷贝? → 追:能放进 vector 吗、怎么转移所有权、自定义删除器怎么写?
- 问:vector 扩容机制? → 追:增长因子多少、为什么不用线性、摊还复杂度、扩容后哪些失效?
- 问:构造函数和析构函数的顺序? → 追:初始化列表顺序算谁的、虚基类谁负责构造、为什么用初始化列表?
- 问:模板实例化在什么时候? → 追:为什么定义要放头文件、函数模板为什么不能偏特化、SFINAE 与 enable_if 怎么写、C++17 怎么替代?
- 问:std::move 做了什么? → 追:为什么不移动任何东西、move 后对象什么状态、和 forward 什么区别、移动构造为什么要 noexcept?
- 问:死锁怎么产生?怎么避免? → 追:四个必要条件、三种解法各破坏哪个、scoped_lock 内部做了什么?
- 问:什么是虚假唤醒? → 追:wait 为什么必须配 unique_lock、带谓词为什么能免疫、condition_variable_any 差别?
- 问:内存序有哪几种? → 追:release/acquire 配对保证什么、计数器该用哪个、默认是哪个、为什么不都用 seq_cst?
- 问:C++11 怎么写线程安全单例? → 追:DCLP 的坑在哪、Magic Static 为什么安全、单例有什么缺点?
- 问:怎么减少 DrawCall? → 追:为什么贵、实例化和图集各解决什么、移动端多少算多?
- 问:固定步长和可变步长怎么选? → 追:为什么物理必须固定、accumulator 怎么用、联机为什么依赖确定性?
- 问:ECS 为什么性能好? → 追:和 SoA 什么关系、相比继承的代价是什么、哪些场景不适合?