Skip to content
Go back

操作系统内存不足处理:从kswapd到OOM Killer的完整链条

一句话结论(30s)

Linux 内存不足时走的是一条「层层兜底」的递进链条。因为缺页中断分配不到内存会依次触发「watermark 检测 → kswapd 后台回收 → direct reclaim 同步回收 → OOM Killer 杀进程」,所以理解这条链是调优内存敏感型应用(数据库/缓存/Java 堆)的必备基础——本质是后台 vs 同步、文件页 vs 匿名页、公平杀进程 vs 保护关键服务的三重权衡。

核心原理(2min)

底层深入(5-10min)

内存分配的完整路径

当用户进程调用 mallocmmap 申请内存时,实际上走的是一条”虚拟内存→物理内存→回收→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

缺页中断本身在上一篇文章中已详细分析。在分配内存的语境下,需要理解几个关键分支:

后台回收: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 的转换逻辑

这种”二次机会”设计类似于时钟算法的思想,比简单的 FIFO 或 LRU 更准确地识别真正活跃的工作集。

kswapd 的扫描过程

kswapd 调用 shrink_nodeshrink_lruvec,其主要决策逻辑:

第一步:确定扫描压力

比较各 LRU 链表的大小,决定对匿名页和文件页分别扫描多少。通过 swappiness 参数(默认 60)控制匿名页与文件页的回收比例:

第二步:回收文件页

对于 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 调整

调整因素包括:

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 等关键进程就是这么设的。


Share this post on:

Previous Post
死锁的四个条件与银行家算法
Next Post
堆和栈的区别——不只是"一个快一个慢