Linux 内存管理概览

从虚拟内存、页表到回收与 OOM

Posted by Yvain Zhang on January 19, 2023 主题:技术

Linux 内存管理最让人头疼的地方,不是某个概念难,而是概念太多、太碎。页表、VMA、缺页、伙伴系统、swap、OOM 每个都知道一点,但很容易串不起来。
我更建议先抓住这条顺序,再回头看细节:

虚拟地址 -> 页表映射 -> 缺页建立映射 -> 物理页分配 -> 内存紧张时回收 / 换出 -> 再不够就 OOM

把这条线理顺之后,再看 mm_struct、伙伴系统、page cache、swap,就不容易散。

1. 为什么操作系统必须做内存管理

如果程序直接操作物理地址,会马上遇到几个问题:

  • 不同程序之间互相踩内存
  • 一个程序崩溃可能直接带崩整个系统
  • 程序装载位置必须固定,可移植性差
  • 空闲内存难以灵活复用

因此现代操作系统会给每个进程提供一个“看起来连续且独立”的虚拟地址空间。应用程序以为自己在独占一整块内存,实际上底层由内核和硬件负责把这些虚拟地址翻译成真正的物理页。

这就是内存管理最先解决的问题:隔离和抽象。

2. 从分段到分页,为什么最后都落到页

分段解决了什么

分段更强调逻辑划分,把程序分成代码段、数据段、栈段等区域。它的好处是更符合程序结构,也更容易表达权限。

但分段的问题在于:

  • 粒度偏大
  • 不利于细粒度调度和换入换出
  • 容易产生外部碎片

分页为什么更适合现代系统

分页把地址空间切成固定大小的页,例如 4 KB。页大小固定之后,内核更容易:

  • 分配和回收内存
  • 做按需加载
  • 做缺页处理
  • 做页面换入换出

Linux 后面那一大串机制,几乎都是围着“页”在转。看页表、缺页中断、伙伴系统时,脑子里最好一直记着:内核不是按“变量”分配内存,它按页干活。

3. 虚拟地址、物理地址、MMU 到底怎么串起来

进程执行一条读写指令时,用的是虚拟地址,不是物理地址。地址转换的参与者有三层:

  • 软件维护页表
  • MMU 负责查表和地址翻译
  • TLB 缓存热点翻译结果

把这件事粗略展开,大概就是:

  1. CPU 发起虚拟地址访问
  2. 先查 TLB
  3. 命中则快速得到物理地址
  4. 未命中则根据页表重新翻译
  5. 如果页表里压根没有有效映射,就触发缺页异常

所以性能上常听到的 TLB miss,不是“找不到内存”,而是“地址翻译缓存没命中,需要重新走页表流程”。

4. 进程看到的地址空间是什么样的

Linux 进程并不是简单拿到一大块连续地址,而是由多个区域拼出来。内核用 mm_struct 描述进程整体内存上下文,用 vm_area_struct 描述一段段 VMA。

常见区域包括:

  • 代码段
  • 数据段
  • BSS
  • 动态库映射区
  • mmap 匿名映射或文件映射区

VMA 很重要,因为很多内存行为都依赖它提供上下文信息:

  • 这块地址是匿名页还是文件映射
  • 可读、可写、可执行权限是什么
  • 触发缺页时应该如何补页

换句话说,页表决定“地址怎么映射”,VMA 决定“这段地址为什么存在、该按什么规则处理”。

5. 缺页中断是整个体系的关键枢纽

缺页中断不是异常中的“出错”,它往往是正常机制的一部分。

进程申请一块内存时,虚拟地址空间可以先建立好,但物理页不一定立刻分配。只有真正访问到该地址时,内核才会发现:

  • 当前还没有物理页
  • 或者页已经被换出
  • 或者是文件映射页尚未载入

此时内核会根据 VMA 和页表状态决定接下来做什么:

  • 分配新的匿名页
  • 把文件内容读入 page cache
  • 从 swap 把匿名页换回内存
  • 如果访问非法地址,则发送段错误

所以“缺页”并不等于出错。更关键的是分清:

  • 合法的按需缺页
  • 非法访问导致的异常缺页

6. 物理内存是怎么被管理的

虚拟内存是进程视角,物理页管理是内核视角。Linux 内核需要解决两个不同层级的问题:

  1. 大块页框怎么分配
  2. 小对象怎么高效复用

