Skip to content

栈、堆、虚拟内存与 Page Fault

“栈和堆”描述对象生命周期与分配方式;“虚拟内存和物理内存”描述操作系统与硬件怎样解释地址。它们不是两组互斥概念。

text
C++ 程序使用虚拟地址

进程虚拟地址空间
    ├── 代码与只读数据
    ├── 全局/静态数据
    ├── Heap
    ├── 内存映射区域
    └── 每个线程的 Stack
            ↓ 页表转换
物理内存

一、线程栈与栈帧

每个线程通常拥有自己的调用栈。函数调用时建立栈帧,用来承载局部状态、保存的寄存器和返回信息。

cpp
void functionA()
{
    int a = 10;
    functionB();
}

void functionB()
{
    int b = 20;
}
text
functionA 调用 functionB:

Stack
┌────────────────┐
│ functionB      │
│ b = 20         │
│ 返回信息        │
├────────────────┤
│ functionA      │
│ a = 10         │
│ 返回信息        │
└────────────────┘

functionB 返回后,其栈帧整体移除。这符合后进先出(LIFO)的嵌套生命周期。

为什么栈分配通常很快

栈通常通过栈指针表示当前边界。为局部状态准备空间,概念上主要是调整栈指针;函数返回时再恢复。

它通常不需要搜索空闲块、为每个对象维护独立分配节点或合并碎片。不过编译器可能把变量放进寄存器,甚至完全优化掉,所以“局部变量一定物理存在于栈上”不是绝对规则。

栈的限制

  • 每个线程的栈容量有限;
  • 超大局部数组可能触发 Stack Overflow;
  • 深递归不断增加栈帧;
  • 栈天然适合嵌套生命周期,不适合函数返回后继续存在的对象。
cpp
void bad()
{
    std::byte hugeBuffer[100 * 1024 * 1024]; // 很可能耗尽线程栈
}

二、堆与动态生命周期

动态分配使对象可以超越当前函数作用域:

cpp
auto player = std::make_unique<Player>();

需要区分智能指针对象和它管理的对象:

text
Stack:unique_ptr 本身
┌──────────────────┐
│ Player*          │
└──────────────────┘


Heap:Player 对象
┌──────────────────┐
│ Player data      │
└──────────────────┘

同样,局部 vector 的元数据通常在栈帧中,元素存储通常来自堆:

text
Stack:vector 的 pointer / size / capacity


Heap:[10][20][30][40]

“容器变量在栈上”不代表它管理的数据也在栈上。

三、堆分配器做了什么

通用堆分配器需要:

  • 找到满足大小和对齐的空闲块;
  • 记录分配大小和释放信息;
  • 管理不同大小类别;
  • 回收并合并空闲块;
  • 处理多线程竞争;
  • 必要时向操作系统申请更多虚拟内存。

每次小型 new 通常不会直接进入内核。运行库分配器往往先从操作系统取得较大区域,再切成小块:

text
操作系统提供较大区域
┌────────────────────────────────────┐
│                                    │
└────────────────────────────────────┘
              ↓ 分配器切分
┌──────┬──────────┬────┬────────────┐
│ 64B  │ 256B     │32B │ 空闲区域    │
└──────┴──────────┴────┴────────────┘

newmalloc

cpp
Player* player = new Player(arguments);

通常包含申请原始内存和执行构造函数。delete 先执行析构,再释放原始内存。

cpp
void* memory = std::malloc(sizeof(Player));

只取得原始字节,不会自动建立 Player 对象生命周期。常规 C++ 业务代码应优先值语义、标准容器、智能指针和 RAII。

四、虚拟地址空间

程序中的指针通常保存虚拟地址。每个进程看到自己的虚拟地址空间:

text
进程 A:虚拟地址 0x1000 → 物理页面 X
进程 B:虚拟地址 0x1000 → 物理页面 Y

相同虚拟地址可以映射到不同物理内存。虚拟内存带来:

  • 进程隔离;
  • 页面级读、写、执行权限;
  • 按需分配物理页面;
  • 文件映射与共享内存;
  • Copy-on-Write;
  • 地址空间布局随机化。

五、Page 与页表

内存映射通常按 Page 管理。常见基础页大小是 4 KB,但平台可以使用其他大小或大页。

text
虚拟 Page 0 → 物理 Page 8
虚拟 Page 1 → 物理 Page 3
虚拟 Page 2 → 尚未映射

页表项通常还记录页面是否存在、是否可读写、是否可执行、是否被访问或修改。

