# 第 8 章 内存管理


> 本章解释"每个进程都以为自己独占内存"这一幻觉如何被页表、TLB 与缺页机制共同维持，是理解 COW、尾延迟毛刺与 RSS 不下降的地基。

## 学习目标

读完本章，你应当能够：
1. 从虚拟内存要解决的三个问题出发，推导"直接给物理地址"在多进程系统上为什么必然失效；
2. 用具体数字算出单级页表在 48 位地址下的规模，写出四级页表的地址位段划分；
3. 区分四类缺页的触发条件与处理路径，说明为什么段错误也是一种缺页；
4. 手算 OPT/FIFO/LRU 的缺页次数，构造 FIFO 的 Belady 异常反例并解释 LRU 为什么不会异常；
5. 辨析 `MAP_SHARED` 与 `MAP_PRIVATE` 的写时复制语义差异，说明共享内存为什么是"故意不 COW"；
6. 解释 `malloc` 在 128 KB 处的行为分界，以及 `free` 之后 RSS 为什么不降、怎么才能让它降。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 进程、`fork` 与写时复制的基本流程 | 见第 4 章 4.1 节；本章 8.4 只讲 COW 的**缺页触发路径**，不重复进程语义 |
| 用户态/内核态切换与中断开销 | 见第 9 章 9.2 节；本章只引用"一次陷入约 100 ns ~ 1 μs"这一量级 |
| 地址翻译与缓存的配合 | 见第 1 章 1.1 节、第 2 章 2.6 节；本章只讲页表与 TLB 本身 |
| `/proc/meminfo` 字段与 OOM killer | 见第 12 章 12.1、12.4 节；本章 8.11 只讲**回收触发路径** |
| page cache 与 `mmap` 作为零拷贝手段 | 见第 7 章 7.5 节；本章 8.7 只讲映射语义，不重复 I/O 栈 |

## 8.1 虚拟内存要解决的三个问题

本科给出的答案是"虚拟内存让进程以为自己独占内存"。它把三个**互相独立**的需求混成一句话，而三者的实现手段完全不同——被追问"到底解决什么问题"时必须拆开答。

**表 8-1 三个问题与各自的手段**
| 要解决的问题 | 不做会怎样 | 主要手段 | 代价 |
|---|---|---|---|
| 隔离与保护 | 进程 A 能读写进程 B 的数据；野指针能改写内核 | 每进程独立页表；页表项里的 U/S、R/W、NX 位由 MMU 每次访存检查 | 每次访存多一次翻译；页表占物理内存 |
| 容量幻觉 | 程序必须整体装入内存；多个大程序无法共存 | 按需分页（demand paging）+ 页替换 + 后备存储 | 次缺页约 1 μs，主缺页 100 μs 以上 |
| 地址无关 | 共享库必须在每进程加载到同一地址；每处绝对地址都要重定位 | 位置无关代码（position-independent code, PIC）+ 页级共享 + 延迟绑定（PLT/GOT） | GOT 间接跳转、启动期重定位开销 |

第一条的关键：**保护检查发生在 MMU 里、每次访存都做**，不是操作系统周期性巡检，因此几乎免费却比任何软件检查都可靠。第二条的关键：**"已分配"与"已驻留"被解耦**，`malloc` 成功不等于物理页已分配（第 12 章 12.3 节的 overcommit 会再讲一次）。第三条的关键：**同一份物理页可映射进多个进程的不同虚拟地址**，这是共享库只占一份物理内存、`fork` 不必复制整份地址空间的前提。
"直接给物理地址"为什么在现代系统上行不通？① **没有隔离**——地址空间划分需静态协商，任何进程一条 `mov` 就能改写别人的数据；② **没有重定位**——可执行文件在链接时就得知道自己运行在哪，而可用物理区间取决于运行时其他进程的占用；③ **装载粒度是整块**——"装入才能运行"意味着可用物理内存必须容纳全部程序；④ **共享无法表达**——两个进程各一份 `libc` 副本，内存与缓存双双浪费；⑤ **碎片不可控**——变长连续分配必然产生外部碎片。任何一条都足以否决直接寻址，而现代系统上它们同时成立。
> [!IMPORTANT]
> 面试官问"虚拟内存解决了什么问题"，三段式回答是：**保护（MMU 每次访存检查页表权限位）、容量欺骗（按需分页把"分配"与"驻留"解耦）、地址解耦（虚拟连续不等于物理连续，同一物理页可多进程共享、位置随装载浮动）**。只答第一段会被认为只记住了教材结论。
> [!NOTE]
> 面试官会追问的下一层：**页表本身在哪里？** 它是内核数据结构，用户态不可写，物理地址由 `CR3` 指向。切换进程的本质动作之一就是换 `CR3`——这也是进程切换比同进程线程切换贵的原因（后者不换页表、不冲刷 TLB），见第 5 章 5.6 节。

## 8.2 分页、分段与段页式

只答"一个固定大小一个可变大小、一个内部碎片一个外部碎片"是不及格的，因为没说清**两者服务的目标不同**：分页服务**物理内存管理的粒度问题**，分段服务**程序逻辑结构的保护与共享问题**。

**表 8-2 三种地址翻译方案的对比**
| 维度 | 纯分段 | 纯分页 | 段页式 |
|---|---|---|---|
| 划分依据 | 程序的逻辑单位（代码段、数据段、栈） | 固定大小的物理页 | 先按逻辑分段，段内再分页 |
| 地址形式 | 段号 + 段内偏移 | 虚拟页号 + 页内偏移 | 段号 + 段内页号 + 页内偏移 |
| 碎片类型 | 外部碎片 | 内部碎片（平均浪费约半页 2 KB） | 段内无外部碎片，仍有页内碎片 |
| 保护与共享粒度 | 段级，天然贴合代码/数据权限 | 页级，需靠 VMA 恢复语义 | 兼有 |
| 翻译次数 | 1 次查段表 | 1 次或多级查页表 | 2 次 |
| 现状 | x86-64 保留段寄存器但基址为 0（平坦模型），仅 `fs`/`gs` 用于 TLS | 主流方案 | x86-64 实际是"平坦分段 + 多级分页" |

分页之所以胜出，是因为**物理内存管理需要等长块**：只有等长块才能做位图/链表管理、与磁盘块一一对应以简化换页、用伙伴系统快速合并。分段想提供的"逻辑保护"被两项技术替代：**VMA（`vm_area_struct`）在内核侧记录每段内存的权限与来源**，**ELF 段表在装载时告诉内核该给哪些页什么权限**。分段的目标被上移进内核数据结构，分页专心解决物理内存管理。

### 8.2.1 4 KB 页的取舍与巨大页

4 KB 是三方权衡的结果：**页内碎片**——页越大平均浪费越大，4 KB 下平均浪费约 2 KB，2 MB 页只用一个字节时浪费接近 2 MB；**页表开销**——页越小页表项越多、每进程页表越大；**I/O 粒度**——换页与预读以页为单位，页越大预读越有效。而页越大真正的杀手级收益在 **TLB 覆盖范围**：TLB 条目数由硬件固定，于是
```text
TLB 覆盖范围 = 条目数 × 页大小
  4 KB 页：L1 dTLB 64 条 ⟹ 256 KB；L2 TLB 1536 条 ⟹ 6 MB
  2 MB 页：L2 TLB 1536 条 ⟹ 1536 × 2 MB = 3 GB
  ⟹ 4 KB 页下工作集超过 6 MB 就持续 TLB 缺失，只能走多级页表遍历（x86 由
     硬件 PMH 完成，额外数次访存）；2 MB 页下 3 GB 以内 TLB 常驻。
  页表项数同比例缩小：1 GB = 262144 个 4 KB 页表项 = 512 张 PTE 页
                        1 GB = 512 个 2 MB 页表项 = 一张 4 KB 的 PD 页
```
代价同样明确：**内部碎片**（小对象被塞进大页）、**换出代价**（换出一个 2 MB 页要写 2 MB）、**回收粒度粗**（大页必须整体回收，易制造不可回收碎片，所以内核有 `khugepaged` 与 THP 的 `madvise`/`always`/`never` 三档策略）。正确结论是：**巨大页（huge page）适合大块、长期驻留、随机访问的数据结构（数据库 buffer pool、JVM 堆、报文池），不适合小对象密集或频繁申请释放的场景**。Linux 上显式巨大页走 `hugetlbfs` 或 `MAP_HUGETLB`，透明巨大页（transparent huge page, THP）由内核自动合并。
> [!TIP]
> 巨大页的收益要按**两条路子**说——**少做缺页**（页表项数下降）与**少做翻译**（TLB 覆盖率上升）。第二条才是数量级收益，也是 JVM `-XX:+UseLargePages` 与数据库显式配大页的动机。只答"减少缺页"很常见，但不够。

