一句话结论(30s)
Linux 内存不足时走的是一条「层层兜底」的递进链条。因为缺页中断分配不到内存会依次触发「watermark 检测 → kswapd 后台回收 → direct reclaim 同步回收 → OOM Killer 杀进程」,所以理解这条链是调优内存敏感型应用(数据库/缓存/Java 堆)的必备基础——本质是后台 vs 同步、文件页 vs 匿名页、公平杀进程 vs 保护关键服务的三重权衡。
核心原理(2min)
- watermark 三线:high / low / min;低于 low 唤醒 kswapd(后台),低于 min 触发 direct reclaim(同步)
- kswapd:每个 NUMA 节点一个内核线程,扫描五条 LRU 链表(匿名/文件 × active/inactive + unevictable)
- 回收优先级:clean 文件页(直接丢弃)> dirty 文件页(回写磁盘)> 匿名页(写 Swap)
- swappiness:控制匿名页与文件页回收比例,默认 60
- direct reclaim:分配进程被阻塞、以自身名义回收 → 是「内存压力下延迟毛刺」的根源
- OOM Killer:按
oom_score(内存占比 ×oom_score_adj调整)选牺牲进程,SIGKILL不可捕获
底层深入(5-10min)
内存分配的完整路径
当用户进程调用 malloc 或 mmap 申请内存时,实际上走的是一条”虚拟内存→物理内存→回收→OOM”的递进路径:
malloc → brk/mmap 扩展 VMA → 未实际分配物理页
实际访问时触发缺页中断
→ page_fault_handler → do_page_fault → handle_mm_fault
→ 分配物理页框
→ 如果 ZONE_NORMAL/ZONE_DMA32 有空闲页:直接分配
→ 如果空闲内存低于 low watermark:唤醒 kswapd(后台回收)
→ 如果空闲内存低于 min watermark:内核自己执行 direct reclaim(直接回收)
→ 如果回收不到足够页面:调用 OOM Killer 杀掉进程释放内存
关键概念——watermark:Linux 为每个内存区域(Zone)设置三个水位线:high、low、min。空闲内存低于 low 时触发后台回收,低于 min 时触发直接回收。
思考:为什么要设三个水位线,而不是一个”临界线”?因为内存回收有轻重缓急,需要”预警”和”硬闸”分开。低于 low 只是”黄灯”——让后台 kswapd 异步慢慢回收,前台进程不受影响;低于 min 才是”红灯”——空闲页真的不够用了,分配进程必须亲自上阵同步回收。多一条 middle 水位,就能把”尽早预防”和”最后兜底”错开,避免内存一紧张就立刻拖慢正在运行的程序。
缺页中断 → Page Fault Handler
缺页中断本身在上一篇文章中已详细分析。在分配内存的语境下,需要理解几个关键分支:
- 匿名页缺页(
do_anonymous_page):进程的堆、栈、mmap 匿名映射引发的缺页。内核首先检查是否有空闲物理页。没有的话进入回收流程 - 文件页缺页(
do_fault):访问 mmap 映射的文件内容引发的缺页。如果不是写操作,可以将页缓存中的干净页直接映射(不需要从磁盘读);如果是写操作,需要复制一份作为进程私有页 - 写时拷贝(
do_wp_page):fork 产生的子进程首次写入共享页时触发
后台回收:kswapd
kswapd 是每个 NUMA 节点上一个独立的内核线程([kswapd0]),在空闲内存低于 low watermark 时被唤醒。它的工作流程:
LRU 链表结构
Linux 为每个内存区域维护五条 LRU 链表:
匿名页: LRU_INACTIVE_ANON → LRU_ACTIVE_ANON
文件页: LRU_INACTIVE_FILE → LRU_ACTIVE_FILE
不可回收页: LRU_UNEVICTABLE(mlock、ramfs 等)
Active 与 Inactive 的转换逻辑:
- 页面首次加入 LRU 时放入 inactive 链表
- 如果 inactive 链表中的页被再次访问(通过页表 Accessed 位判断),提升到 active 链表
- active 链表中的页如果长时间未被访问,降级回 inactive 链表
- 回收时优先从 inactive 链表中选择页面
这种”二次机会”设计类似于时钟算法的思想,比简单的 FIFO 或 LRU 更准确地识别真正活跃的工作集。
kswapd 的扫描过程
kswapd 调用 shrink_node → shrink_lruvec,其主要决策逻辑:
第一步:确定扫描压力
比较各 LRU 链表的大小,决定对匿名页和文件页分别扫描多少。通过 swappiness 参数(默认 60)控制匿名页与文件页的回收比例:
- swappiness 越大,越倾向于回收匿名页(需要 Swap)
- swappiness = 0:只回收文件页,不回收匿名页(除非系统内存极度紧张)
第二步:回收文件页
对于 clean 文件页(未被修改):直接丢弃——内容与磁盘上一致,需要时可以再读回来。这是回收效率最高的类型。
对于 dirty 文件页(被修改过):必须先回写到磁盘(调用 writepage),然后才能回收。页缓存回写由 pdflush 线程群完成,kswapd 会等待或辅助回写。
第三步:回收匿名页
匿名页(堆、栈、匿名 mmap)没有对应的磁盘文件,回收的唯一出路是 Swap。kswapd 将匿名页的内容写入 Swap 分区/文件,然后释放物理页。当进程再次访问该页时,触发 Swap In 从磁盘读回。
为什么回收匿名页代价更高
匿名页的回收需要 Swap I/O(写磁盘),文件页如果干净则只需丢弃。这就是为什么系统在内存紧张时天然更倾向于回收文件页——速度快且不需要额外的磁盘空间。
思考:为什么优先回收文件页而不是匿名页?因为两者的”退路”成本不同。文件页有磁盘备份:干净的页直接丢弃、脏的写回磁盘即可,需要时还能再读回来;匿名页(堆、栈)没有对应文件,唯一退路是写 Swap,必然产生磁盘 I/O。内核贪便宜,先挑”成本最低”的回收——这就是干净文件页总是最先被牺牲的原因。
直接回收:direct reclaim
当进程触发缺页中断、但空闲内存已经低于 min watermark 时,内核不让进程继续执行,而是在当前进程的上下文中直接调用回收逻辑(__alloc_pages_direct_reclaim)。
这意味着一件事:分配内存的进程被阻塞,内核以当前进程的名义去回收内存。如果回收过程写磁盘(回写脏文件页或 Swap Out),这个进程的延迟会飙升。在极端情况下,一个简单的 malloc + 首次访问可能花费数百毫秒——这就是”内存压力下的延迟毛刺”的根本原因。
思考:为什么 direct reclaim 是”延迟毛刺”的元凶?因为它是同步的——分配进程自己卡在缺页中断里,内核借它的名义去回收,回收若碰磁盘(回写/Swap),这整个磁盘延迟全算在这一个进程头上。kswapd 是后台线程、不挡路,direct reclaim 却是”谁要内存谁挨刀”。这就是内存一紧张,某些请求延迟突然飙到几百毫秒的根源。
OOM Killer
当 kswapd 和 direct reclaim 都无法回收足够的内存时,内核面临一个残酷的选择:必须杀掉某个进程来释放内存。这个决定由 out_of_memory 函数中的 OOM Killer 做出。
选择牺牲进程的策略(oom_badness 函数)
OOM Killer 不是随机杀进程的。每个进程有一个 oom_score:
oom_score = 进程占用的物理内存比例 × 1000 × oom_score_adj 调整
调整因素包括:
/proc/PID/oom_score_adj:用户可以手动设置(-1000 到 1000)。设为 -1000 表示永远不被 OOM 杀掉(如 sshd、systemd 等关键进程设置为 -1000)- 是否 root 进程:root 进程默认略微降低 score(因为通常更重要)
- 子进程内存:计算进程及其所有子进程的内存总和,避免杀掉一个轻量父进程而遗漏其大量消耗内存的子进程
- 运行时间:短时间消耗大量内存的进程(刚启动就申请巨量内存)比长时间稳定运行的进程更容易被选中
OOM 发生后的处理
选中的进程收到 SIGKILL(不可捕获、不可阻塞),进程立即终止。内核释放该进程的所有物理页面、文件描述符、内核对象。然后触发 OOM 的分配请求重试,通常此时能成功分配。
文件页与匿名页回收的完整对比
| 维度 | 文件页 | 匿名页 |
|---|---|---|
| 备份存储 | 磁盘上的文件 | Swap 分区/文件 |
| 干净页回收 | 直接丢弃(零 I/O) | 不存在干净匿名页(总是需要 Swap) |
| 脏页回收 | 回写磁盘 → 丢弃 | 写入 Swap → 丢弃 |
| 回收效率 | 高 | 低 |
| 不可回收情况 | mlock 锁定 | mlock 锁定 |
| swappiness 影响 | swappiness 低时优先回收 | swappiness 高时增加回收比例 |
总结
Linux 的内存回收是一条优雅的递进链条:缺页中断触发分配 → watermark 检测触发后台 kswapd → 严重不足时触发同步 direct reclaim → 最后手段 OOM Killer。这条链条中的每个环节都有精妙的权衡:后台 vs 同步(减少延迟毛刺 vs 保证分配不失败)、文件页 vs 匿名页(I/O 代价不同)、公平杀进程 vs 保护关键服务(oom_score_adj 机制)。理解这条链条,是做好内存敏感型应用(数据库、缓存、Java 堆管理)调优的必备基础。
章末提问
追问 1:内存紧张时为什么优先回收文件页而不是匿名页?
回答思路:结论先行——因为文件页的回收成本更低。文件页有磁盘备份:干净页直接丢弃(零 I/O)、脏页回写磁盘即可,需要时还能读回来;匿名页(堆、栈)没有对应文件,唯一退路是写 Swap,必然产生磁盘 I/O。内核按”成本最低优先”原则,所以干净文件页最先被牺牲。
追问 2:kswapd 和 direct reclaim 有什么区别?
回答思路:结论先行——一个是后台异步、一个是前台同步。kswapd 是每 NUMA 节点一个的内核线程,空闲内存低于 low watermark 时被唤醒,在后台慢慢回收,不阻塞业务进程;direct reclaim 是空闲内存低于 min watermark 时,分配进程被阻塞、以自身名义同步回收,回收若碰磁盘(回写/Swap),延迟全算在这个进程头上,是”内存压力下延迟毛刺”的根源。
追问 3:OOM Killer 怎么选牺牲进程?怎么保护关键进程不被杀?
回答思路:结论先行——按 oom_score 从高到低选,关键进程通过把 oom_score_adj 设为 -1000 来豁免。oom_score = 进程物理内存占比 × 1000 × oom_score_adj 调整,还会考虑 root 身份、子进程内存总和、运行时间(短时间狂吃内存的更优先被杀)。保护方式:/proc/PID/oom_score_adj 设为 -1000 表示永远不被 OOM 杀掉,sshd、systemd 等关键进程就是这么设的。