伙伴系统

伙伴系统负责页级分配。它的思路是按 2 的幂次管理内存块:

  • 需要大块时尽量直接拿
  • 没有合适块时向上拆分
  • 释放时如果“伙伴块”也空闲,就向上合并

这种方式优点是简单、适合页框管理,缺点是会有一定内部碎片。

slab / slub

内核里大量分配并不是整页,而是各种小对象,例如 inode、dentry、task 相关结构。直接拿页来切太浪费,于是需要 slab 类分配器来做对象缓存。

它们解决的问题是:

  • 小对象高频分配释放
  • 减少重复初始化成本
  • 降低碎片

所以可以简单记成:

  • 伙伴系统负责“页”
  • slab / slub 负责“页上的小对象”

7. 页面回收为什么会牵扯 page cache 和匿名页

当物理内存紧张时,内核不会立刻 OOM,而是优先尝试回收。回收对象主要有两类:

page cache

文件数据读入内存之后会形成 page cache。它的优势是:

  • 已经写回磁盘的干净页可以直接丢弃
  • 再次访问时可以重新从文件系统加载

因此干净的 page cache 一般回收成本较低。

匿名页

匿名页通常来自堆、栈、匿名 mmap。这类页没有现成文件作为后备存储,如果要回收,往往需要换出到 swap。

因此匿名页的回收成本通常更高,因为它会涉及:

  • 写 swap
  • 后续访问时再从 swap 换入

这也是为什么很多系统一到内存吃紧就会明显卡顿,因为它已经开始频繁换页了。

8. swap、内存规整、huge page、OOM 各自解决什么问题

swap

swap 的作用不是“让系统变快”,而是提供更大的腾挪空间,让匿名页在物理内存不足时有地方暂存。它解决的是“内存不够时能不能继续活下去”,不是“性能优化”。

内存规整

长时间运行后,空闲页可能很多,但连续大块页不足。这时内存规整会尝试迁移页面,把碎片整理成更大的连续区域,方便高阶分配或 huge page。

huge page

大页减少了页表项数量,也减少了 TLB miss 频率,对数据库、虚拟化、内存密集型场景更有价值。但它要求更大的连续物理块,管理也更复杂。

OOM

当下面这些手段都不够时:

  • 普通回收
  • swap
  • 内存规整

内核就可能触发 OOM killer,杀掉部分进程释放内存。OOM 不是第一反应,而是最后兜底。

9. 理解内存问题时应该怎么观察

如果只是背概念,遇到线上问题还是容易慌。更实用的思路是按现象反推:

现象 1:程序一访问某块内存就崩

优先怀疑:

  • 非法地址访问
  • VMA 权限不对
  • 页表映射不存在

现象 2:系统越来越卡,但 CPU 不一定高

优先怀疑:

  • page reclaim 频繁
  • swap in / swap out 频繁
  • page cache 和匿名页争抢内存

现象 3:明明还有剩余内存,却申请大块内存失败

优先怀疑:

  • 外部碎片
  • 高阶页分配失败
  • 需要规整或 huge page 相关配置不合适

现象 4:系统突然杀进程

优先从 OOM 日志看:

  • 哪个进程被杀
  • 当时 anon/rss/page cache 各占多少
  • 是否存在明显泄漏或异常缓存膨胀

10. 推荐的学习和理解顺序

如果要把 Linux 内存管理学得更稳,建议按这条顺序推进:

  1. 虚拟地址和物理地址区别
  2. 分页、页表、TLB
  3. mm_struct 和 VMA
  4. 缺页中断
  5. 伙伴系统和 slab / slub
  6. page cache、匿名页、swap
  7. reclaim、compaction、OOM

不要一开始就钻内核细节,否则很容易被数据结构淹没。

11. 总结

Linux 内存管理并不是单一模块,而是一整套协作链路:

  • 硬件提供地址翻译能力
  • 内核维护页表和 VMA
  • 分配器管理页和对象
  • 回收机制在紧张时腾挪内存
  • 最后由 OOM 兜底

真把这套东西摸顺之后,价值不在于你会背多少结构体名,而是碰到内存问题时,能大概判断它更像是哪一类:

  • 地址映射问题
  • 页分配问题
  • 回收压力问题
  • swap 问题
  • 还是 OOM 问题