本章解释"每个进程都以为自己独占内存"这一幻觉如何被页表、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 副本,内存与缓存双双浪费;⑤ 碎片不可控——变长连续分配必然产生外部碎片。任何一条都足以否决直接寻址,而现代系统上它们同时成立。

重要

面试官问"虚拟内存解决了什么问题”,三段式回答是:保护(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 条目数由硬件固定,于是

1
2
3
4
5
6
7
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)由内核自动合并。

提示

巨大页的收益要按两条路子说——少做缺页(页表项数下降)与少做翻译(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 字节:

1
2
3
条目数 = 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,一张表恰好占一页,这个"巧合"是设计目标):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  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 KB8 KB(含 1 张 PD 页)

开销与”实际用到的区间数“绑定,而不是与"虚拟区间的跨度"绑定——两者差 256 倍,这就是惰性建立页表的价值。

警告

常见错误回答:“多级页表是为了省内存,因为一级页表太大。“只对了一半——它省的不是表项总数,而是空洞的开销。面试官紧接着会问"如果进程用满整个地址空间,多级页表还省吗”,正确答案是不省,甚至更贵(多了中间层表页)。多级页表的收益前提是地址空间稀疏,而这对所有真实进程都成立。

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。

注意

面试官会追问的下一层:为什么 fork 之后 TLB 里的旧映射必须失效? fork 会复制页表并把父子两边的写权限都清掉,这是一次页表修改。若忘记失效,父进程的写就会绕过缺页路径直接改写共享物理页,COW 立刻失效——这正是"COW 依赖页表只读位 + TLB 及时失效"两件事同时成立的原因。

8.4 缺页的四种类型

先建立统一骨架:任何一次访存,MMU 先查 TLB;TLB 缺失则查页表;只要页表项不满足本次访问的要求,就触发缺页异常(#PF),硬件把出错地址压入 CR2、原因编码压入错误码。所以"缺页"严格来说是**“页表项无法满足本次访问”**,而不只是"页不在内存里"。

1
2
3
4
5
#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=0malloc 后首次触碰;mmap 一个已被别的进程读入缓存的文件;换出又换回且仍在缓存复用缓存页或分配新页 → 填 PTE → 置 P/A 位 → 返回重试约 1 μs
主要缺页(major)页不在内存,需要读写磁盘首次读未被缓存的文件页;swap 换入;mmap 冷文件进入 vm_ops->fault(文件映射走 filemap_fault)→ 发起块层 I/O → 睡眠等待 → 填 PTESSD 约 100 μs 起,机械盘 5 ~ 10 ms
写时复制缺页(COW)PTE 存在且 P=1,但 R/W=0,且 VMA 可写;错误码 P=1, W/R=1fork 之后父子任一方首次写共享页分配新页 → 复制原页内容 → 改 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。内核并没有"检查指针是否为空"这一步。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
用户态访存 ─▶ 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 保存出错指令的地址,因此重试天然正确)

重要

面试必答的一句话:缺页处理程序不"搬数据”,它做的是"把页表项修正成能支持本次访问的样子"。数据搬运只发生在主要缺页与 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,逐个模拟(每行标出被淘汰的页):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
引用串 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
     ┌──────┐   ┌──────┐   ┌──────┐   ┌──────┐
     │ 页 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 让"睡眠过久任务重新获得竞争力"是同一类设计思想:用平滑衰减代替无限累积的历史。

小心

“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 节)。

提示

被问"怎么减少缺页开销”,按收益排序答:① 用巨大页同时减少缺页次数与 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 的文件页。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/* 匿名私有:等价于 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 降下来"的关键手段。

警告

MADV_DONTNEED 在私有匿名映射上的语义是"内容被丢弃,后续读取得到零页",不是"稍后可恢复";对共享文件映射则是"丢弃干净副本、脏页先回写"。把两者当成一件事、以为它只是"释放内存但数据还在",会在用 mmap 实现内存池时造成静默数据丢失。

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

它们是一对对偶设计:COW 的目标是"看起来各自一份,实际共享直到必须分开";共享内存的目标是"必须共享,永远不分开"。

1
2
3
4
5
6
7
8
9
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"的正确边界——最快,但把同步责任完全交给了使用者。

注意

面试官会追问的下一层: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 个页。

1
2
3
4
5
6
7
8
9
空闲链表: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。