## 8.3 多级页表与 TLB

### 8.3.1 单级页表在 64 位下为什么不可行

设虚拟地址位宽 `n`、页内偏移 `o` 位、页表项 `e` 字节，单级页表需要 `2^(n−o)` 个表项、总大小 `2^(n−o) × e`。取当下 x86-64 的有效位宽 `n = 48`（规范允许扩展到 57 位，当前实现是 48 位，高 16 位必须与第 47 位相同，即符号扩展）、页大小 4 KB（`o = 12`）、页表项 8 字节：
```text
条目数 = 2^(48−12) = 2^36 = 68719476736 条（约 687 亿条）
大小 = 2^36 × 8 B = 2^39 B = 512 GB ⟹ 一个进程的页表比整机物理内存还大，
       而且每个进程都要一份，所以单级页表在 64 位下根本不可行。
```
更致命的是**稀疏性**：真实进程只用到地址空间中极小一部分，单级页表却强制每个虚拟页都有一个固定位置的表项。栈区（`0x7fff...`）与代码区（`0x0000...`）之间隔着 128 TB，中间全是要填的无效项。**多级页表的收益不是减少表项总数，而是让"未使用的巨大区间"不占用任何底层页表页**——中间层一个空表项就代表一整片空洞。

### 8.3.2 四级页表的地址位段划分

x86-64 把 48 位虚拟地址切成 5 段，除末段偏移固定 12 位外每段恰好 9 位（`2⁹ = 512` 项，`512 × 8 B = 4096 B`，**一张表恰好占一页，这个"巧合"是设计目标**）：
```text
  47..39    38..30    29..21    20..12    11..0
 ┌────────┬─────────┬────────┬────────┬──────────┐
 │  PML4  │  PDPT   │   PD   │   PT   │  页内偏移 │
 │  9 位  │  9 位   │  9 位  │  9 位  │   12 位   │
 └────────┴─────────┴────────┴────────┴──────────┘
      ▼         ▼         ▼        ▼
   PML4E ─▶ PDPTE ─▶ PDE ─▶ PTE ─▶ 物理页帧号 + 标志位；每级 512 项
   每张表的物理地址写在上一级表项里；CR3 ─▶ PML4（换进程即换 CR3）
   覆盖自下而上：PT 2 MB ▸ PD 1 GB ▸ PDPT 512 GB ▸ PML4 256 TB = 2^48
   标志位：P 存在位（0 即 #PF）▸ R/W 可写（COW 靠它）▸ U/S 用户位 ▸ NX 不可执行
```

**表 8-3 稀疏映射省内存的算例（映射 1 GB 匿名内存）**
| 情形 | PTE 页数 | 底层页表总量 |
|---|---|---|
| 全部 1 GB 都触碰过（512 个 2 MB 区间） | 512 张 × 4 KB | 约 2052 KB（含 1 张 PD 页） |
| 只触碰前 8 KB（2 个页，落在同一 2 MB 区间） | 1 张 × 4 KB | 8 KB（含 1 张 PD 页） |

开销与"**实际用到的区间数**"绑定，而不是与"虚拟区间的跨度"绑定——两者差 256 倍，这就是惰性建立页表的价值。
> [!WARNING]
> 常见错误回答："多级页表是为了省内存，因为一级页表太大。"只对了一半——**它省的不是表项总数，而是空洞的开销**。面试官紧接着会问"如果进程用满整个地址空间，多级页表还省吗"，正确答案是**不省，甚至更贵**（多了中间层表页）。多级页表的收益前提是**地址空间稀疏**，而这对所有真实进程都成立。

### 8.3.3 TLB：命中就绕过页表

TLB（translation lookaside buffer）是 MMU 内的**页表项缓存**，缓存"虚拟页号 → 物理页帧号 + 权限位"。三条必须说清的性质：
**第一，命中则完全不访问页表。** 命中路径只是把虚拟页号与各条目并行比较（全相联或高相联），取命中项的页帧号拼上偏移——4 KB 页下 **1 GB 区间的 262144 个页表项在 TLB 命中时一个都不会被读**。页表遍历只在 TLB 缺失时发生，x86 由硬件页表遍历器（page miss handler, PMH）完成，最多 4 次额外访存（且每次都可能缓存命中，所以实际远低于 `4 × 100 ns`）。
**第二，它是"翻译缓存"而非"数据缓存"。** 一次访存可能 TLB 命中而数据缓存缺失（映射已知、数据不在），也可能 TLB 缺失而数据在缓存里（页表项不在 TLB、数据行在 L1）。把两者混为一谈是最常见的失分点。
**第三，维护 TLB 一致性是内核的责任。** 页表被改（换页、`munmap`、权限收紧、COW 建立新映射）后旧映射必须作废：x86 用 `invlpg` 失效单条、重载 `CR3` 失效全部（很贵）；Linux 用**地址空间标识符**（x86 上是 PCID）给 TLB 条目打进程标签，从而进程切换时不必冲刷全部 TLB。
> [!NOTE]
> 面试官会追问的下一层：**为什么 `fork` 之后 TLB 里的旧映射必须失效？** `fork` 会复制页表并把父子两边的写权限都清掉，这是一次页表修改。若忘记失效，父进程的写就会绕过缺页路径直接改写共享物理页，COW 立刻失效——这正是"COW 依赖页表只读位 + TLB 及时失效"两件事同时成立的原因。

## 8.4 缺页的四种类型

先建立统一骨架：任何一次访存，MMU 先查 TLB；TLB 缺失则查页表；**只要页表项不满足本次访问的要求，就触发缺页异常（`#PF`）**，硬件把出错地址压入 `CR2`、原因编码压入错误码。所以"缺页"严格来说是**"页表项无法满足本次访问"**，而不只是"页不在内存里"。
```text
#PF 错误码（x86，与出错的那次翻译同时产生）
  bit0 P 0=页不在内存(PTE 不存在或 P=0)  1=页在内存但权限不允许(保护违例)
  bit1 W/R 0=读触发 1=写触发    bit2 U/S 0=内核态 1=用户态    bit4 I/D 取指
  bit3 RSVD 保留位被置位（PTE 本身非法）   内核查 VMA：不在任何 VMA 内 ⟹ SIGSEGV
  在 VMA 内 ⟹ 按 VMA 标志与 PTE 现状决定"补页表项"还是"读数据"
```

