在 Linux 内核的宏大内存管理架构中,伙伴系统(Buddy System)负责页面的分配,而与之配套的另一半——页面回收(Page Reclamation),则负责在内存紧张时释放页面。页面回收的核心执行者是 Kswapd 内核线程。如果没有高效及时的回收,系统将迅速陷入内存压力,最终触发 OOM(Out Of Memory)杀手。
本文将深入剖析 Linux 内核页面回收机制的完整链路、LRU 算法设计、直接回收与异步回收的区别、ZRAM/ZSwap 压缩交换技术,以及实际工程中的调优方法论。
一、页面回收的核心问题:何时回收、回收哪些
操作系统需要决定在内存使用率达到什么阈值时启动回收,以及当可用内存不足时应该优先回收哪些页面。这是两个最关键的设计问题:
- 何时回收(When):由水位线(Watermarks)决定——min、low、high 三个阈值将内存划分为不同压力区间
- 回收哪些(Which):由 LRU(Least Recently Used)算法决定——每个内存区域维护活跃/不活跃链表,通过页面访问的时效性判断其回收价值
二、LRU 算法:Linux 内核的五链表设计
2.1 基本原理
LRU 算法基于一个核心假设:过去一段时间内未被访问的页面,短期再次被访问的概率较低,因此优先回收。早期的 Linux 内核使用简单的 Active/Inactive 双链表,但随着系统规模和内存容量的增长,这种设计暴露出显著缺陷:扫描开销大(全局链表过长)、冷热判断粗糙(仅两个等级)、NUMA 不友好(跨节点回收延迟不可控)。
为此,内核社区引入了 Refault Distance 评估和 五链表 LRU 架构,将冷热判断从2级扩展到5级。
2.2 五链表结构
现代内核(5.x+)每个 NUMA 节点(pglist_data)维护以下五条 LRU 链表:
LRU_ACTIVE_FILE ← 活跃文件页(热缓冲页缓存)
LRU_INACTIVE_ANON ← 不活跃匿名页(用户态未访问)
LRU_ACTIVE_ANON ← 活跃匿名页(用户态最近访问)
LRU_UNEVICTABLE ← 不可回收锁定页面
页面如何在链表间移动?
- 页面首次加入时放入 Inactive 链表尾部
- 如果 Inactive 中的页面在扫描前被再次访问,则标记 PG_referenced 标志,下次扫描时提升到 Active 链表
- Active 链表中的页面如果经过两轮扫描仍保持 referenced 状态,继续留在 Active 链表(真正热页)
- 如果 Active 链表中的页面经过两轮扫描没有被访问,则降级到 Inactive 链表
- Inactive 链表尾部的页面是最冷最先被回收的对象
2.3 各页面的生命周期路径
文件页(File Page / Page Cache):
↘ 未再访问,继续溜到尾部 → shrink_list() → 释放或回写
匿名页(Anonymous Page):
↘ 未再访问 → 写入 swap 分区 → 释放页面
2.4 交换倾向(Swappiness)
系统通过 vm.swappiness 控制回收时文件页与匿名页的比例:
swappiness=0:几乎只回收文件页(除非free内存极低)
swappiness=60:平衡回收文件页和匿名页
swappiness=100:文件页与匿名页权重相等
swappiness=200:完全偏向回收匿名页
从 Linux 5.15 开始,scrubd 和 MGLRU(Multi-Gen LRU)的引入进一步提升了页面回收的精准度。MGLRU 放弃了传统的 Active/Inactive 二元分类,采用多代(Multi-Generation)模型,每轮扫描根据访问热度将页面划分为不同代数,年轻的页面更容易保留在内存中。
三、回收触发路径:异步回收 vs 直接回收
3.1 异步回收:Kswapd
Kswapd 是每个 NUMA 节点对应一个的专用内核线程(例如 kswapd0、kswapd1)。它由 init_mm 中的交换初始化函数创建,平时处于 schedule_timeout 睡眠状态,在水位线低于 WMARK_LOW 时被伙伴系统的直接分配路径或定时器唤醒。
Kswapd 的工作流程大致如下:
1. 进入 try_to_free_pages(),准备回收
2. 检查当前水位是否 >= WMARK_LOW,若是则休眠
3. 调用 balance_pgdat() 执行实际回收
4. shrink_node() → shrink_pgdat() 遍历 LRU 链表尾部
5. 写回脏页(kflushd/kupdate 异步回写)
6. 对于匿名页,写入 swap
7. 休眠直到下一次被唤醒
唤醒条件:
- 伙伴系统页面分配时发现 zone->managed_pages 中可用页面低于 low_wmark
- 内核线程 kswapd 定时器检查发现水位下降
- 内存压力通知器(Memory Pressure Notifier)报告严重压力
- 透明大页(THP)分裂失败触发紧急回收
3.2 直接回收(Direct Reclaim)
直接回收发生在进程自身的执行上下文中。当伙伴系统在 alloc_pages() 快速路径失败时,会调用 _alloc_pages_direct_reclaim,将当前进程加入回收工作队列。
直接回收最大的问题是阻塞当前进程。在高内存压力期间,进程在 alloc_pages() 调用中可能被长时间阻塞,导致应用延迟抖动、响应超时。常见场景:
• 日志写入(write → page cache → 直接回收)
• 网络读取(read → socket buffer → 直接回收)
• 数据库检查点刷盘(fsync → 脏页回写 → 直接回收)
判断直接回收的指标:
pgsteal_direct # 直接回收的页面数
pgscan_kswapd # kswapd 扫描的页面数
pgscan_direct # 直接回收扫描的页面数
compact_stall # 触发内存整理的总次数
compact_fail # 内存整理失败次数
compact_success # 内存整理成功次数
3.3 回收路径对比
| 维度 | Kswapd 异步回收 | Direct Reclaim 直接回收 |
|---|---|---|
| 执行上下文 | 内核线程(kswapd%d) | 调用者进程上下文 |
| 触发条件 | 水位 < low_wmark | alloc_pages 快速路径失败 |
| 对调用者的影响 | 透明无感 | 同步阻塞,可能耗时长达秒级 |
| 典型场景 | 后台异步唤醒回收 | Nginx proxy_buffering、Kafka Producer send |
四、页面回收的精细机制
4.1 页面写出与回收
回收不是一删了之,还需要确保数据一致性。页面回收的核心操作分三种:
- 干净文件页(Clean File Page):内存副本大于等于磁盘版本,直接释放 page 结构
- 脏文件页(Dirty File Page):需要先回写到磁盘(writeback),然后才能释放。由 wb_workfn 写回线程异步执行
- 匿名页(Anonymous Page):写入 swap 空间,并维护交换映射关系(swap_entry)
页面回收时还会检查 SwapCache —— 已写入 swap 但又被访问的匿名页(page fault 重新加载),直接释放 swap slot 并恢复页面。
4.2 THP 分裂与页面的重新合并
透明大页(THP)分配时是 2MB 的大页,但在回收时可能面临碎片化问题。Kswapd 在页面碎片化到一定程度时会通过 __alloc_pages_direct_compact 触发内存整理(Compaction),将小页合并为大页。
4.3 Unevictable LRU 与锁定页面
某些页面不允许被回收:mlock / mlockall 锁定的页面(PINNED)、共享内存被 shmctl(SHM_LOCK) 锁定的页面、内核代码段和 per-cpu 数据、设备映射 / DMA 缓冲区。这些页面进入 LRU_UNEVICTABLE 链表,shrink_lru_vma() 扫描时直接跳过。
五、交换空间(Swap)与压缩交换技术
5.1 Swap 基本原理
当匿名页被回收时,内容必须写入 swap 分区或 swap 文件。内核使用 Swap Area 结构维护交换空间:
unsigned long flags; // SWP_USED / SWP_WRITEOK
int prio; // 优先级(多 swap 区域排序)
struct plist_head swap_avail_heads; // 可用 swap slot
};
// 每个 swap slot(通常 4KB)对应一个 swp_entry_t
// swp_entry_t = (type << 1) | offset —— type 是 swap area 索引(最多31个),offset 是 slot 号
5.2 ZRAM —— 内存压缩交换
ZRAM(原 CompCache)将 swap 存储在压缩后的内存中,而非磁盘。
→ 释放原始页面
访问压缩页 → 分配新页 → ZRAM 解压 → 恢复数据
ZRAM 的关键特点:无需磁盘 I/O,适合嵌入式/无盘设备;压缩比通常为 2:1 ~ 3:1;CPU 消耗增加;Linux 5.0 开始支持多压缩算法动态切换。
5.3 ZSwap —— 交换缓存层
ZSwap 是介于磁盘 swap 与内存之间的压缩写回缓存。当 swap 写出时先压缩存入内存池,延迟到内存压力增大时才刷回磁盘。
与 ZRAM 的关键区别:ZRAM 是独立 swap 设备(/dev/zramX),不需要磁盘 swap;ZSwap 是磁盘 swap 的缓存层,需要底层磁盘后端,但缓存满载后可通过回写磁盘释放,不会 OOM。
六、OOM 杀手的最后防线
当直接回收和 Kswapd 都无法挤出足够的内存时,内核最后的防线是 OOM Killer(Out Of Memory Killer)。它选择一个进程终止以释放大量内存。
6.1 OOM 的评分机制
OOM Killer 通过 oom_badness() 为每个进程计算分数(0~1000):
// 分数 = 总内存占用比例 × 1000 × (1 + oom_score_adj / 1000)
// 总内存 = RSS + swap 使用 + page table 占用 + 内核栈
// oom_score_adj 调整:
// OOM_SCORE_ADJ_MIN (-1000):永远不会被 OOM 杀掉
// -700:重点保护
// 0:默认值
// OOM_SCORE_ADJ_MAX (+1000):优先被杀
6.2 保护关键进程
$ echo -1000 > /proc/$(pidof redis-server)/oom_score_adj
# systemd 服务可通过 OOMPolicy= 或 ExecStartPre= 设置
# cgroup v2 使用 memory.oom.group
七、工程调优:从理论到实践
7.1 内核参数调优
vm.swappiness = 20
# vm.dirty_ratio —— 脏页占RAM比例阻塞阈值(默认20%)
vm.dirty_ratio = 30
# vm.dirty_background_ratio —— 后台刷盘阈值
vm.dirty_background_ratio = 10
# vm.min_free_kbytes —— 保留最小空闲内存(64GB可设1GB)
vm.min_free_kbytes = 1048576
# vm.vfs_cache_pressure —— 文件缓存回收权重(默认100)
vm.vfs_cache_pressure = 200
# vm.zone_reclaim_mode —— NUMA节点本地回收(0默认,1优先本地)
vm.zone_reclaim_mode = 1
7.2 监控指标体系
$ cat /proc/meminfo | grep -E "MemFree|Cached|AnonPages|Shmem|SwapTotal|SwapFree"
# /proc/vmstat —— 回收统计
$ grep -E "pgsteal|pgscan|compact|oom" /proc/vmstat
# /proc/buddyinfo —— 伙伴系统内存碎片
$ cat /proc/buddyinfo
# /proc/<pid>/smaps_rollup —— 进程级内存占用
$ cat /proc/<pid>/smaps_rollup
# /proc/zoneinfo —— 节点级内存区域详情含水位线
$ grep -E "Node|zone|min|low|high" /proc/zoneinfo
7.3 常见内存回收相关性能问题
问题 1:直接回收频繁导致应用延迟抖动
- 症状:pgscan_direct 快速增长,P99 延迟飙升
- 原因:页面分配速率超过 Kswapd 回收速率
- 对策:提前唤醒 Kswapd(降低 low 水位),或优化应用内存使用
问题 2:Swap 频繁导致 CPU 饱和(swap storm)
- 症状:磁盘 I/O 占用 100%,CPU iowait 高,swap 频繁 in/out
- 原因:swappiness 过高或内存过小
- 对策:降低 swappiness,检查并终止异常进程,增加 ZRAM
问题 3:OOM 误杀关键进程
- 症状:数据库、MQ 等核心服务突然终止
- 原因:OOM Killer 评分偏差或未设置 oom_score_adj
- 对策:使用 systemd OOMPolicy 或 cgroup memory.max + 优先级保护
7.4 cgroup v2 内存控制
$ echo "256M" > /sys/fs/cgroup/myapp/memory.min
$ echo "384M" > /sys/fs/cgroup/myapp/memory.low
$ echo "448M" > /sys/fs/cgroup/myapp/memory.high
$ cat /sys/fs/cgroup/myapp/memory.current
$ cat /sys/fs/cgroup/myapp/memory.events # oom / oom_kill / high / max / low
八、总结与演进方向
页面回收是 Linux 内核内存管理中不可或缺的一环。从 Kswapd 的异步回收,到直接回收的临时干预,再到 OOM Kill 的最后防线,内核构建了一套多层次的内存保障体系。
关键技术演进:
- MGLRU(Multi-Gen LRU):Linux 6.1 引入实验性支持,替代传统 Active/Inactive 二元分类,通过多代年龄模型更精准识别冷热页面
- DAMON(Data Access Monitor):基于硬件访问位的精确采样,识别真正的热页面和冷页面
- PSI(Pressure Stall Information):/proc/pressure/memory 提供更细粒度的内存压力指标
- MGLRU + DAMON 联合使用:未来趋势是利用 DAMON 的访问频率数据驱动 MGLRU 的页面老化决策
理解 Kswapd 和页面回收机制,对于诊断和解决生产环境中的内存性能问题至关重要。建议运维和开发者结合 Buddy System、SLUB 分配器,以及本篇文章的回收机制,形成对 Linux 内存管理全景式的理解。

发表评论 取消回复