当你在日志里看到 Out of memory: Killed process 1234 (java) 时,内核其实已经走完了一条相当漫长的“求生链路”:分配失败、直接回收、唤醒 kswapd、内存规整、再次重试,直到最后不得不选择一个进程作为牺牲品。本文就沿着这条链路,把 Linux OOM 从触发到 kill、从 memcg OOM 到系统 panic 的整体流程串起来。

在前面几篇文章里,我们已经拆过内存回收的核心路径:shrink_lruvec()shrink_page_list()pageout()try_to_unmap()__remove_mapping()……这些机制的目标都很明确:尽量从系统中回收出足够的物理页,让下一次分配能够成功

但如果这些努力全部失败呢?

如果直接回收回不来,kswapd 也来不及救场,slab 能缩的已经缩了,LRU 上能扫的页也扫过了,内存规整也没能拼出足够的连续页,那么内核就会进入最后阶段:OOM(Out of Memory)处理

OOM 不是普通回收路径的自然延续,而是回收失败后的兜底策略。它的核心问题不再是“还能不能温和地腾出一点内存”,而是更残酷的:

到底要不要杀进程?杀谁?如果连杀进程都不可信,是否直接让系统 panic?


一、OOM 的定位:回收走不通后的最后决策

首先要明确一个边界:OOM killer 不是内存回收算法本身

内存回收负责“尽量不伤人地释放页面”,比如:

  1. 扫描 LRU 链表;

  2. 回收 clean file page;

  3. 回写 dirty page;

  4. 将匿名页写入 swap;

  5. 解除页表映射;

  6. 释放 slab cache;

  7. 做内存规整 compaction;

  8. 唤醒 kswapd 在后台继续回收。

这些都还属于“正常自救”。只有当分配路径发现这些手段都不足以满足当前分配请求时,才会进入 OOM 决策。

典型入口在页分配慢路径中:

__alloc_pages_slowpath()
    ...
    __alloc_pages_may_oom()
        out_of_memory()

也就是说,OOM 不是内核一看到空闲内存低就立刻触发,而是在分配路径已经多次尝试、回收和重试之后,才被迫启动的最后机制。


二、OOM 的主要触发场景

OOM 看起来都是“内存不够”,但在内核里,不同来源的 OOM 约束范围并不一样。最常见的可以分成三类。

1. 全局分配失败:系统级 OOM

这是最典型的 OOM 场景。

某个进程或内核路径调用 alloc_pages() 分配物理页,页分配器进入 slowpath,尝试直接回收、唤醒 kswapd、做 compaction、降低水位线重试等一系列动作。如果最终仍然失败,就可能通过 __alloc_pages_may_oom() 进入:

out_of_memory(&oc)

这里的 OOM 是全局意义上的:系统层面的可用内存已经无法满足当前请求。后续 victim 选择也会在全局范围内寻找合适的可杀进程,但仍会受到 cpuset、mempolicy、nodemask 等约束影响。

2. MemCG OOM:cgroup 范围内的 OOM

第二类是 memcg OOM。

比如某个容器或 cgroup 设置了 memory.max,组内进程的内存使用超过了这个上限。此时系统整体可能还有大量空闲内存,但对这个 cgroup 来说,它已经“没钱了”。

这种情况下,内核会进入 memcg OOM 路径:

mem_cgroup_out_of_memory()
    out_of_memory(&oc)

注意,这里虽然也调用 out_of_memory(),但传入的 oom_control 中带着 memcg 上下文。也就是说,memcg OOM 使用的是同一套 OOM 决策框架,但 victim 的选择范围会被限制在对应 memcg 及其相关层级内

它不是“扩散到全系统随便杀其他 cgroup 的进程”。如果配置了 memory.oom.group,内核还可能按 cgroup 粒度杀掉一组进程,而不是只杀单个 task。

3. Page Fault 场景:缺页路径感知 OOM

第三类和缺页异常有关。

用户进程访问某个尚未建立映射的虚拟地址时,内核会进入 page fault handler,尝试为它分配物理页并建立页表。如果底层分配失败,缺页路径可能看到 VM_FAULT_OOM

不过这里要稍微小心:在现代内核中,pagefault_out_of_memory() 主要负责同步和处理 memcg OOM 状态;真正的全局 OOM 通常还是由页分配慢路径触发。也就是说,缺页路径可以“感知” OOM,但全局 OOM killer 的主要入口仍然在 allocator slowpath。

这点很重要,否则容易误以为所有 page fault 分配失败都会直接调用全局 out_of_memory()


三、进入 out_of_memory():先判断是否必须 panic

当内核进入 out_of_memory() 时,并不是马上开始杀进程。它首先要判断:这个 OOM 场景是否应该直接让系统 panic。

关键配置是:

/proc/sys/vm/panic_on_oom