**表 8-4 四类缺页的触发场景与处理路径**
| 类型 | 判定依据 | 典型触发场景 | 处理路径 | 量级 |
|---|---|---|---|---|
| 次要缺页（minor） | 页已在内存（page cache 或共享页），只是本进程 PTE 未建立或 `P=0` | `malloc` 后首次触碰；`mmap` 一个已被别的进程读入缓存的文件；换出又换回且仍在缓存 | 复用缓存页或分配新页 → 填 PTE → 置 P/A 位 → 返回重试 | 约 1 μs |
| 主要缺页（major） | 页不在内存，需要读写磁盘 | 首次读未被缓存的文件页；swap 换入；`mmap` 冷文件 | 进入 `vm_ops->fault`（文件映射走 `filemap_fault`）→ 发起块层 I/O → 睡眠等待 → 填 PTE | SSD 约 100 μs 起，机械盘 5 ~ 10 ms |
| 写时复制缺页（COW） | PTE 存在且 `P=1`，但 `R/W=0`，且 VMA 可写；错误码 `P=1, W/R=1` | `fork` 之后父子任一方首次写共享页 | 分配新页 → 复制原页内容 → 改 PTE 指向新页并恢复可写 → 减引用计数 | 约 1 μs（纯内存拷贝） |
| 无效地址缺页（段错误） | 地址不落在任何 VMA 内，或访问方式与 VMA 权限冲突 | 空指针解引用、数组越界、写字符串字面量、跳转到数据区 | 修不了 → 发送 `SIGSEGV`（越出映射区也可能是 `SIGBUS`）→ 进程终止并 dump core | 无 I/O，代价在信号与 core dump |

第三类看起来是"权限问题"而非"缺页"，但**硬件层面它确实是 `#PF`（错误码 `P=1, W/R=1`）**，走同一条入口。把 COW 说成"额外的一次内存拷贝"而不提"它是一次缺页"，就答不出 COW 的性能特征——**拷贝发生在写的那一刻、由缺页路径完成，因此对小对象便宜、对大对象贵**。
第四类的关键认识：**段错误不是独立的异常机制，而是缺页处理程序判定"无法修复"后的结果**。"为什么空指针访问会崩"的完整答案是：访问地址 0 → TLB 缺失 → 查页表无有效映射 → `#PF`（`P=0, U/S=1`）→ 内核查 VMA 发现该地址不属于任何 VMA → 发 `SIGSEGV`。**内核并没有"检查指针是否为空"这一步。**
```text
用户态访存 ─▶ MMU 查 TLB ─命中─▶ 访问完成（完全不碰页表）
                  │缺失
                  ▼
          硬件 PMH 查四级页表 ─有效且权限满足─▶ 填 TLB，访问完成
                  │否则
                  ▼   #PF：CR2 = 出错地址，错误码入栈，陷入内核
           do_page_fault(addr, error_code) ─▶ find_vma(mm, addr)
                  ├─ 不在任何 VMA 内 或 与 VMA 权限冲突 ⟹ SIGSEGV（段错误/保护违例）
                  └─ 在 VMA 内且权限允许
                            │
                            ▼
                   PTE 是否已存在（P=1）？
              ┌─────────────┴──────────────────────────┐
              │是：只读 + 写 + VMA 可写                  │否（P=0）
              ▼                                        ▼
   COW 缺页：分配新页、复制内容、        页是否在 page cache / 换回队列？
   改 PTE 可写、减引用计数              ┌──────────┴──────────┐
   ── 无需磁盘 I/O，约 1 μs             │在：次要缺页 约 1 μs │不在：主要缺页
                                        │复用该物理页         │发起块层 I/O、等 I/O
              └─────────────┬───────────┴────────────────────┘
                            ▼
             填 PTE、置 P/A 位、失效旧 TLB 条目 ─▶ 返回用户态重试出错指令
      （x86 的 #PF 保存出错指令的地址，因此重试天然正确）
```
> [!IMPORTANT]
> 面试必答的一句话：**缺页处理程序不"搬数据"，它做的是"把页表项修正成能支持本次访问的样子"**。数据搬运只发生在主要缺页与 COW 缺页里，次要缺页甚至不分配新物理页（复用 page cache 的页）。抓住这一点，四类缺页不必背，可以现场推。

## 8.5 页替换算法

物理页不够时必须在"换出哪一页"上做选择，评价尺子有两把：**缺页率**与**可实现性**（硬件要付出多少状态与操作）。

**表 8-5 五种替换算法的机制、可实现性与缺陷**
| 算法 | 淘汰依据 | 需要的状态 | 优点 | 致命问题 |
|---|---|---|---|---|
| OPT（Belady 最优） | 淘汰未来最晚才被访问的页 | 预知未来引用串 | 给定引用串下缺页最少，是理论下界 | 不可实现，仅作评价基准 |
| FIFO | 淘汰最早进入的页 | 一个队列 | 实现最简单 | 出现 Belady 异常：物理页增多，缺页反而变多 |
| LRU | 淘汰最久未被访问的页 | 精确的访问时间序 | 无 Belady 异常，接近 OPT | 精确实现要每次访存都更新序信息，硬件做不到 |
| Clock（NRU 近似） | 时钟指针扫过，跳过使用位为 1 的页并清零，遇 0 则淘汰 | 每页 1 个使用位 + 1 个指针 | 以极低代价近似 LRU | 只是近似，不保证栈性质 |
| LFU + 老化 | 淘汰访问计数最小者，计数随时间右移衰减 | 每页一个计数器 | 能保留长期热点页 | 计数只增不减会把"曾热已冷"的页钉住，必须配老化 |

### 8.5.1 手算三种算法与 Belady 异常

取引用串 `1 2 3 4 1 2 5 1 2 3 4 5`，帧数 3，逐个模拟（每行标出被淘汰的页）：
```text
引用串 1 2 3 4 1 2 5 1 2 3 4 5        帧数 = 3
步骤  驻留集 [命中/缺页]              FIFO            LRU             OPT
 1,2,3  缺缺缺（填满 3 帧）
 4  [4 2 3]                            缺（淘汰 1）    缺（淘汰 1）    缺（淘汰 3）
 1                                     缺（淘汰 2）    缺（淘汰 2）    缺（淘汰 4）
 2                                     缺（淘汰 3）    缺（淘汰 3）    缺（淘汰 1）
 5                                     缺（淘汰 4）    缺（淘汰 1）    缺（淘汰 2）
 1                                     命中            缺（淘汰 2）    命中
 2                                     命中            缺（淘汰 4）    命中
 3                                     缺（淘汰 1）    缺（淘汰 1）    缺
 4                                     缺（淘汰 2）    缺（淘汰 5）    缺
 5                                     命中            缺（淘汰 4）    缺
 合计                                  9 次            10 次           9 次
帧数改为 4：FIFO 变 10 次（9 → 10，缺页反而变多 = Belady 异常）
            LRU 变 8 次、OPT 变 7 次（单调不增）
```
为什么只有 FIFO 会异常？因为 LRU 与 OPT 满足**栈性质（stack property）**：帧数为 `m` 时的驻留集恒为帧数为 `m+1` 时驻留集的子集，因此多加一个帧绝不会把原本的命中变成缺失。FIFO 的淘汰顺序只由"谁先进来"决定、与访问模式无关，加一个帧会改变整个淘汰序列，可能恰好踢掉马上要用的页。

### 8.5.2 LRU 的硬件近似：Clock 算法

