内存管理的本质是把程序看到的虚拟地址和真实物理内存解耦:分页与页表负责映射和权限,TLB 负责给地址翻译提速,置换算法决定内存紧张时换谁出去,伙伴系统和 slab 则在内核里分管物理页的批发与零售。本文按”为什么需要、怎么映射、怎么置换、怎么分配”的顺序把这条链路讲透。

它解决什么问题

早期系统里程序直接操作物理地址,单程序还能凑合,多道程序一并发立刻暴露三个硬伤。

  1. 隔离失效:程序 A 往物理地址 1000 写数据,程序 B 恰好也把变量放在 1000,互相覆盖;更危险的是普通程序可能踩到内核自己的内存,系统稳定性无从谈起。
  2. 无法重定位:程序编译时不知道会被装进物理内存哪一段,地址一旦写死,换个装载位置就跑不了,更别说同时跑多个实例。
  3. 无法超售:物理内存就那么大,但程序总想要一大片连续空间,还希望”用到的才真的占内存”。

解法是引入虚拟地址空间:每个进程独占一套连续地址,落到哪些物理页由操作系统与 MMU 共同决定。隔离、重定位、超售三个目标,全部建立在这层映射之上。

核心原理:分段与分页怎么切地址空间

分段(Segmentation)按程序逻辑切:代码段、数据段、栈段各成一段,地址由段号加段内偏移构成,段表记录每段基址、界限与特权级。它贴近程序结构、便于按段设权限,但段长不固定,段与段之间会留下难以复用的空隙,即外部碎片;整理碎片又要做大粒度搬移,代价很高。

分页(Paging)换个思路:虚拟空间与物理内存都切成固定大小的页(Linux x86-64 通常 4 KB),虚拟地址拆成页号加页内偏移,页表记录虚拟页到物理页帧的映射。物理页可以离散分配,进程不再需要连续物理内存,外部碎片基本消失;代价是最后一页可能用不满(内部碎片),且页表自身要占空间。

折中方案是段页式:先按逻辑分段,段内再分页,兼顾逻辑保护与离散分配,但转换链条更长。现代通用操作系统以分页为绝对主力,x86-64 长模式下传统分段已退化为平坦模型,仅 FS/GS 保留实际用途(如线程本地存储)。

深入机制

多级页表与 TLB

单级页表的空间账算不过来:32 位地址、4 KB 页,一个进程就有 2^20 个页,每项 4 字节,页表独占 4 MB,且绝大部分条目对应从没用过的地址。多级页表的思路是只为用到的范围建下级表:顶级目录常驻覆盖全空间,下级页表按需创建。x86-64 主流采用四级分页,48 位虚拟地址按 9+9+9+9+12 位拆分,每级 512 项恰好占满一页。

分级省了空间,却让一次翻译最多要走四趟内存。TLB(Translation Lookaside Buffer,快表)是 MMU 内部的页表项缓存:命中直接得到物理页帧号,未命中再走页表并把结果回填。靠程序的访问局部性,TLB 命中率通常很高,查表成本被摊薄。2 MB 大页能让单个 TLB 项覆盖更大地址范围,这正是大页的主要收益来源。

虚拟地址 [虚拟页号 VPN | 页内偏移 offset]
|-- 查 TLB --命中--> 物理页帧号 ----------+
|-- 未命中 |
v |
逐级查页表 PGD -> PUD -> PMD -> PTE |
| |
+----> 物理地址 [物理页帧号 PFN | offset] <-+

页面置换:内存不够时换谁出去

物理页耗尽又需要新页,必须挑一个”受害者”换出,挑得差就会刚换出又换入,白忙一场。

  • OPT(最优置换):换出未来最久不会再用的页,缺页次数理论最少,但需要预知未来,只能当评判其他算法的基准。
  • FIFO(先进先出):实现最简单,却不关心页面冷热,还会出现 Belady 异常——分配的页帧变多,缺页次数反而上升。
  • LRU(最近最少使用):赌局部性,换出最久未访问的页,效果接近 OPT,但精确维护全局访问顺序的软硬件成本都太高。
  • Clock(时钟/二次机会):LRU 的廉价近似。页面排成环形队列,每页配一个访问位;置换指针扫到访问位为 1 就清 0 放行(给第二次机会),扫到 0 就淘汰。一个位加一圈扫描,便低成本逼近 LRU。

