栈、堆、虚拟内存与 Page Fault
“栈和堆”描述对象生命周期与分配方式;“虚拟内存和物理内存”描述操作系统与硬件怎样解释地址。它们不是两组互斥概念。
C++ 程序使用虚拟地址
↓
进程虚拟地址空间
├── 代码与只读数据
├── 全局/静态数据
├── Heap
├── 内存映射区域
└── 每个线程的 Stack
↓ 页表转换
物理内存一、线程栈与栈帧
每个线程通常拥有自己的调用栈。函数调用时建立栈帧,用来承载局部状态、保存的寄存器和返回信息。
void functionA()
{
int a = 10;
functionB();
}
void functionB()
{
int b = 20;
}functionA 调用 functionB:
Stack
┌────────────────┐
│ functionB │
│ b = 20 │
│ 返回信息 │
├────────────────┤
│ functionA │
│ a = 10 │
│ 返回信息 │
└────────────────┘functionB 返回后,其栈帧整体移除。这符合后进先出(LIFO)的嵌套生命周期。
为什么栈分配通常很快
栈通常通过栈指针表示当前边界。为局部状态准备空间,概念上主要是调整栈指针;函数返回时再恢复。
它通常不需要搜索空闲块、为每个对象维护独立分配节点或合并碎片。不过编译器可能把变量放进寄存器,甚至完全优化掉,所以“局部变量一定物理存在于栈上”不是绝对规则。
栈的限制
- 每个线程的栈容量有限;
- 超大局部数组可能触发 Stack Overflow;
- 深递归不断增加栈帧;
- 栈天然适合嵌套生命周期,不适合函数返回后继续存在的对象。
void bad()
{
std::byte hugeBuffer[100 * 1024 * 1024]; // 很可能耗尽线程栈
}二、堆与动态生命周期
动态分配使对象可以超越当前函数作用域:
auto player = std::make_unique<Player>();需要区分智能指针对象和它管理的对象:
Stack:unique_ptr 本身
┌──────────────────┐
│ Player* │
└──────────────────┘
│
▼
Heap:Player 对象
┌──────────────────┐
│ Player data │
└──────────────────┘同样,局部 vector 的元数据通常在栈帧中,元素存储通常来自堆:
Stack:vector 的 pointer / size / capacity
│
▼
Heap:[10][20][30][40]“容器变量在栈上”不代表它管理的数据也在栈上。
三、堆分配器做了什么
通用堆分配器需要:
- 找到满足大小和对齐的空闲块;
- 记录分配大小和释放信息;
- 管理不同大小类别;
- 回收并合并空闲块;
- 处理多线程竞争;
- 必要时向操作系统申请更多虚拟内存。
每次小型 new 通常不会直接进入内核。运行库分配器往往先从操作系统取得较大区域,再切成小块:
操作系统提供较大区域
┌────────────────────────────────────┐
│ │
└────────────────────────────────────┘
↓ 分配器切分
┌──────┬──────────┬────┬────────────┐
│ 64B │ 256B │32B │ 空闲区域 │
└──────┴──────────┴────┴────────────┘new 与 malloc
Player* player = new Player(arguments);通常包含申请原始内存和执行构造函数。delete 先执行析构,再释放原始内存。
void* memory = std::malloc(sizeof(Player));只取得原始字节,不会自动建立 Player 对象生命周期。常规 C++ 业务代码应优先值语义、标准容器、智能指针和 RAII。
四、虚拟地址空间
程序中的指针通常保存虚拟地址。每个进程看到自己的虚拟地址空间:
进程 A:虚拟地址 0x1000 → 物理页面 X
进程 B:虚拟地址 0x1000 → 物理页面 Y相同虚拟地址可以映射到不同物理内存。虚拟内存带来:
- 进程隔离;
- 页面级读、写、执行权限;
- 按需分配物理页面;
- 文件映射与共享内存;
- Copy-on-Write;
- 地址空间布局随机化。
五、Page 与页表
内存映射通常按 Page 管理。常见基础页大小是 4 KB,但平台可以使用其他大小或大页。
虚拟 Page 0 → 物理 Page 8
虚拟 Page 1 → 物理 Page 3
虚拟 Page 2 → 尚未映射页表项通常还记录页面是否存在、是否可读写、是否可执行、是否被访问或修改。
虚拟地址可概念性地拆成:
虚拟页号 + 页内偏移CPU 通过页表找到物理页,再加上页内偏移得到最终物理地址。
六、TLB 与 CPU Cache
逐次遍历页表成本很高,CPU 使用 TLB(Translation Lookaside Buffer)缓存最近的地址转换:
虚拟页号
↓
查询 TLB
├── 命中:快速得到物理页
└── 未命中:执行页表遍历TLB 和 CPU Cache 作用不同:
TLB:缓存虚拟页到物理页的转换
CPU Cache:缓存实际数据和指令访问大量分散页面可能同时增加 TLB Miss 和 Cache Miss。
七、Page Fault 不一定是错误
CPU 遇到当前页表无法直接完成的访问,会触发 Page Fault 并把控制权交给操作系统。
Demand Paging
程序已经保留虚拟地址,但页面尚未配备物理内存:
第一次写入页面
↓
Page Fault
↓
操作系统分配物理页并建立映射
↓
重新执行原指令文件映射
内存映射文件的页面首次访问时,系统可能读取对应文件内容并建立映射。
Copy-on-Write
多个执行环境先共享只读页面;某一方写入时触发 Page Fault,系统复制页面,再让写入发生在私有副本上。
非法访问
如果地址没有合法映射或权限不允许,系统无法修复,就会报告 Segmentation Fault 或 Access Violation。
Page Fault 是硬件请求操作系统处理页面状态的机制;只有无法合法处理时,才表现为程序访问错误。
八、Minor 与 Major Page Fault
Minor Page Fault 通常不需要从磁盘读取,例如按需分配零页、建立已有页面映射或 Copy-on-Write。它仍需要进入内核处理。
Major Page Fault 需要从较慢的存储设备读取页面,例如文件映射内容当前不在物理内存。其延迟对实时系统尤其危险。
移动平台的交换和内存回收策略各不相同;内存压力过大时,应用还可能被系统直接终止。
九、申请内存与首次触碰
保留较大的虚拟地址范围,不代表所有物理页面都已立即建立。逐页第一次写入时,可能才真正触发物理页面提交:
for (std::size_t i = 0; i < buffer.size(); i += 4096) {
buffer[i] = std::byte{1};
}性能分析应区分:
- 保留虚拟地址;
- 提交或映射物理页面;
- 第一次触碰页面;
- 后续缓存访问。
某些实时系统会在加载阶段预触碰内存,把 Page Fault 从关键帧移动到可控阶段。
十、栈也建立在虚拟内存上
线程栈通常是虚拟地址空间中的一个保留区域。系统可以只在栈实际增长时提交物理页,并在边界放置 Guard Page:
正常 Stack 页面
正常 Stack 页面
Guard Page(不可访问)越界进入 Guard Page 时可以尽早检测 Stack Overflow。
十一、内存碎片
外部碎片
总空闲量足够,但被分散为多个小块:
[使用20][空闲10][使用30][空闲15][使用25][空闲20]总空闲为 45,却没有连续 40 的块。
内部碎片
请求 33 字节,分配器因对齐或大小类别提供 48 或 64 字节;块内未使用部分就是内部碎片。
大量分散的小对象还会增加页表、缓存和分配元数据成本,即使有效数据总量并不大。
十二、专用分配器
Linear/Arena Allocator
在预分配区域中顺序推进,最后整体重置:
[已使用数据][下一个位置 →][空闲空间]适合单帧临时数据、一次加载流程和解析中间结果,不适合任意顺序单独释放。
Pool Allocator
把内存切成固定大小槽位:
[Slot][Slot][Slot][Slot]适合粒子、组件、任务节点等同尺寸且频繁创建销毁的对象。
Frame Allocator
帧内顺序分配,等相关 CPU/GPU 工作完成后整体重置。CPU 与 GPU 跨帧并行时,需要双缓冲、三缓冲或 Fence,不能在 CPU 帧结束时立即复用仍被 GPU 访问的内存。
十三、移动端约束
移动设备需要同时考虑:
- 进程内存预算与峰值;
- CPU/GPU 共享内存带宽;
- Texture、Buffer 与临时解压数据;
- 后台切换和系统资源回收;
- 低内存警告与系统终止;
- 温控降频后的持续性能。
统一内存架构不表示资源没有同步和带宽成本,也不意味着 CPU/GPU 可以无限制并发访问。
本章结论
- 栈和堆通常都位于进程的虚拟地址空间。
- 栈适合嵌套生命周期,堆支持动态生命周期但管理成本更复杂。
- 局部容器可以在栈上,而其元素存储位于堆上。
- CPU 通过页表把虚拟地址转换为物理地址,TLB 缓存转换结果。
- Page Fault 可以是按需分配、文件映射、Copy-on-Write,也可以是非法访问。
- 外部碎片是空闲块分散,内部碎片是分配块大于实际需求。
- 专用分配器的价值是让生命周期、延迟和预算更可预测。
- 实时系统不仅关心占用量,还关心分配、首次触碰和释放发生在什么时刻。