精确 LRU 在硬件上做不到，原因很实在：**每次访存都要更新"最后访问时间"**，意味着每次访存都写一次内存结构，带来锁、竞争与额外带宽，还必须在很短时间内完成——与"翻译本就是为了加速访存"直接冲突；软件链表实现同样不可接受。因此真实系统用**硬件提供的 1 个使用位（reference bit）+ 软件维护的时钟指针**来近似，即 **Clock 算法（二次机会算法，second-chance）**：
```text
     ┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐
     │ 页 A │──▶│ 页 B │──▶│ 页 C │──▶│ 页 D │
     │ U=1  │   │ U=0  │   │ U=1  │   │ U=0  │
     └──────┘   └──────┘   └──────┘   └──────┘
         ▲                                   │
         └───────────────────────────────────┘  p（时钟指针）
  淘汰请求到来时看 p 所指页的使用位 U：U=1 ⟹ 给"二次机会"（置 U=0，p 前移
  继续考察）；U=0 ⟹ 淘汰该页，p 前移。硬件在每次访问该页时自动置 U=1，
  内核周期性清零 U 以限定"最近"的窗口。
  ★ 代价是每页 1 bit + 一个指针，而不是每次访存都更新时间戳。
  改进版 NRU 用"访问位 + 脏位"组成 4 个优先级类，优先淘汰干净页以省一次写回。
```
**为什么真实系统用近似而非精确 LRU**：① **精确实现的成本无法摊销**——每次访存都要更新序信息，代价与省下的缺页同量级，而省下的一次可能只是次缺页（约 1 μs）；② **硬件只免费提供 1 个使用位**——MMU 里为每页存时间戳不可行；③ **精确 LRU 并不总是更好**——顺序扫描"一次扫过不再使用"，精确 LRU 会把整轮扫描的页都保留最久、反而污染工作集，这也是文件页与匿名页分开管理、双链 LRU（active/inactive 两列）存在的原因。
LFU 与老化（aging）要放一起理解：LFU 用访问计数近似"频率"，问题是**计数只增不减**，启动期被反复访问的页会永远占高位。解法是**老化**：周期性把每个计数器右移一位（频率随时间指数衰减），新访问在最高位置 1，于是"很久没被访问的页"计数衰减到低位、终可淘汰。这与第 5 章 5.2 节 CFS 让"睡眠过久任务重新获得竞争力"是同一类设计思想：**用平滑衰减代替无限累积的历史**。
> [!CAUTION]
> "Linux 用精确 LRU 链表"是把面试答崩的低级错误之一。实际用的是**双链 LRU（active/inactive LRU lists）+ 使用位扫描**：新页先进 inactive 链表尾部，被二次访问才提升到 active 链表，回收时从 inactive 头部取。它既不是 FIFO 也不是精确 LRU，而是**带二次机会语义的近似**。答"Linux 用 LRU"尚可，答"维护精确 LRU 链表"会被判为没读过内核。

## 8.6 页表与进程隔离的工程后果
**第一条：缺页是"惰性"的代价，而惰性有收益。** `malloc(1 GB)` 之后 RSS 可能只有几 KB——虚拟区间建立了、PTE 一个都没建。这既是内存超卖能成立的原因，也是"程序跑一会儿才变快"这类现象的根源：**预热**的本质就是把主要缺页与 COW 缺页提前到启动阶段付清（见第 3 章 3.4 节基准测试的预热陷阱）。
**第二条：页表本身也有可观的内存开销，且可观测。** `/proc/<pid>/status` 的 `VmPTE` 给出页表占用，`VmPMD` 给出页中间目录开销。一个映射了 100 GB 匿名内存并全部触碰过的进程，`VmPTE` 约为 `100 GB / 4 KB × 8 B ≈ 200 MB`。这是"大内存进程"成本的一部分，容易被忽略。
**第三条：`fork` 的成本不只在页表拷贝。** `fork` 要复制页表（逐级复制到有映射的那部分）、遍历 VMA、调整引用计数、把所有可写页设为只读、失效相关 TLB，因此开销与**地址空间大小**相关；而 `exec` 之后这些 COW 收益全部作废。这解释了"`fork` + `exec`"的大内存进程启动慢，以及 `posix_spawn`/`vfork` 在特定场景更优（见第 4 章 4.1 节）。
> [!TIP]
> 被问"怎么减少缺页开销"，按收益排序答：**① 用巨大页同时减少缺页次数与 TLB 缺失**（数量级收益）；**② 提前预热**（`MAP_POPULATE`、`mlock`、启动阶段写一遍）把缺页移到非关键路径；**③ 用 `madvise(MADV_HUGEPAGE)`/`MADV_WILLNEED` 提示内核做预读与大页**；**④ 复用内存池，避免反复 `mmap`/`munmap` 触发新缺页**。只答"加内存"是最弱的回答。

## 8.7 `mmap` 的用途谱系

`mmap` 的本质是**在调用进程的虚拟地址空间里，把一段虚拟区间（VMA）与一个"后备来源"绑定**；后备来源要么是文件，要么是匿名（无后备）。两条轴组合出四种基本用法。

**表 8-6 `mmap` 的两条正交轴与四种组合**
| 映射类型 | 是否共享 | 写入的后果 | 典型用途 | 关键注意点 |
|---|---|---|---|---|
| 文件 + `MAP_SHARED` | 共享 | 直接修改 page cache，最终落盘，对其他映射者立即可见 | 进程间共享数据、数据库数据文件、日志文件 | 不是原子写，需自行同步；用 `msync` 控制落盘时机 |
| 文件 + `MAP_PRIVATE` | 私有 | 首次写触发 COW，只在本进程可见，**不落盘** | 加载可执行文件与共享库的代码段/数据段、只读配置 | 有后备文件，换出时脏页可安全丢弃（重读文件即可） |
| 匿名 + `MAP_PRIVATE` | 私有 | 各进程独立，写即分配物理页（按需分页） | `malloc` 的大块分配、线程栈、临时缓冲 | 无后备 ⇒ 换出必须依赖 swap |
| 匿名 + `MAP_SHARED` | 共享 | 所有映射者看到同一份物理页 | 父子或无关进程的共享内存、`shm_open` + `mmap` 的实现基础 | 就是"故意不 COW"的共享内存，见 8.8 节 |

一句话总结语义差异：**`MAP_PRIVATE` 是"写入时复制"，`MAP_SHARED` 是"写入即共享（写同一份物理页）"**。注意 `MAP_PRIVATE` 的 COW 与 `fork` 的 COW 是**同一套机制**（页表只读位 + 写时缺页），区别只在物理页来源：`fork` 的共享页是父进程已分配的匿名页，文件私有映射的共享页是 page cache 的文件页。
```c
/* 匿名私有：等价于 malloc 的大块路径。加 MAP_NORESERVE 是因为这里只打算用
   一小部分；若让内核按全额预留 commit，反而会在 overcommit 检查下失败。 */
void *buf = mmap(NULL, 1UL << 30, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
/* 匿名共享：mmap 版的 shmget。绝不能配 MAP_PRIVATE —— 那会让每个进程各拿
   一份副本，共享红利完全消失。 */
void *shm = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                 MAP_SHARED | MAP_ANONYMOUS, -1, 0);
/* 文件共享映射：写先进 page cache，msync 才保证落盘；长度按页对齐、偏移必须
   是页大小整数倍，否则 EINVAL。 */
int fd = open("/var/lib/app/data.bin", O_RDWR);
void *file = mmap(NULL, 65536, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);  /* 映射持有对文件 inode 的引用，与 fd 生命周期无关 */
```
两个常被追问的配套接口：**`msync`** 把共享文件映射的脏页显式刷回磁盘（`MS_SYNC` 同步等待、`MS_ASYNC` 只入回写队列、`MS_INVALIDATE` 让其他映射者缓存失效），对匿名映射无意义。**`madvise`** 不改变映射本身，只给内核行为提示：`MADV_SEQUENTIAL`/`MADV_RANDOM` 调整预读策略，`MADV_WILLNEED` 触发预读，`MADV_DONTNEED` 让内核**丢弃这段区间的物理页**，`MADV_HUGEPAGE` 控制透明巨大页，`MADV_DONTFORK` 让该区间不被 `fork` 继承（避免大缓冲被 COW 复制）。`MADV_DONTNEED` 是 8.10 节"让 RSS 降下来"的关键手段。
> [!WARNING]
> `MADV_DONTNEED` 在**私有匿名映射**上的语义是"内容被丢弃，后续读取得到零页"，不是"稍后可恢复"；对**共享文件映射**则是"丢弃干净副本、脏页先回写"。把两者当成一件事、以为它只是"释放内存但数据还在"，会在用 `mmap` 实现内存池时造成静默数据丢失。

