第 6 章 · 内存管理

计组课第 8 章讲过虚拟内存的硬件机制(MMU、页表、TLB),这一章补上操作系统侧的完整故事,起点是一笔吓人的账。4GB 虚拟地址空间、4KB 一页,页表需要 2²⁰ ≈ 100 万个表项;每项 8 字节,一张页表 8MB。听着还行?别忘了每个进程都要一张,开 200 个进程,光账本就 1.6GB,物理内存还没干活先被页表吃掉十分之一。而且这些账本 99% 的表项对应着从未被访问过的地址(每个进程的地址空间就用了几个零头)。

为这 1% 的有效项付 100% 的账本钱,显然荒唐。解法也直白:账本分级,顶部目录里没被用到的分支整棵跳过。这章就沿着「怎么记账、怎么分地、碎片怎么办」三条线,把 OS 内存管理的主干讲完。

6.1 先看没有虚拟内存的世界:连续分配

最朴素的方案:每个进程分一块连续内存。麻烦立刻出现:进程有走有来,内存渐渐变成「瑞士奶酪」,空洞满地。空洞管理有三种放置策略,考试爱出计算题:

  • 首次适应:从头扫,第一个塞得下的就用。快,但低地址端渐渐全是小碎片。
  • 最佳适应:找塞得下且最贴身的空洞。听着省,实际产生最多针眼碎片(每个空洞被啃剩一点),还得全表扫描。
  • 最坏适应:专挑最大的空洞下刀,剩的下刀口子还够别人用。听着挥霍,实测常常不差。

不管哪种,连续分配的天性缺陷叫外部碎片:空闲总量够,但每个洞都不够大,谁也装不下。对策是「紧凑」(把所有进程搬家挪到一头),但进程搬家要改地址、停运行,代价大到不可接受。这个死结的出路是下一节:别要求连续了。

6.2 分页:非连续的胜利

分页把「连续」这个执念扔了:物理内存切成固定大小的页框(4KB),进程的虚拟地址空间切成同样大的页,任意一页可以放任意一个页框,页表负责记账。外部碎片从此消失(页框是标准尺寸,随时可复用),代价是内部碎片:进程要 10KB,分 3 页共 12KB,最后 2KB 浪费在页内。平均每个进程浪费半页(2KB),比外部碎片的不可控好管理得多,这笔账全行业都认了。

顺带对比一下分段:按程序的逻辑结构(代码段、数据段、栈)划分,每段变长、语义清晰、便于共享和保护(整个代码段一个权限位)。但段是变长的,外部碎片问题原样回归。历史上的 x86 曾是段页式(先分段再分页),现代系统事实上已经把段退化成「全空间一段」,只留分页干活:工程最终选了碎片可控的方案,放弃了「内存结构映射程序结构」的美感。x86-64 还留着段寄存器,基本只是兼容考古现场。

6.3 多级页表:账本也要分页

回到开头那笔账。解法是给页表本身也分页:顶级页目录 1024 项,每项指向一张二级页表,二级页表没被用到的整张不分配。2³² 地址、4KB 页:一级表 4KB(常驻),二级表按需最多 4MB。一个只用了地址空间零头的小进程,实际开销从 8MB 掉到两三张页(十几 KB)。64 位机器再往上加层级,x86-64 是四级(共 48 位地址),每级 9 位索引。

代价也直白:查一次地址要走四层账本,最坏四次访存才拿到物理地址。于是又引出下一节的老朋友。

6.4 TLB 与有效访问时间:一道必考的计算

TLB(计组第 8 章讲过)缓存最近的虚拟页号到页框号的映射,让多数访存免于走页表。这门课考它,是因为「平均访存时间」能给出一道干净的计算题:

设 TLB 查询 10ns,内存访问 100ns,TLB 命中率 90%,单级页表:

命中:  10(查TLB) + 100(访存)          = 110 ns
未命中: 10 + 100(查页表) + 100(访存)  = 210 ns
EAT   = 0.9 × 110 + 0.1 × 210        = 120 ns

命中率从 90% 掉到 80%,EAT 立刻变 130ns;若不加 TLB,每次都是 210ns。这个敏感性就是为什么局部性(程序反复访问同一批页)是整个虚拟内存体系的地基,也是为什么大内存应用(数据库、JVM 大堆)会专门调 huge page:2MB 一页,同样 TLB 容量覆盖的地址范围扩大 512 倍,命中率立竿见影。

6.5 malloc 之下:OS 管的是页,不是字节

一个常见误解是 malloc 直接找 OS 要内存。实际分工:OS 的分配粒度是页,进程内的小额分配由运行时库自己管理。glibc malloc 的两个进货渠道:

  • brk:把堆顶指针往上推,小额分配(默认阈值 128KB 以下)从这里切,free 只是还给库的空闲链表,不还给 OS;
  • mmap:大块分配(含你手写的大数组)直接向内核要独立映射,munmap 立刻归还。

这解释了几个经典现象:程序 free 之后 RSS 不降,是库在攒着复用,不是泄漏;一次 free 一个 mmap 出来的大块,内存立刻还回去;「程序吃着 1GB 但实际工作集只有 100MB」时,先分清虚拟内存、RSS、堆内空闲链表三个口径再下结论。这部分逻辑到数据库课谈 Buffer Pool、Java 谈 GC 堆时还会反复出现:OS 管 page,语言运行时管 object,两层记账各管各的。

常见错误

  • 混淆三类「碎片」:外部碎片(空洞装不下任何进程,连续分配/分段的病)、内部碎片(页内浪费,分页的病)、以及表格里常混进来的「页表开销」(记账成本)。三个词在简答题里各答各的,别串。
  • 以为缺页都是错误。第一次访问一个新映射的页是正常缺页(次缺页,minor:从页缓存搬),从 swap 回来是主缺页(major,真读盘)。ps -o majflt,minflt 能分开看,性能排查时看 major。
  • 拿虚拟地址空间当内存占用。top 里 VIRT 惊人很正常,那是「账面地址空间」;看 RES(常驻物理内存)和 PSS 才靠谱。

练习

  1. 手算:空闲块序列 100K、500K、200K、300K、600K,进程需求 212K、417K、112K、426K,分别用首次、最佳、最坏适应放置,哪种能全部放下?(这是考研真题的原型。)
  2. 手算:64 位系统 48 位虚拟地址、4KB 页、四级页表,每级索引几位?顶级页目录一项覆盖多大地址范围?
  3. 手算:TLB 查询 5ns,访存 100ns,命中率 95%,单级页表求 EAT;命中率降到 90% 呢?算完你会发现 TLB 命中率每掉 5% 的代价值多少。
  4. 思考:为什么 malloc 小分配走 brk、大分配走 mmap?如果让所有分配都走 mmap(每次独立映射),好处是 free 即归还、坏处是什么?(提示:mmap 有系统调用开销,页表和 TLB 也要跟着建;想想频繁 malloc/free 的服务。)