第 8 章 内存管理
本章解释"每个进程都以为自己独占内存"这一幻觉如何被页表、TLB 与缺页机制共同维持,是理解 COW、尾延迟毛刺与 RSS 不下降的地基。
学习目标
读完本章,你应当能够:
- 从虚拟内存要解决的三个问题出发,推导"直接给物理地址"在多进程系统上为什么必然失效;
- 用具体数字算出单级页表在 48 位地址下的规模,写出四级页表的地址位段划分;
- 区分四类缺页的触发条件与处理路径,说明为什么段错误也是一种缺页;
- 手算 OPT/FIFO/LRU 的缺页次数,构造 FIFO 的 Belady 异常反例并解释 LRU 为什么不会异常;
- 辨析
MAP_SHARED与MAP_PRIVATE的写时复制语义差异,说明共享内存为什么是"故意不 COW"; - 解释
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 条目数由硬件固定,于是
| |
代价同样明确:内部碎片(小对象被塞进大页)、换出代价(换出一个 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 字节:
| |
更致命的是稀疏性:真实进程只用到地址空间中极小一部分,单级页表却强制每个虚拟页都有一个固定位置的表项。栈区(0x7fff...)与代码区(0x0000...)之间隔着 128 TB,中间全是要填的无效项。多级页表的收益不是减少表项总数,而是让"未使用的巨大区间"不占用任何底层页表页——中间层一个空表项就代表一整片空洞。
8.3.2 四级页表的地址位段划分
x86-64 把 48 位虚拟地址切成 5 段,除末段偏移固定 12 位外每段恰好 9 位(2⁹ = 512 项,512 × 8 B = 4096 B,一张表恰好占一页,这个"巧合"是设计目标):
| |
表 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 倍,这就是惰性建立页表的价值。
警告
常见错误回答:“多级页表是为了省内存,因为一级页表太大。“只对了一半——它省的不是表项总数,而是空洞的开销。面试官紧接着会问"如果进程用满整个地址空间,多级页表还省吗”,正确答案是不省,甚至更贵(多了中间层表页)。多级页表的收益前提是地址空间稀疏,而这对所有真实进程都成立。
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、原因编码压入错误码。所以"缺页"严格来说是**“页表项无法满足本次访问”**,而不只是"页不在内存里"。
| |
表 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。内核并没有"检查指针是否为空"这一步。
| |
重要
面试必答的一句话:缺页处理程序不"搬数据”,它做的是"把页表项修正成能支持本次访问的样子"。数据搬运只发生在主要缺页与 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,逐个模拟(每行标出被淘汰的页):
| |
为什么只有 FIFO 会异常?因为 LRU 与 OPT 满足栈性质(stack property):帧数为 m 时的驻留集恒为帧数为 m+1 时驻留集的子集,因此多加一个帧绝不会把原本的命中变成缺失。FIFO 的淘汰顺序只由"谁先进来"决定、与访问模式无关,加一个帧会改变整个淘汰序列,可能恰好踢掉马上要用的页。
8.5.2 LRU 的硬件近似:Clock 算法
精确 LRU 在硬件上做不到,原因很实在:每次访存都要更新"最后访问时间",意味着每次访存都写一次内存结构,带来锁、竞争与额外带宽,还必须在很短时间内完成——与"翻译本就是为了加速访存"直接冲突;软件链表实现同样不可接受。因此真实系统用硬件提供的 1 个使用位(reference bit)+ 软件维护的时钟指针来近似,即 Clock 算法(二次机会算法,second-chance):
| |
为什么真实系统用近似而非精确 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 的文件页。
| |
两个常被追问的配套接口: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 的目标是"看起来各自一份,实际共享直到必须分开";共享内存的目标是"必须共享,永远不分开"。
| |
表 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 个页。
| |
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。
| |
为什么定在 128 KB?因为两条路径的代价结构相反:走堆的代价是"释放时难以归还内核"(碎片留在堆内复用),走 mmap 的代价是"每次分配一次系统调用 + 一小段 VMA"。小块数量多、单次代价敏感,走堆;大块数量少、单次价值大、归还整段的收益远超一次系统调用,走 mmap。128 KB 是两者的经验交点。
8.10.2 free 之后 RSS 为什么不降
要点是分清三层内存:虚拟区间(VMA)、已分配的 chunk(分配器视角)、已驻留的物理页(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)驱动:
| |
**直接回收(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 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 页?
答案与解析
① `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、不保证严格栈性质,理论上可能有,实用中不关注。答案与解析
`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`/信号量/文件锁。答案与解析
**RSS 不降**:这不是泄漏而是分配器未归还(8.10.2 节)。确认三点——块是小块(`free` 后留在空闲链表)还是大块(应 `munmap`);是否有多个线程 arena(每个可达 64 MB 虚拟段且线程退出前不归还);堆顶是否被在用小块堵住使 `brk` 降不下去。手段是 `malloc_stats`/`mallinfo2` 看 arena、`/proc/答案与解析
**方案**:把字典固化成一个文件,各进程用 `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 双链 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 节了解这些机制在生产中的观测与调优口径。