## 8.8 共享内存与写时复制的关系

它们是一对**对偶设计**：COW 的目标是"看起来各自一份，实际共享直到必须分开"；共享内存的目标是"必须共享，永远不分开"。
```text
COW（fork 之后）                     共享内存（匿名 MAP_SHARED）
进程 A 页表 ─┐                       进程 A 页表 ─┐
             ├─▶ 同一物理页                         ├─▶ 同一物理页
进程 B 页表 ─┘   （PTE 标记只读）     进程 B 页表 ─┘   （PTE 保持可写）
A 写 ⟹ #PF ⟹ 复制出新页             A 写 ⟹ 直接落在同一物理页
     A 从此指向新页，B 不变               ⟹ 没有缺页，也没有复制
★ 写者与读者"看不见彼此"              ★ B 立刻看到，同步责任全在使用者
⟹ COW 是"共享到写为止"；共享内存是"共享到底"。实现共享内存最省事的办法，
  就是"用 MAP_SHARED 把 COW 关掉"。
```

**表 8-7 共享内存的三种接口**
| 接口 | 命名/发现方式 | 生命周期 | 底层实现 | 适用场景 |
|---|---|---|---|---|
| `shmget`/`shmat`（System V） | key（`ftok`）或 `IPC_PRIVATE` | 独立于进程，需 `shmctl(IPC_RMID)` 显式删除 | 基于 tmpfs | 遗留代码；需要命名共享段且不依赖文件系统路径 |
| `shm_open`/`mmap`（POSIX） | POSIX 名字（形如 `/myshm`） | 独立于进程，需 `shm_unlink` | 同样基于 tmpfs | 新代码首选；与 `mmap` 语义、`fcntl` 锁配合自然 |
| `mmap` 匿名 + `MAP_SHARED` | 无名字，靠 `fork` 继承或传递指针 | 随最后一个映射者消失而消失 | 匿名页 + 共享页表项 | 父子进程协作、线程间大缓冲 |

三者殊途同归：**最终都落到"一段被多个进程页表指向的物理页"**，区别只在"如何命名/发现"和"谁负责回收"。System V 的共享段即使无人挂接也依然存在（`ipcs` 里那些"孤儿"共享段的来源），而匿名共享映射随最后一个映射者退出而释放。
一个高频陷阱：**共享内存本身不提供同步**。它只解决了"零拷贝地交换数据"，不解决"什么时候可以读"。多进程场景必须配信号量/`futex`/文件锁，否则会出现"读到半写状态"的隐蔽 bug。这是"共享内存是最快的 IPC"的正确边界——**最快，但把同步责任完全交给了使用者**。
> [!NOTE]
> 面试官会追问的下一层：**`shm_open` 必须配 `MAP_SHARED` 吗？** 是。`shm_open` 得到的只是一个挂在 tmpfs 上的 fd，真正拿到共享内存必须 `mmap` 它且必须用 `MAP_SHARED`；若用 `MAP_PRIVATE`，就退化成"各进程一份副本的文件私有映射"，两个进程根本看不到对方的数据，改动也永远不会写回。

## 8.9 内存碎片、伙伴系统与 slab

### 8.9.1 内部碎片与外部碎片的成因差异

两者的成因与修法完全不同：**内部碎片（internal fragmentation）** 指**分配出去的块比请求的大**，多出来的空间在块内部被浪费且无法被别的请求使用，成因是"分配粒度大于请求粒度"（固定大小的页、2 的幂块、按 size class 取整的内存池）；**外部碎片（external fragmentation）** 指**空闲总量足够但被切成互不相邻的小块**，无法满足一个较大的连续请求，成因是"变长分配 + 任意顺序释放"。一句话判据：问"这些碎片能不能被合并给下一个请求"——能合并的是外部碎片，不能的是内部碎片。这也解释了为什么分页（等长块）从根上消除了外部碎片却必然引入内部碎片：**你只能选择碎片的形态，不能同时消除两者**。

### 8.9.2 伙伴系统（buddy allocator）的合并规则

内核需要"以页（4 KB）为单位、大小可变"的分配，用的是伙伴系统：**空闲块按 2 的幂组织成若干条链表，order `k` 的块大小为 `2^k` 个页**。
```text
空闲链表：order 0 (1 页)  order 1 (2 页)  order 2 (4 页)  order 3 (8 页)
请求 1 页但 order 0 为空：取 order 3 的 8 页反复对半拆
   8 页 ─▶ 4+4（留一半在 order 2）─▶ 2+2（留一半在 order 1）─▶ 1+1（一半给请求者）
★ 合并规则：块 B（order k）的伙伴地址 = B 的地址 XOR (2^k 个页对应的字节数)
  —— 除第 k 位外伙伴与 B 的高位完全相同，所以一次异或就能算出伙伴。
  释放 B 时先算伙伴地址并查它是否在同一 order 的空闲链表里：
    在   ⟹ 合并成 order k+1，再向上继续尝试级联合并
    不在 ⟹ B 直接挂进 order k 链表
★ 必然的内部碎片：请求 3 页拿到 4 页（浪费 25%），请求 1025 页拿到 2048 页。
```

### 8.9.3 slab/slub 为什么专为内核小对象设计

伙伴系统最小粒度是 1 页，而内核里大量对象只有几十到几百字节（`task_struct`、`inode`、`dentry`、socket 缓冲）。若每个小对象都向伙伴系统要一页，**内部碎片极高**（64 字节对象占 4 KB，浪费 98%）且**分配释放极其频繁**。因此内核在伙伴系统之上叠了一层 slab，价值有四条。
**① 把一页切成多个同类对象。** 一个 slab 是一组连续页，被切成若干等长对象槽位；因为同 slab 内对象等长，"用哪个槽"只需一个位图或空闲链表，小对象内部碎片被显著压低。
**② 缓存构造/析构结果（constructor caching）。** 这是 slab 名字的由来：对象释放后不立刻归还伙伴系统，而是保持"已初始化"状态。内核里很多结构初始化成本高（锁、链表头），复用能省掉重复初始化——**这是"缓存"而非"池"的关键差别**。
**③ 每 CPU 本地缓存（per-CPU cache）消除锁竞争。** 分配是最热的内核路径之一，若每次都拿全局锁，多核立刻互相阻塞。slab/slub 为每个 CPU 维护本地空闲对象链表，绝大多数分配释放完全不碰锁，只在本地缓存耗尽/溢出时批量与共享 slab 交换，把"每次分配一次加锁"降级为"每批一次加锁"。
**④ 支持调试、统计与收缩。** slab 有独立缓存名（见 `/proc/slabinfo`），可做对象泄漏定位；slub 通过 slab merging 合并布局相同的缓存以省内存；需要时整体收缩归还伙伴系统。

**表 8-8 内核分配器与用户态分配器的分工**
| 分配器 | 管理粒度 | 解决的问题 | 主要缺陷 |
|---|---|---|---|
| 伙伴系统 | 2 的幂个页 | 物理页的快速分配与合并 | 只到页级；非 2 幂请求有内部碎片 |
| slab | 每缓存一组页 | 小对象复用与构造缓存 | 元数据偏多、实现复杂 |
| slub（现代默认） | 同上，元数据放进 `struct page` | 同 slab 语义，去掉了大量重复元数据 | 跨缓存合并有额外规则 |
| `malloc`（glibc） | 16 字节对齐的任意大小 | 用户态任意大小的分配 | 只归还堆顶连续段；RSS 可能不降 |

