4. 虚拟内存管理:缺页异常处理、页面置换算法与内存回收
虚拟内存这东西,说白了就是给每个进程画了一张「大饼」—— 让进程以为自己拥有连续且巨大的内存空间。但实际上,物理内存就那么点,全靠内核在背后偷偷调度。今天我们就聊聊这个调度过程里最核心的三个环节:缺页异常怎么处理、页面怎么换进换出、以及内存怎么回收。
核心观点: 虚拟内存管理的本质是「延迟分配 + 按需换入」。缺页异常是触发点,页面置换算法是决策者,内存回收是执行者。三者环环相扣。
4.1 缺页异常处理流程
进程访问一个虚拟地址,MMU 查页表发现该页不在物理内存中,CPU 就会触发一个缺页异常(Page Fault)。这时候内核就忙起来了。
我遇到过不少刚接触内核的同学,以为缺页异常就是「内存不够了」。其实不然。缺页异常分好几种,处理方式完全不同:
| 缺页类型 | 触发原因 | 处理动作 |
|---|---|---|
| 有效缺页(Major Fault) | 页面在交换分区或文件映射中,但不在内存 | 从磁盘读入内存,可能阻塞进程 |
| 轻微缺页(Minor Fault) | 页面已在内存(如共享页),但页表未建立映射 | 仅更新页表,不涉及磁盘 I/O |
| 无效缺页(Invalid Fault) | 访问非法地址或权限错误 | 发送 SIGSEGV,进程崩溃 |
处理流程大致是这样的:
- CPU 保存现场,跳转到内核的缺页异常处理函数
do_page_fault()。 - 内核检查触发地址是否在进程的虚拟地址空间内。
- 如果不在,直接判为无效缺页,发 SIGSEGV。
- 如果在,检查页表项。如果页表项存在但页不在内存,走 Major Fault 路径。
- 分配物理页框,从磁盘读取数据,更新页表。
- 如果页表项不存在但 VMA 存在(比如堆区首次访问),走 Minor Fault 路径,直接分配零页。
- 恢复进程执行。
我的经验: 我曾经排查过一个数据库性能抖动问题,发现每秒 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 知识结构总览
下面这张图把本章的核心逻辑串起来了。你可以把它当作一个思维导图来理解:
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,就说明内存已经在「硬撑」了。这时候就该考虑加内存或者优化应用的内存使用。