它大致有三种含义:

  1. 0:默认行为,尽量通过 OOM killer 杀进程自救;

  2. 1:对系统级 OOM 可以 panic,但对 memcg、cpuset、mempolicy 等受限 OOM 场景不一定 panic;

  3. 2:更强硬,遇到 OOM 时倾向于直接 panic。

所以 panic_on_oom=1 并不等于“任何 OOM 都立刻崩溃”。内核会结合当前 OOM 的约束类型判断,只有满足条件时才会走:

check_panic_on_oom()
    panic("Out of memory")

如果没有选择 panic,OOM killer 才会继续向下,进入 victim 选择流程。


四、特殊快速路径:当前进程已经在退出或即将被杀

在真正扫描全系统选 victim 之前,内核还会看一些特殊情况。

比如当前触发 OOM 的进程已经收到了 SIGKILL,或者正在退出路径上,那么它本身马上就会释放内存。此时内核没有必要再额外杀一个进程,而是可以把当前进程标记为 OOM victim,让它优先完成退出。

相关动作包括:

mark_oom_victim(current)
queue_oom_reaper(current)

这里的 mark_oom_victim() 很关键。被标记为 OOM victim 的任务,在退出过程中可以获得一些特殊待遇,比如更容易访问少量内存保留资源,从而避免“想退出释放内存,却连退出所需的一点内存都分配不到”的死局。

这类保留资源不建议简单理解成“每个 NUMA 节点固定 32 页 OOM Reserve”。更准确的说法是:内核会通过 OOM victim 标记和页分配器的 reserve 机制,尽量保证被选中的牺牲进程能够顺利退出并释放内存


五、选择 victim:谁最该被杀?

如果没有直接 panic,也没有现成的退出进程可以利用,内核就必须面对 OOM killer 最核心的问题:

杀谁?

这个逻辑主要由下面的函数完成:

select_bad_process()
    oom_evaluate_task()
        oom_badness()

select_bad_process() 会遍历候选进程,对每个进程计算一个 badness 分数。分数越高,越可能成为 OOM victim。

oom_badness() 的基本思路

oom_badness() 不是单纯看谁占用内存最多,而是综合几个因素:

  1. 进程占用的内存越多,分数越高;

  2. oom_score_adj 越大,越容易被杀;

  3. oom_score_adj = -1000 的进程通常被视为不可杀;

  4. 内核线程、不可回收或不合适的任务会被跳过;

  5. 在 memcg OOM 中,候选范围会受 memcg 限制。

用户最常接触的是:

/proc/<pid>/oom_score
/proc/<pid>/oom_score_adj

oom_score 是内核计算出的当前分数,oom_score_adj 是用户或系统服务可以设置的调节值,范围是 -10001000

例如:

echo -1000 > /proc/<pid>/oom_score_adj

可以尽量保护某个关键进程不被 OOM killer 选中。

而:

echo 1000 > /proc/<pid>/oom_score_adj

则表示这个进程在 OOM 时可以优先牺牲。

很多服务管理器、容器运行时、Android low memory 策略,都会围绕这个接口做进程优先级管理。


六、执行 kill:oom_kill_process() 做了什么?

一旦 victim 被选中,内核就会进入:

oom_kill_process()

这一步不是简单地发一个 SIGKILL 就结束。它通常会做几类事情。

1. 打印 OOM 信息

内核会打印 OOM 上下文,包括当前触发者、gfp mask、order、nodemask、cpuset、memcg 信息等,并通过 dump_header() 输出系统内存快照。

这些日志是排查 OOM 的黄金线索。

2. 标记 victim

被选中的进程会被标记为 OOM victim:

mark_oom_victim(victim)

这个标记的意义是告诉系统:它就是这次 OOM 的牺牲对象,请尽快让它退出、释放内存,并在必要时给它一点“退出通道”。

3. 发送 SIGKILL

随后内核会向 victim 发送不可捕获的 kill 信号:

do_send_sig_info(SIGKILL, ...)

日志里常见的这一行就来自这里附近:

Out of memory: Killed process 1234 (java) total-vm:8388608kB, anon-rss:1048576kB, file-rss:0kB, shmem-rss:0kB

其中几个字段很有用:

  1. total-vm:进程总虚拟地址空间;

  2. anon-rss:匿名页常驻内存;

  3. file-rss:文件页常驻内存;

  4. shmem-rss:共享内存常驻内存。

真正分析问题时,anon-rss 往往比 total-vm 更值得关注,因为虚拟地址空间大不代表实际占用了同样多的物理内存。

4. 处理线程组和 memcg group kill

如果 victim 是多线程进程,内核会确保相关线程正确退出。

如果是 memcg OOM,并且开启了 memory.oom.group,内核可能会将整个 cgroup 作为牺牲对象处理,而不是只杀其中一个进程。这对容器场景很重要:与其只杀容器里的一个进程导致服务半死不活,不如按组终止,让上层编排系统重新拉起。