## 8.10 `malloc` 的实现层次与 RSS 不下降

### 8.10.1 `brk` 与 `mmap` 的分界：128 KB 阈值

glibc 的 `malloc` 有两条取内存的路径，分界由 `M_MMAP_THRESHOLD` 决定，**默认 128 KB**。
```text
请求 size
  ├─ size + 元数据 < 128 KB ─▶ 走"堆"：先在已有 arena 里按大小分级找空闲 chunk
  │     （tcache / fastbin / small bin / unsorted bin / large bin，见 31.7 节），
  │     都不够再用 brk() 抬高 program break 扩堆
  │     ★ 优点：复用快、不额外系统调用
  └─ size ≥ 128 KB ──────────▶ 走 mmap()：直接要一段匿名私有映射
        ★ 优点：free 时 munmap 能整段还给内核
        ★ 代价：每次分配一次系统调用（约 100 ns ~ 1 μs）+ 一段新 VMA + 首次缺页
★ 阈值是动态的：free 一个 mmap 块时 glibc 把该块大小记为新阈值（64 位下上限 32 MB），好让同类大块继续走 mmap 以便整段归还。
```
为什么定在 128 KB？因为两条路径的代价结构相反：**走堆的代价是"释放时难以归还内核"**（碎片留在堆内复用），**走 mmap 的代价是"每次分配一次系统调用 + 一小段 VMA"**。小块数量多、单次代价敏感，走堆；大块数量少、单次价值大、归还整段的收益远超一次系统调用，走 mmap。128 KB 是两者的经验交点。

### 8.10.2 `free` 之后 RSS 为什么不降

要点是分清三层内存：**虚拟区间（VMA）**、**已分配的 chunk（分配器视角）**、**已驻留的物理页（RSS 视角）**。`free` 只改变第二层：
```text
① 走堆的块：free 只是把 chunk 挂回空闲链表（或放进 tcache/fastbin）
   ⟹ 虚拟区间没变、物理页没还、RSS 完全不变。这是最常见的情形。
② 堆顶的块：只有它紧邻 program break 且堆顶有足够大的连续空闲，brk() 下调
   才能把页还给内核；中间有个在用小块挡着就退不下去（"堆不能打洞"）。
③ 走 mmap 的块：free 触发 munmap，虚拟区间与物理页一起消失，RSS 立即下降。
④ 多线程 arena：非主线程 arena 是独立 mmap 的段，free 后整段留给该线程复用，线程不退出 RSS 就不降。
⟹ 结论：free 的语义是"交还给分配器"，不是"交还给操作系统"。
```
要让 RSS 真降，手段按侵入性排序：**`malloc_trim(0)`** 显式收缩主 arena 堆顶并对空闲大块执行 `MADV_DONTNEED`；**`madvise(MADV_DONTNEED)`** 直接对映射区间丢弃物理页（自管理内存池常用）；**`MADV_FREE`** 告诉内核"可回收但不必立刻回收"，比 `MADV_DONTNEED` 便宜但延迟不确定；**`mallopt(M_MMAP_THRESHOLD, 小值)`** 让更多分配走 mmap 以便整段 `munmap`，代价是系统调用变多；**`MALLOC_ARENA_MAX`** 限制线程 arena 个数，能显著降低多线程服务的 RSS 上限。
> [!WARNING]
> "`top` 里 RSS 高就是内存泄漏"是错误诊断。RSS 还包含**未归还但可复用的空闲堆**、**page cache 映射的共享库**、**线程栈**。判定泄漏要看**趋势**（持续单调上升且不回落）与**堆内结构**（`malloc_stats`、`/proc/<pid>/smaps`、`valgrind massif`、`jemalloc` profiling），而不是某一时刻的绝对值。观测工具的完整用法见第 12 章 12.7 节。

## 8.11 内存回收的触发路径与延迟毛刺

回收解决的是"空闲页不够了从谁手里拿"，它的触发不是轮询，而是**水位（watermark）驱动**：
```text
水位：min（保命线，供原子分配用）/ low / high，都是 per-zone 的
空闲页 < low 水位 ⟹ 唤醒 kswapd（每 NUMA 节点一个）
   ★ 异步后台回收：扫 inactive LRU、回写脏页、回收干净页、必要时换出匿名页
     到 swap。应用线程不受阻。
空闲页 < min 水位（kswapd 追不上分配速度）⟹ direct reclaim
   ★ 分配者自己在分配路径上同步回收；应用线程被拖进回收，分配延迟从约
     1 μs 变成毫秒级 —— 这是 P99 毛刺最典型的来源之一
回收失败（回不出任何页）⟹ 触发 OOM killer（见第 12 章 12.4 节）
回收对象的代价对照：
  干净文件页 ⟹ 直接丢弃，无需 I/O，最便宜
  脏文件页   ⟹ 必须先回写（约 100 μs ~ 10 ms），贵
  匿名页     ⟹ 无 swap 则无法回收；有 swap 需先写 swap，贵且增加 I/O
⟹ swappiness 调大并非"更省内存"，而是"把回收压力转成 swap I/O"。
```
**直接回收（direct reclaim）**的机理把本章多个概念串在一起：应用线程在内核态分配内存 → 空闲页低于 `min` → 该线程自己去扫 LRU、回写脏页、可能等 swap I/O → 这段时间它既不执行用户代码也不释放 CPU（表现为 `sy`/`wa` 升高、进程进入 `D` 状态）→ 一次分配从约 1 μs 变成几十 μs 到几十 ms。**长尾由此产生，而平均延迟可能几乎看不出变化**——这正是第 3 章 3.5 节尾延迟放大与第 11 章 11.2 节内存指标要一起看的原因。缓解手段：**给回收留提前量**（提高 `vm.min_free_kbytes`、调 `vm.watermark_scale_factor` 让 kswapd 更早启动）、**减少脏页**（`vm.dirty_ratio`/`vm.dirty_background_ratio`，把回写从分配路径挪到后台）、**隔离**（cgroup v2 的 `memory.high` 让超限者在自己内部回收而不拖累全机，见第 10 章 10.4 节）、**削平匿名分配尖峰**（内存池 + 预热）。
> [!IMPORTANT]
> 面试官问"服务 P99 抖动、`vmstat` 的 `si`/`so` 不为零、`sy` 偏高，怎么判断是内存回收造成的"，答法要落在**因果链**上：`si/so` 非零只说明在换页；决定性证据是 `/proc/vmstat` 的 `allocstall`（进入直接回收的次数）与 `pgscan_direct`（直接回收扫描量）是否同步增长——**只要 `allocstall` 在涨，就能确认毛刺来自分配路径上的同步回收，而不是 CPU 竞争或锁**。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 虚拟内存解决三个独立问题 | 保护靠 MMU 每次访存查权限，容量幻觉靠按需分页，地址解耦靠页级映射与 PIC | 只答"让进程以为自己独占内存"，说不出三者手段不同 |
| 单级页表在 48 位下要 512 GB | `2^(48−12) × 8 B = 2^39 B`，必须多级；多级省的是**空洞** | 答"多级页表总是更省内存"，忽略地址空间稀疏这一前提 |
| TLB 命中即绕过页表 | 4 KB 页下 L2 TLB 只覆盖约 6 MB，2 MB 页可覆盖约 3 GB | 把 TLB 缺失与数据缓存缺失混为一谈 |
| 缺页有四类，段的本质是"修不了的缺页" | 次要/主要/COW/无效地址，判定依据是错误码与 VMA | 把 COW 说成"额外一次拷贝"；以为内核会检查空指针 |
| FIFO 有 Belady 异常，LRU 没有 | LRU/OPT 满足栈性质（帧数增加时驻留集包含），FIFO 不满足 | 认为"内存越多缺页必然越少" |
| 真实系统用近似 LRU | Clock 用 1 个使用位 + 时钟指针；Linux 用 active/inactive 双链 LRU | 声称"Linux 维护精确 LRU 链表" |
| 共享内存就是"故意不 COW" | COW 共享到写为止，共享内存共享到底且不提供同步 | 用 `MAP_PRIVATE` 配 `shm_open`，两进程互相看不见 |
| `malloc` 在 128 KB 处分界 | 小块走堆求复用，大块走 `mmap` 求可整段归还 | 以为 `free` 一定把内存还给操作系统 |
| RSS 不降不等于泄漏 | `free` 只交还分配器，`malloc_trim`/`MADV_DONTNEED` 才能归还 | 用单次 `top` 的 RSS 绝对值判定泄漏 |
| 直接回收是 P99 毛刺的典型来源 | 空闲页低于 `min` 水位时分配者在分配路径上同步回收 | 只看平均延迟，或不拿 `allocstall` 就去猜原因 |