真实 Linux 并不照搬某个教科书算法,而是用活跃/非活跃 LRU 链表、workingset、refault 检测、内存水位等机制组合近似,文件页与匿名页的回收路径也不同;说”Linux 用的就是 Clock”并不严谨。

物理页分配:伙伴系统与 slab

页表解决”怎么映射”,伙伴系统(Buddy System)解决”物理页从哪来”。空闲页按 2 的幂次大小分组:申请时找能满足请求的最小块,太大就一分为二;释放时若相邻伙伴也空闲就逐级合并。规则简单、能快速给出连续物理页、抑制外部碎片,但分配粒度是 2^order 个页,申请 65 KB 连续物理内存可能拿到 128 KB,存在内部浪费。

内核里还有大量比一页小得多的对象——inode、文件对象、网络描述符——按页分配太奢侈。slab(及后继 slub/slub 系)分配器架在伙伴系统之上:批量向伙伴系统拿页,再按对象大小切成缓存块反复复用,既省空间又省反复初始化的开销。两者是”页级批发”与”对象级零售”的分层关系。

工程实践

  • JVM 堆只是地址空间的一部分:进程 RSS 还包含元空间、线程栈、JIT 代码缓存、DirectBuffer 等,RSS 超过 -Xmx 不能直接断定堆泄漏。
  • 大页按场景验证:数据库、JVM 用大页可缓解 TLB 压力,但 THP 的分配、清零与内存紧凑可能带来延迟毛刺,Redis 官方就建议关闭 THP。
  • 内存指标要拆开看:Linux 会把空闲内存拿去做 Page Cache,free 很低不代表紧张;结合 MemAvailable、Swap 活跃度、主缺页速率与 PSI 判断才有意义。
  • Swap 是后备不是扩容:匿名页回收依赖 Swap 换出换入,工作集一旦长期超过物理内存,系统进入抖动,加 Swap 只是延缓失败。

对比与权衡

维度 分段 分页 段页式
划分依据 程序逻辑(代码/数据/栈) 固定大小 先分段再分页
地址结构 段号 + 段内偏移 页号 + 页内偏移 段号 + 段内页号 + 偏移
碎片特征 外部碎片严重 内部碎片可控 内部碎片
共享与保护 按段授权,语义自然 按页授权,粒度细 两者兼顾
转换开销 查一次段表 多级页表 + TLB 链条最长

面试视角

为什么需要虚拟地址空间?

直接暴露物理地址会带来互相覆盖、无法重定位、无法超售三个问题。虚拟地址让每个进程独占一套连续地址,落到哪些物理页由内核与 MMU 决定,隔离、保护与按需调页才有落脚点。

分页和分段的区别?

分段按逻辑模块切,长度不固定,保护共享直观但产生外部碎片;分页按固定大小切,物理页离散分配,外部碎片基本消除,代价是内部碎片与页表开销。现代系统以分页为主。

TLB 是什么,为什么离不开它?

TLB 是 MMU 里的页表项缓存。多级页表下每次翻译最多要多次访存,开销不可接受;TLB 利用局部性缓存热点映射,命中即直达物理页帧号,把查表成本摊薄到接近零。

LRU 效果好,为什么实际系统偏爱 Clock?

精确 LRU 要在每次访问时维护全局顺序,成本太高。Clock 只用一个访问位加环形扫描,给最近用过的页第二次机会,以极低成本逼近 LRU,是工程上的主流折中;真实内核则在此基础上叠加冷热链表与水位控制。

伙伴系统和 slab 各管什么?

伙伴系统管物理页批发:按 2 的幂分裂合并,快速提供连续页、抑制外部碎片。slab 管对象级零售:从伙伴系统拿页后切成固定大小的对象缓存复用,服务内核里大量小对象的高频分配释放。

小结

  • 虚拟地址空间一次性解决隔离、重定位、超售三个问题,是内存管理的基石。
  • 分页靠固定粒度换来离散分配,多级页表省下稀疏空间的开销,TLB 再把翻译成本压回去。
  • 置换算法从 OPT 到 Clock 是效果与成本的权衡谱系,真实内核用组合机制近似而非单选。
  • 伙伴系统按页批发、slab 按对象零售,二者分层协作,撑起内核的物理内存管理。