内存回收:LRU链表、kswapd内核线程、内存压力与OOM Killer

内存回收这个话题,说白了就是系统内存不够用了怎么办。我刚开始接触内核时,总觉得内存管够,直到线上一个Java服务半夜挂了,查了半天才发现是OOM Killer干的。从那以后,我对内存回收机制就格外上心。

这一章我们聚焦四个核心点:LRU链表怎么管理页面、kswapd内核线程怎么触发回收、内存压力如何量化、以及OOM Killer什么时候出手。理解了这些,你就能看懂系统内存不足时的“自救”逻辑。

LRU链表:页面换入换出的基础

Linux用LRU(Least Recently Used)链表来跟踪内存页面的活跃程度。说白了,就是把页面按“最近是否被访问”排个队,优先回收那些长时间没被碰过的页面。

内核维护了两组LRU链表:

  • active list:活跃页面链表,存放最近被访问过的页面
  • inactive list:非活跃页面链表,存放可能被回收的页面

每个链表又分为匿名页和文件页两类。所以实际上内核维护了4条LRU链表:

链表用途回收方式
active匿名页进程堆栈、匿名映射换出到swap
inactive匿名页可回收的匿名页换出到swap
active文件页频繁访问的文件缓存回写后释放
inactive文件页不常访问的文件缓存直接释放

页面在active和inactive之间是怎么移动的?内核用了一个“两次机会”算法。页面第一次被访问时,放到inactive链表头部;如果短时间内再次被访问,就晋升到active链表。反过来,active链表里的页面如果长时间没被访问,会被降级到inactive。

关键参数:/proc/sys/vm/swappiness 控制内核倾向于回收匿名页还是文件页。取值范围0-100,默认60。数值越大,越倾向于回收匿名页(即使用swap)。

我在项目中遇到过一个问题:一台数据库服务器内存充足,但swap使用率却很高。查了半天发现是swappiness设成了100,内核拼命把匿名页往swap里赶,导致性能下降。后来调回60,问题解决。

kswapd内核线程:内存回收的“巡逻兵”

kswapd是内核的一个后台线程,每个内存节点(NUMA架构下每个NUMA节点)都有一个。它的职责是:当内存水位低于某个阈值时,异步回收页面,把水位拉回来。

内核定义了三个水位线:

  • pages_min:最低水位,低于这个值系统会直接回收
  • pages_low:低水位,kswapd开始工作
  • pages_high:高水位,kswapd停止工作

kswapd的工作流程大致如下:

  1. 检查当前空闲内存是否低于pages_low
  2. 如果是,遍历LRU链表,从inactive链表尾部开始回收页面
  3. 回收过程中,优先回收文件页(因为不需要swap I/O)
  4. 回收足够页面后,水位回到pages_high以上,kswapd休眠

查看水位:cat /proc/zoneinfo 可以查看每个内存区域的水位线和当前空闲页数。重点关注 min、low、high 三个值。

我曾经在调优一个高并发Web服务时,发现kswapd频繁唤醒,导致CPU开销飙升。用perf top一看,shrink_inactive_list占了不少CPU。后来调整了vm.min_free_kbytes,把水位线抬高,kswapd唤醒频率明显下降。

内存压力:如何量化“内存不够了”

内存压力不是一个模糊的感觉,内核用一套机制来量化它。核心指标是扫描比例和回收效率。

内核在回收页面时,会记录以下指标:

  • 扫描页数:遍历了多少个页面
  • 回收页数:成功回收了多少个页面
  • 回收效率:回收页数 / 扫描页数

如果回收效率很低(比如扫描了1000页,只回收了10页),说明内存压力很大——因为大部分页面都是活跃的,没法回收。

内核还有一个refault distance的概念。一个页面被回收后,如果很快又被访问,就会触发缺页中断重新加载。这个“很快”有多快?内核用workingset_refault来跟踪。如果频繁出现refault,说明回收策略太激进了,应该多保留一些页面。

注意:/proc/vmstat 中的 pgscan_direct 和 pgscan_kswapd 分别记录了直接回收和kswapd回收的扫描页数。如果 pgscan_direct 持续增长,说明系统经常进入直接回收路径,这是性能瓶颈的信号。

我习惯用sar -B来监控内存压力:

# 查看内存回收相关指标
sar -B 1 5

# 输出示例
pgpgin/s pgpgout/s   fault/s  majflt/s  pgfree/s pgscank/s pgscand/s pgsteal/s  %vmeff
   0.00      0.00   1234.00      0.00   5678.00    100.00      0.00     90.00   90.00

%vmeff 就是回收效率。如果这个值低于30%,说明内存压力很大,系统在“硬扛”。

OOM Killer:最后的“清道夫”

当内存压力大到一定程度,kswapd回收不过来,直接回收也失败时,内核会调用OOM Killer。它的逻辑很简单:杀掉一个进程,释放它的内存。

但杀谁?内核有一套badness评分机制:

  • 进程占用的内存越大,评分越高
  • 进程的存活时间越短,评分越高
  • root进程有豁免权(评分减半)
  • 子进程多的进程,评分会降低(因为杀掉它会影响更多进程)

评分最高的进程会被杀掉。内核会向该进程发送SIGKILL信号,强制终止。

OOM Killer触发条件:当系统尝试分配内存但失败,并且/proc/sys/vm/overcommit_memory设置为0或1时,内核会调用OOM Killer。如果设置为2(禁止overcommit),则永远不会触发OOM Killer,但内存分配会直接失败。

我曾经在线上遇到过OOM Killer误杀的情况。一个重要的监控进程因为内存泄漏被杀了,导致报警系统瘫痪。后来我们做了两件事:一是给关键进程设置oom_score_adj为负值,降低被选中的概率;二是用cgroup memory limit限制每个容器的内存上限,避免一个容器拖垮整个宿主机。

下面这张图展示了内存回收的整体流程:

内存回收流程 内存分配请求 检查空闲内存是否充足 充足 → 直接分配 不足 → 唤醒kswapd kswapd扫描LRU链表 回收是否成功? 成功 → 水位恢复 失败 → OOM Killer 说明:kswapd异步回收失败后,分配路径会进入直接回收(direct reclaim) 直接回收也失败时,最终触发OOM Killer

调优实践:如何避免OOM Killer

避免OOM Killer的关键是提前干预。我总结了几个实用方法:

  1. 设置合理的swap空间:swap不是越多越好,但完全没有swap也不行。建议物理内存的0.5-1倍。
  2. 调整vm.min_free_kbytes:这个值决定了最低水位线。默认值通常偏小,可以适当调大,给系统留出缓冲。
  3. 使用cgroup内存限制:每个容器或进程组设置内存上限,避免一个进程吃光所有内存。
  4. 监控关键指标:重点关注pgscan_direct、pgsteal_kswapd和%vmeff。

快速检查命令:

# 查看当前内存压力
grep -E "pgscan|pgsteal" /proc/vmstat

# 查看OOM Killer历史
dmesg | grep -i "killed process"

# 查看进程的oom_score
cat /proc/<pid>/oom_score

嗯,内存回收这块内容不少,但核心逻辑其实不复杂。LRU链表是基础,kswapd是执行者,内存压力是触发器,OOM Killer是最后一道防线。理解了这四者的关系,你就能在系统内存紧张时做出正确的判断和调优。