## 自测题
**Q1**（计算）某 64 位系统虚拟地址有效位宽 48 位、页大小 4 KB、页表项 8 字节。① 单级页表需要多少字节？② 若改为两级，第一级多少项、每项覆盖多大区间？③ 一个进程映射了连续的 2 GB 匿名内存但只触碰了前 8 KB，四级页表下最低层需要几张 PTE 页？

<details><summary>答案与解析</summary>
① `2^(48−12) × 8 B = 2^36 × 8 B = 2^39 B = 512 GB`，且**每个进程一份**，所以这个方案连一个进程都装不下。
② 覆盖 48 位、偏移占 12 位，中间 36 位若两级则每级 18 位，即 `2^18 = 262144` 项/级，第一级每项覆盖 `2^18 × 4 KB = 1 GB`。但注意 **x86-64 实际用的是每级 9 位的四级**，因为要满足 `2⁹ × 8 B = 4096 B`——**一张页表恰好占一页**，这是级数与每级位宽必须同时满足的约束。
③ 只触碰 8 KB，即 2 个相邻的 4 KB 页，落在同一个 2 MB 区间内 ⟹ **只需 1 张 PTE 页（4 KB）**。若答"2 GB / 2 MB = 1024 张"，那是把"映射"当成了"驻留"。
**错在哪**：② 套用"两级各 18 位"却不检查页表页大小；③ 混淆 VMA 与 PTE。**面试官会怎么追问**：①"为什么每级必须是 9 位？"——为了让一张页表正好一页，既不需要连续多页也不跨页访问。②"进程用满整个地址空间时多级页表还省吗？"——不省，反而多出中间层表页；多级页表的收益完全来自地址空间稀疏。
</details>

**Q2**（推导）引用串 `1 2 3 4 1 2 5 1 2 3 4 5`。用 FIFO、LRU、OPT 在 3 个物理帧下模拟并给出缺页次数；再把帧数改为 4，指出哪个算法出现 Belady 异常并解释原因。
<details><summary>答案与解析</summary>
3 帧：FIFO 9 次、LRU 10 次、OPT 9 次（逐步模拟见 8.5.1 节）。4 帧：FIFO **10 次**（由 9 增到 10，即 Belady 异常），LRU 降到 8 次，OPT 降到 7 次。
**原因**：LRU 与 OPT 满足**栈性质**——帧数为 `m` 时的驻留集一定是帧数为 `m+1` 时驻留集的子集，所以多加一个帧绝不会把原命中变成缺失。FIFO 的淘汰顺序只由进入时间决定、与访问模式无关，多加一个帧会改变整个淘汰序列，可能恰好踢掉马上要用的页。
**错在哪**：把 FIFO 模拟成"命中时也刷新顺序"（那是 LRU）；或认为 Belady 异常源于"FIFO 太简单"。**面试官会怎么追问**：①"OPT 不可实现为什么还要讲？"——它是评价基准与下界，用于量化"知道未来能省多少"。②"Clock 会有 Belady 异常吗？"——它只是近似 LRU、不保证严格栈性质，理论上可能有，实用中不关注。
</details>
**Q3**（辨析）先 `mmap` 1 GB 匿名内存，标志分别为 `MAP_PRIVATE` 与 `MAP_SHARED`，然后 `fork`，子进程写入中间某页。两种情形下父进程分别看到什么？为什么？
<details><summary>答案与解析</summary>
`MAP_PRIVATE`：父子页表指向同一物理页，但 PTE 被清成只读（`R/W=0`）。子进程写触发 **COW 缺页**（错误码 `P=1, W/R=1`），内核为子进程分配新页、复制内容、恢复可写；父进程的页**完全不受影响**。
`MAP_SHARED`：`fork` 不会清掉共享映射的写权限，因为该 VMA 的语义就是共享。子进程的写直接落在**同一物理页**上，**父进程立刻可见**，且除首次触碰的按需分页外不发生任何缺页。
**关键点**：`fork` 的 COW 与 `MAP_SHARED` 的共享是**同一份页表里共存的两套规则**——`fork` 遍历 VMA 时，对私有映射清写位，对共享映射原样保留。所以"`fork` 之后一切都 COW"是错的，**它取决于 VMA 的 `MAP_SHARED`/`MAP_PRIVATE` 标志**。
**错在哪**：答"`fork` 之后父子一定看不见对方的写"（漏了共享映射）；或答"共享映射的写也要先走 COW 缺页"（COW 在已有写权限的 PTE 上不会触发）。**面试官会怎么追问**：①"没有 swap 时私有匿名脏页能换出吗？"——不能，无后备又无 swap 只能留在内存里。②"`MAP_SHARED` 匿名映射的两个进程怎么同步？"——共享内存**不提供同步**，必须外挂 `futex`/信号量/文件锁。
</details>
**Q4**（分析）某服务压测中 RSS 稳定在 6 GB 不下降，`malloc_stats` 显示堆内大量**已释放**的块；同时 P99 从 20 ms 涨到 300 ms，`vmstat` 显示 `si/so` 偶尔非零、`sy` 偏高。给出诊断路径与两类修复。
<details><summary>答案与解析</summary>
**RSS 不降**：这不是泄漏而是分配器未归还（8.10.2 节）。确认三点——块是小块（`free` 后留在空闲链表）还是大块（应 `munmap`）；是否有多个线程 arena（每个可达 64 MB 虚拟段且线程退出前不归还）；堆顶是否被在用小块堵住使 `brk` 降不下去。手段是 `malloc_stats`/`mallinfo2` 看 arena、`/proc/<pid>/smaps` 看各 VMA 的 RSS 分布。
**P99 毛刺**：`si/so` 非零只说明在换页；决定性证据是 `/proc/vmstat` 的 `allocstall` 与 `pgscan_direct` 是否同步增长——增长即确认**直接回收**（8.11 节），应用线程在分配路径上被拖去回写脏页甚至等 swap I/O。若 `allocstall` 不涨而 `sy` 高，应转向 CPU/锁方向（见第 11 章 11.2 节）。
**两类修复**：① 归还与复用——`malloc_trim(0)` 或对内存池做 `MADV_DONTNEED`，限制 `MALLOC_ARENA_MAX`，调小 `M_MMAP_THRESHOLD` 让大块走 `mmap` 以便整段归还，用内存池减少碎片化。② 回收时机——提高 `vm.min_free_kbytes`、调整 `vm.watermark_scale_factor` 让 kswapd 提前介入，用 cgroup `memory.high` 把回收限制在本 cgroup 内，控制 `vm.dirty_ratio` 把回写挪到后台，关键路径预热消除启动期尖峰。
**错在哪**：把 RSS 高直接判成泄漏去查代码里的 `malloc`/`new`（本例堆内块是已释放的）；或看到 `si/so` 就断定根因是 swap，而没有区分**分配路径上的同步回收**与后台 `kswapd` 正常工作。**面试官会怎么追问**：①"`MADV_DONTNEED` 与 `free` 的本质区别？"——前者丢弃物理页、后续访问重新缺页，后者只是把块交回分配器。②"直接把 `vm.swappiness` 设为 0 行不行？"——它降低匿名页换出倾向，但压力下仍会回收，还可能抬高文件页回收比例、加大直接回收概率，属于用配置掩盖结构问题。
</details>
**Q5**（设计）设计一个"跨进程共享的只读大字典（约 8 GB）"，要求启动快、多进程零拷贝、物理内存只算一份。给出映射方案、页表/TLB 层面的注意事项，以及构建该文件时的一个陷阱。
<details><summary>答案与解析</summary>
**方案**：把字典固化成一个文件，各进程用 `mmap(..., PROT_READ, MAP_SHARED)` 映射。物理页只有一份（page cache），所有进程的页表都指向它；写被权限位禁止，因此不存在一致性问题。比"每个进程 `read` 一份"省掉 `8 GB × N` 的物理内存与启动期 I/O。
**页表与 TLB**：8 GB 用 4 KB 页需要 `2^21` 个页表项、约 16 MB 的 PTE 页，随机查找会持续 TLB 缺失；应使用**巨大页**（`hugetlbfs` 或 `MADV_HUGEPAGE`）让 TLB 覆盖整个字典（8.2.1 节）。同时用 `MADV_RANDOM` 关闭顺序预读（随机查找下预读是纯浪费），并对热点区 `MADV_WILLNEED` 预热。
**构建陷阱**：**文件里的数据必须与虚拟地址无关**——用偏移而非指针，或统一按"映射基址 + 偏移"计算。若写入绝对指针，各进程映射基址不同，解引用立刻段错误，这正是 8.1 节"地址无关"问题的直接体现。次要陷阱：映射偏移必须是页大小整数倍；文件长度不是页整数倍时末尾不足一页的部分被零填充，且**写入末尾那半页不会落盘**（写共享映射时的经典 bug）。
**错在哪**：设计成每个进程各自 `MAP_PRIVATE` 映射——那就退化成各一份 COW 副本，内存红利消失；或设计成"共享内存 + 自己维护一致性"——只读字典根本不需要可变共享内存。**面试官会怎么追问**：①"为什么不用 `shm_open`？"——可以，但文件映射天然有后备存储、可持久化、能用 `msync` 控制落盘，只读字典用文件更自然。②"文件被删了进程还能用吗？"——可以，映射持有 inode 引用，这也是"`df` 显示空间没释放"的常见来源（见第 13 章 13.3 节）。
</details>