虚拟地址可概念性地拆成:

text
虚拟页号 + 页内偏移

CPU 通过页表找到物理页,再加上页内偏移得到最终物理地址。

六、TLB 与 CPU Cache

逐次遍历页表成本很高,CPU 使用 TLB(Translation Lookaside Buffer)缓存最近的地址转换:

text
虚拟页号

查询 TLB
    ├── 命中:快速得到物理页
    └── 未命中:执行页表遍历

TLB 和 CPU Cache 作用不同:

text
TLB:缓存虚拟页到物理页的转换
CPU Cache:缓存实际数据和指令

访问大量分散页面可能同时增加 TLB Miss 和 Cache Miss。

七、Page Fault 不一定是错误

CPU 遇到当前页表无法直接完成的访问,会触发 Page Fault 并把控制权交给操作系统。

Demand Paging

程序已经保留虚拟地址,但页面尚未配备物理内存:

text
第一次写入页面

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 需要从较慢的存储设备读取页面,例如文件映射内容当前不在物理内存。其延迟对实时系统尤其危险。

移动平台的交换和内存回收策略各不相同;内存压力过大时,应用还可能被系统直接终止。

九、申请内存与首次触碰

保留较大的虚拟地址范围,不代表所有物理页面都已立即建立。逐页第一次写入时,可能才真正触发物理页面提交:

cpp
for (std::size_t i = 0; i < buffer.size(); i += 4096) {
    buffer[i] = std::byte{1};
}

性能分析应区分:

  • 保留虚拟地址;
  • 提交或映射物理页面;
  • 第一次触碰页面;
  • 后续缓存访问。

某些实时系统会在加载阶段预触碰内存,把 Page Fault 从关键帧移动到可控阶段。

十、栈也建立在虚拟内存上

线程栈通常是虚拟地址空间中的一个保留区域。系统可以只在栈实际增长时提交物理页,并在边界放置 Guard Page:

text
正常 Stack 页面
正常 Stack 页面
Guard Page(不可访问)

越界进入 Guard Page 时可以尽早检测 Stack Overflow。

十一、内存碎片

外部碎片

总空闲量足够,但被分散为多个小块:

text
[使用20][空闲10][使用30][空闲15][使用25][空闲20]

总空闲为 45,却没有连续 40 的块。

内部碎片

请求 33 字节,分配器因对齐或大小类别提供 48 或 64 字节;块内未使用部分就是内部碎片。

大量分散的小对象还会增加页表、缓存和分配元数据成本,即使有效数据总量并不大。

十二、专用分配器

Linear/Arena Allocator

在预分配区域中顺序推进,最后整体重置:

text
[已使用数据][下一个位置 →][空闲空间]

适合单帧临时数据、一次加载流程和解析中间结果,不适合任意顺序单独释放。

Pool Allocator

把内存切成固定大小槽位:

text
[Slot][Slot][Slot][Slot]

适合粒子、组件、任务节点等同尺寸且频繁创建销毁的对象。

Frame Allocator

帧内顺序分配,等相关 CPU/GPU 工作完成后整体重置。CPU 与 GPU 跨帧并行时,需要双缓冲、三缓冲或 Fence,不能在 CPU 帧结束时立即复用仍被 GPU 访问的内存。

十三、移动端约束

移动设备需要同时考虑:

  • 进程内存预算与峰值;
  • CPU/GPU 共享内存带宽;
  • Texture、Buffer 与临时解压数据;
  • 后台切换和系统资源回收;
  • 低内存警告与系统终止;
  • 温控降频后的持续性能。

统一内存架构不表示资源没有同步和带宽成本,也不意味着 CPU/GPU 可以无限制并发访问。


本章结论

  1. 栈和堆通常都位于进程的虚拟地址空间。
  2. 栈适合嵌套生命周期,堆支持动态生命周期但管理成本更复杂。
  3. 局部容器可以在栈上,而其元素存储位于堆上。
  4. CPU 通过页表把虚拟地址转换为物理地址,TLB 缓存转换结果。
  5. Page Fault 可以是按需分配、文件映射、Copy-on-Write,也可以是非法访问。
  6. 外部碎片是空闲块分散,内部碎片是分配块大于实际需求。
  7. 专用分配器的价值是让生命周期、延迟和预算更可预测。
  8. 实时系统不仅关心占用量,还关心分配、首次触碰和释放发生在什么时刻。

← 上一章:map、unordered_map 与 rehash · 下一章:内存安全、OOM 与诊断 →