七、oom_reaper:不是“不杀人的替代方案”,而是 victim 的异步回收器

很多 OOM 文章会把 oom_reaper 写成一个“杀进程之前先试试解除映射”的温和机制,但这个说法容易误导。

更准确地说:

oom_reaper 是 OOM victim 的异步内存回收加速器。

它的目标不是随便找一个进程剥夺内存,也不是替代 kill。它主要服务于已经被选中、已经被标记,或者正在退出的 OOM victim。

典型链路是:

oom_kill_process()
    mark_oom_victim()
    do_send_sig_info(SIGKILL, ...)
    queue_oom_reaper()

oom_reaper()
    oom_reap_task()
        oom_reap_task_mm()
            __oom_reap_task_mm()

为什么需要它?

因为进程收到 SIGKILL 后,并不会瞬间释放所有内存。它还要调度运行、进入退出路径、释放 mm、清理页表。如果 victim 卡在某个锁上,或者退出过程迟迟无法推进,系统可能会继续处在 OOM 状态,甚至反复触发 OOM killer。

oom_reaper 的作用就是提前介入,尽快解除 victim 的部分用户态映射,让相关匿名页可以更快回到系统中。

它主要处理的是适合异步回收的 VMA,例如匿名私有映射;对一些不安全或不能随便拆的映射,会选择跳过。这样做的目标是:尽快从 victim 身上榨出可释放内存,同时避免卡在复杂的退出路径上

所以,oom_reaper 不是“先不杀人试试看”的第一道防线,而是“既然已经选了牺牲者,就尽快让它释放内存”的加速器。


八、全局 OOM 的主流程图

把前面的内容串起来,一个典型的全局 OOM 大致是这样:

这个图里最重要的点是:OOM killer 的核心主线是 panic 判断 → victim 选择 → kill → oom_reaper 加速释放,而不是 panic、reaper、kill 三个完全并行的分叉。


九、MemCG OOM 的主流程图

memcg OOM 和全局 OOM 共享很多代码,但约束范围不同:

这解释了一个常见现象:容器里进程被 OOM kill,并不代表宿主机全局内存真的耗尽了。它可能只是这个容器自己的 memcg 配额耗尽。

排查这类问题时,不能只看 free -h,还要看 cgroup 的内存限制和事件计数,例如:

memory.current
memory.max
memory.events
memory.oom.group

十、如何从日志判断一次 OOM?

一次典型 OOM 日志通常包含几类信息。

1. 触发上下文

比如:

invoked oom-killer: gfp_mask=..., order=..., oom_score_adj=...

这里可以看出是谁触发了 OOM、当时想分配什么类型的内存、分配阶数是多少。

如果 order 很大,问题可能不只是“总内存不足”,还可能是连续物理页不足,也就是碎片化严重。

2. 系统内存快照

dump_header() 会打印大量内存状态,包括 active/inactive、file/anon、slab、swap、各 zone 水位线等。

这里要重点看:

  1. free page 是否真的很低;

  2. file cache 是否还有可回收空间;

  3. swap 是否耗尽;

  4. slab 是否异常膨胀;

  5. 某个 zone 是否低于水位线;

  6. 是否是高阶分配失败。

3. 被杀进程

最后会看到:

Out of memory: Killed process 1234 (java) ...

这行告诉你 victim 是谁,但它不一定等于“罪魁祸首”。OOM killer 选择的是当时最适合杀的进程,而不是一定最早泄漏内存的进程。

这也是排查 OOM 时最容易踩坑的地方:被杀者不一定是肇事者


十一、总结:OOM 是内核最后的秩序维护

alloc_pages() 分配失败,到最终打印 Out of memory: Killed process ...,中间并不是一条简单的“内存不够 → 杀进程”直线。

更完整的链路是:

  1. 页分配进入 slowpath;

  2. 尝试直接回收、slab 回收、kswapd、compaction;

  3. 多次重试仍然失败;

  4. 进入 out_of_memory()

  5. 判断是否需要 panic;

  6. 如果当前进程已经在退出,尽量让它完成释放;

  7. 否则通过 oom_badness() 选择 victim;

  8. oom_kill_process() 标记并杀死 victim;

  9. oom_reaper 异步解除 victim 映射,加快内存释放;

  10. 系统重新获得可用内存,分配路径得以继续。

OOM killer 的本质不是“暴力杀进程”,而是在系统已经没有温和选择时,尽量用一个最小代价维持整体可用性。

它像内存管理系统最后的执法者:平时不出现,一旦出现,说明前面的回收、防抖、规整、限额机制都已经顶不住了。

接下去的OOM系列文章,我们可以继续深入拆解三个关键细节:

  1. out_of_memory() 的源码主线;

  2. oom_badness() 的打分细节;

  3. oom_reaper 如何安全地异步拆除 victim 的页表映射。