## 面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
| 虚拟内存到底解决了什么问题？ | 能否把三个独立需求拆开 | 陷阱：只答"让每个进程以为自己独占内存"，说不出保护是 MMU 每次访存做的、容量幻觉来自按需分页、地址解耦来自页级映射 | 8.1 |
| 页大小为什么是 4 KB？换成 2 MB 会怎样？ | 内部碎片、页表开销、I/O 粒度的权衡 | 陷阱：答"历史原因"或"2 的幂方便"就停住。加分：从 TLB 覆盖范围（`条目数 × 页大小`）推出巨大页的数量级收益，并说出换出与回收粒度变粗的代价 | 8.2.1 |
| 为什么 64 位系统要用多级页表？ | 能否现场算出单级页表的规模 | 陷阱：只说"一级页表太大"而不给数字，也说不清多级省的是**空洞**。加分：给出 `2^(48−12) × 8 B = 512 GB`，并说明每级 9 位是为了"一张表恰好一页" | 8.3.1 / 8.3.2 |
| TLB 命中还需要查页表吗？TLB 缺失的代价是什么？ | "翻译缓存"与"数据缓存"的区分 | 陷阱：把 TLB 缺失与数据缓存缺失混为一谈，或认为"TLB 命中还要查页表校验权限"。加分：指出命中即完全绕过页表，缺失由硬件 PMH 走四级遍历 | 8.3.3 |
| 缺页有几种？段错误算缺页吗？ | 四类缺页的分辨与判定依据 | 陷阱：只答"页不在内存"，把 COW 说成"额外一次拷贝"，以为内核会检查空指针。加分：用 `#PF` 错误码的 `P`/`W`/`U` 位现场分派，并指出段错误是"修不了的缺页" | 8.4 |
| FIFO 会不会出现内存越多缺页越多？为什么 LRU 不会？ | Belady 异常与栈算法 | 陷阱：答"不可能，内存越多越好"，或举不出反例。加分：给出引用串与帧数 3/4 的缺页次数（9 → 10），用**栈性质**解释 LRU/OPT 为何单调不增 | 8.5.1 |
| 操作系统真的用 LRU 吗？精确 LRU 为什么不能用？ | LRU 的硬件近似与实际实现 | 陷阱：答"Linux 用 LRU 链表精确维护"。加分：说清"每次访存都要更新序信息"与"翻译本为加速访存"直接冲突，因此用使用位 + Clock，并提到 Linux 的 active/inactive 双链 LRU | 8.5.2 |
| 写时复制缺页和普通缺页有什么不同？ | COW 在缺页路径中的位置 | 陷阱：把 COW 讲成 `fork` 时立刻复制，说不出它在**写的那一刻**由缺页触发、且不需要磁盘 I/O。加分：指出 `fork` 只是把 PTE 清成只读并失效 TLB | 8.4 |
| 共享内存和 mmap 是什么关系？ | 是否理解"故意不 COW" | 陷阱：用 `MAP_PRIVATE` 配 `shm_open`（两个进程互相看不见数据），或以为共享内存自带同步。加分：对照画 COW 与共享内存的页表，指出同步责任完全在使用者 | 8.8 |
| 为什么 free 之后内存没有还给操作系统？ | `malloc` 的分层与 RSS 语义 | 陷阱：答"操作系统没收到通知"，或把 RSS 高当成内存泄漏。加分：区分走堆的小块（留在空闲链表）、走 `mmap` 的大块（可 `munmap`）、线程 arena 保留三种情形 | 8.10.2 |
| 服务 P99 抖动，怎么判断是不是内存回收造成的？ | 回收触发路径与可观测证据 | 陷阱：看到 `si/so` 就断定根因是 swap，或只看平均延迟。加分：用 `/proc/vmstat` 的 `allocstall`/`pgscan_direct` 证明是**直接回收**发生在分配路径上 | 8.11 |

## 延伸阅读

- Bryant & O'Hallaron, *CSAPP* 第 9 章"虚拟内存"：地址翻译与多级页表的推导主线；Silberschatz 等, *Operating System Concepts* 第 9 ~ 10 章：页替换算法与 Belady 异常的原始反例。
- Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 3A 第 4 章（Paging：四级页表、`CR3`、页表项标志位、`#PF` 错误码）与 4.10 节（TLB、PCID）。
- Linux 内核文档 `Documentation/admin-guide/mm/`（`transparent_hugepage.rst`、`hugetlbpage.rst`、`ksm.rst`）与 `Documentation/admin-guide/sysctl/vm.rst`（水位、`swappiness`、直接回收相关参数）。
- `man 2 mmap`、`man 2 madvise`、`man 2 msync`、`man 3 malloc`（含 `mallopt`、`M_MMAP_THRESHOLD`、`malloc_trim` 的官方语义）、`man 7 shm_overview`、`man 5 proc`（`VmPTE`/`VmPMD` 字段）。
- Mel Gorman, *Understanding the Linux Virtual Memory Manager*：伙伴系统、slab/slub、双链 LRU 与 `kswapd` 的实现；对照第 12 章 12.1 与 12.4 节了解这些机制在生产中的观测与调优口径。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch08/  