1
2
3
4
5
6
7
8
9
请求 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 只改变第二层:

1
2
3
4
5
6
7
① 走堆的块: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 上限。

警告

“top 里 RSS 高就是内存泄漏"是错误诊断。RSS 还包含未归还但可复用的空闲堆、page cache 映射的共享库、线程栈。判定泄漏要看趋势(持续单调上升且不回落)与堆内结构(malloc_stats、/proc/<pid>/smaps、valgrind massif、jemalloc profiling),而不是某一时刻的绝对值。观测工具的完整用法见第 12 章 12.7 节。

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

回收解决的是"空闲页不够了从谁手里拿”,它的触发不是轮询,而是水位(watermark)驱动:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
水位: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 节)、削平匿名分配尖峰(内存池 + 预热)。

重要

面试官问"服务 P99 抖动、vmstat 的 si/so 不为零、sy 偏高,怎么判断是内存回收造成的",答法要落在因果链上:si/so 非零只说明在换页;决定性证据是 /proc/vmstat 的 allocstall(进入直接回收的次数)与 pgscan_direct(直接回收扫描量)是否同步增长——只要 allocstall 在涨,就能确认毛刺来自分配路径上的同步回收,而不是 CPU 竞争或锁。

本章小结

关键结论一句话表述常见错误
虚拟内存解决三个独立问题保护靠 MMU 每次访存查权限,容量幻觉靠按需分页,地址解耦靠页级映射与 PIC只答"让进程以为自己独占内存",说不出三者手段不同
单级页表在 48 位下要 512 GB2^(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 不满足认为"内存越多缺页必然越少"
真实系统用近似 LRUClock 用 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 页?

答案与解析① `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 位?"——为了让一张页表正好一页,既不需要连续多页也不跨页访问。②"进程用满整个地址空间时多级页表还省吗?"——不省,反而多出中间层表页;多级页表的收益完全来自地址空间稀疏。

Q2(推导)引用串 1 2 3 4 1 2 5 1 2 3 4 5。用 FIFO、LRU、OPT 在 3 个物理帧下模拟并给出缺页次数;再把帧数改为 4,指出哪个算法出现 Belady 异常并解释原因。

答案与解析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、不保证严格栈性质,理论上可能有,实用中不关注。
**Q3**(辨析)先 `mmap` 1 GB 匿名内存,标志分别为 `MAP_PRIVATE` 与 `MAP_SHARED`,然后 `fork`,子进程写入中间某页。两种情形下父进程分别看到什么?为什么?
答案与解析`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`/信号量/文件锁。
**Q4**(分析)某服务压测中 RSS 稳定在 6 GB 不下降,`malloc_stats` 显示堆内大量**已释放**的块;同时 P99 从 20 ms 涨到 300 ms,`vmstat` 显示 `si/so` 偶尔非零、`sy` 偏高。给出诊断路径与两类修复。
答案与解析**RSS 不降**:这不是泄漏而是分配器未归还(8.10.2 节)。确认三点——块是小块(`free` 后留在空闲链表)还是大块(应 `munmap`);是否有多个线程 arena(每个可达 64 MB 虚拟段且线程退出前不归还);堆顶是否被在用小块堵住使 `brk` 降不下去。手段是 `malloc_stats`/`mallinfo2` 看 arena、`/proc//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 行不行?"——它降低匿名页换出倾向,但压力下仍会回收,还可能抬高文件页回收比例、加大直接回收概率,属于用配置掩盖结构问题。
**Q5**(设计)设计一个"跨进程共享的只读大字典(约 8 GB)",要求启动快、多进程零拷贝、物理内存只算一份。给出映射方案、页表/TLB 层面的注意事项,以及构建该文件时的一个陷阱。
答案与解析**方案**:把字典固化成一个文件,各进程用 `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 节)。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应小节
虚拟内存到底解决了什么问题?能否把三个独立需求拆开陷阱:只答"让每个进程以为自己独占内存",说不出保护是 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 双链 LRU8.5.2
写时复制缺页和普通缺页有什么不同?COW 在缺页路径中的位置陷阱:把 COW 讲成 fork 时立刻复制,说不出它在写的那一刻由缺页触发、且不需要磁盘 I/O。加分:指出 fork 只是把 PTE 清成只读并失效 TLB8.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 节了解这些机制在生产中的观测与调优口径。