计组课第 8 章讲过虚拟内存的硬件机制(MMU、页表、TLB),这一章补上操作系统侧的完整故事,起点是一笔吓人的账。4GB 虚拟地址空间、4KB 一页,页表需要 2²⁰ ≈ 100 万个表项;每项 8 字节,一张页表 8MB。听着还行?别忘了每个进程都要一张,开 200 个进程,光账本就 1.6GB,物理内存还没干活先被页表吃掉十分之一。而且这些账本 99% 的表项对应着从未被访问过的地址(每个进程的地址空间就用了几个零头)。
为这 1% 的有效项付 100% 的账本钱,显然荒唐。解法也直白:账本分级,顶部目录里没被用到的分支整棵跳过。这章就沿着「怎么记账、怎么分地、碎片怎么办」三条线,把 OS 内存管理的主干讲完。
最朴素的方案:每个进程分一块连续内存。麻烦立刻出现:进程有走有来,内存渐渐变成「瑞士奶酪」,空洞满地。空洞管理有三种放置策略,考试爱出计算题:
不管哪种,连续分配的天性缺陷叫外部碎片:空闲总量够,但每个洞都不够大,谁也装不下。对策是「紧凑」(把所有进程搬家挪到一头),但进程搬家要改地址、停运行,代价大到不可接受。这个死结的出路是下一节:别要求连续了。
分页把「连续」这个执念扔了:物理内存切成固定大小的页框(4KB),进程的虚拟地址空间切成同样大的页,任意一页可以放任意一个页框,页表负责记账。外部碎片从此消失(页框是标准尺寸,随时可复用),代价是内部碎片:进程要 10KB,分 3 页共 12KB,最后 2KB 浪费在页内。平均每个进程浪费半页(2KB),比外部碎片的不可控好管理得多,这笔账全行业都认了。
顺带对比一下分段:按程序的逻辑结构(代码段、数据段、栈)划分,每段变长、语义清晰、便于共享和保护(整个代码段一个权限位)。但段是变长的,外部碎片问题原样回归。历史上的 x86 曾是段页式(先分段再分页),现代系统事实上已经把段退化成「全空间一段」,只留分页干活:工程最终选了碎片可控的方案,放弃了「内存结构映射程序结构」的美感。x86-64 还留着段寄存器,基本只是兼容考古现场。
回到开头那笔账。解法是给页表本身也分页:顶级页目录 1024 项,每项指向一张二级页表,二级页表没被用到的整张不分配。2³² 地址、4KB 页:一级表 4KB(常驻),二级表按需最多 4MB。一个只用了地址空间零头的小进程,实际开销从 8MB 掉到两三张页(十几 KB)。64 位机器再往上加层级,x86-64 是四级(共 48 位地址),每级 9 位索引。
代价也直白:查一次地址要走四层账本,最坏四次访存才拿到物理地址。于是又引出下一节的老朋友。
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 倍,命中率立竿见影。
一个常见误解是 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,两层记账各管各的。
ps -o majflt,minflt 能分开看,性能排查时看 major。top 里 VIRT 惊人很正常,那是「账面地址空间」;看 RES(常驻物理内存)和 PSS 才靠谱。