4. 虚拟内存管理:缺页异常处理、页面置换算法与内存回收

虚拟内存这东西,说白了就是给每个进程画了一张「大饼」—— 让进程以为自己拥有连续且巨大的内存空间。但实际上,物理内存就那么点,全靠内核在背后偷偷调度。今天我们就聊聊这个调度过程里最核心的三个环节:缺页异常怎么处理、页面怎么换进换出、以及内存怎么回收。

核心观点: 虚拟内存管理的本质是「延迟分配 + 按需换入」。缺页异常是触发点,页面置换算法是决策者,内存回收是执行者。三者环环相扣。

4.1 缺页异常处理流程

进程访问一个虚拟地址,MMU 查页表发现该页不在物理内存中,CPU 就会触发一个缺页异常(Page Fault)。这时候内核就忙起来了。

我遇到过不少刚接触内核的同学,以为缺页异常就是「内存不够了」。其实不然。缺页异常分好几种,处理方式完全不同:

缺页类型 触发原因 处理动作
有效缺页(Major Fault) 页面在交换分区或文件映射中,但不在内存 从磁盘读入内存,可能阻塞进程
轻微缺页(Minor Fault) 页面已在内存(如共享页),但页表未建立映射 仅更新页表,不涉及磁盘 I/O
无效缺页(Invalid Fault) 访问非法地址或权限错误 发送 SIGSEGV,进程崩溃

处理流程大致是这样的:

  1. CPU 保存现场,跳转到内核的缺页异常处理函数 do_page_fault()。
  2. 内核检查触发地址是否在进程的虚拟地址空间内。
  3. 如果不在,直接判为无效缺页,发 SIGSEGV。
  4. 如果在,检查页表项。如果页表项存在但页不在内存,走 Major Fault 路径。
  5. 分配物理页框,从磁盘读取数据,更新页表。
  6. 如果页表项不存在但 VMA 存在(比如堆区首次访问),走 Minor Fault 路径,直接分配零页。
  7. 恢复进程执行。
我的经验: 我曾经排查过一个数据库性能抖动问题,发现每秒 Major Fault 数量从几十飙升到上千。查到最后,是 swap 分区配置太小,导致内存回收时频繁换入换出。所以,/proc/meminfo 里的 pgmajfault 字段,我每次调优都会盯着看。

4.2 页面置换算法:LRU 与 LFU

物理内存满了,必须把某些页面踢出去,腾出空间给新页面。踢谁?这就是页面置换算法的事。

4.2.1 LRU(最近最少使用)

LRU 的思路很直观:如果一个页面最近被访问过,那它很可能很快还会被访问。所以优先淘汰最久没被访问的那个。

Linux 内核并没有实现严格的 LRU,因为那需要维护一个全局链表,每次访问都要更新,开销太大。实际用的是「近似 LRU」—— 双链 + 标志位。

  • 活跃链表(active list): 存放近期被访问过的页面。
  • 非活跃链表(inactive list): 存放可能被淘汰的页面。
  • 页面被访问时,设置 PG_referenced 标志位。内核的 kswapd 线程定期扫描,把标志位为 1 的页面移到活跃链表,为 0 的移到非活跃链表。

你想想看,这其实就是一种「二次机会」算法。页面有两次机会证明自己有用,两次都没被访问,那就准备走人。

4.2.2 LFU(最不经常使用)

LFU 看的是访问频率,不是最近时间。它淘汰的是「过去一段时间内被访问次数最少」的页面。

Linux 内核里没有纯粹的 LFU,但 kswapd 在计算页面热度时,会综合考虑访问次数和最近访问时间。说白了,就是 LRU 和 LFU 的混合体。

对比总结:
  • LRU 适合「时间局部性」强的场景,比如循环访问数组。
  • LFU 适合「频率局部性」强的场景,比如热点数据反复被查。
  • Linux 实际用的是「近似 LRU + 频率加权」,兼顾两者。

4.3 内存回收机制

内存回收不是等到内存用光了才开始。内核里有个后台线程 kswapd,它会监控内存水位线。一旦低于某个阈值,就开始回收。

回收的对象主要有三类:

回收对象 回收方式 代价
页面缓存(Page Cache) 直接丢弃(干净页)或写回磁盘(脏页) 低(干净页) / 中(脏页)
匿名页(Anonymous Pages) 换入到 swap 分区 高(涉及磁盘 I/O)
内核 slab 缓存 收缩 slab 分配器中的空闲对象 低

回收的触发条件由三个水位线控制:

  • pages_min: 最低水位。低于此值,kswapd 会同步回收,且分配内存的进程也会参与直接回收(direct reclaim)。
  • pages_low: 低水位。kswapd 开始异步回收。
  • pages_high: 高水位。回收到此水位后,kswapd 休眠。
避坑指南: 我曾经遇到一个案例,某 Java 应用频繁触发直接回收(direct reclaim),导致 CPU 飙升、应用卡顿。查了 /proc/zoneinfo 发现 pages_min 设置得太低。后来调高了 vm.min_free_kbytes,问题解决。记住,直接回收是同步的,它会阻塞分配内存的进程,对延迟敏感的应用是灾难。

4.4 知识结构总览

下面这张图把本章的核心逻辑串起来了。你可以把它当作一个思维导图来理解:

虚拟内存管理核心流程 虚拟内存管理核心流程 进程访问虚拟地址 MMU 查询页表 页表命中 → 正常访问 缺页异常触发 do_page_fault() 处理 分配物理页框 / 从磁盘换入 关键水位线 pages_min pages_low pages_high

4.5 实战调优建议

嗯,最后给几个我常用的调优思路:

  • 监控缺页异常: 用 sar -B 看 pgmajfault/s 和 pgfault/s。如果 Major Fault 持续偏高,说明物理内存不足或 swap 配置不合理。
  • 调整回收倾向: vm.swappiness 控制内核更倾向于回收页面缓存还是匿名页。默认 60,对数据库服务器我习惯降到 10 左右,减少 swap 使用。
  • 观察页面置换: /proc/pagetypeinfo 可以看各类型页面的分布。如果某个 zone 的 Unmovable 页面过多,可能导致内存碎片,触发不必要的回收。
一个小技巧: 我习惯在压测时同时跑 vmstat 1,盯着 si(swap in)和 so(swap out)两列。只要这两个值不为 0,就说明内存已经在「硬撑」了。这时候就该考虑加内存或者优化应用的内存使用。