内存回收: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的工作流程大致如下:
- 检查当前空闲内存是否低于pages_low
- 如果是,遍历LRU链表,从inactive链表尾部开始回收页面
- 回收过程中,优先回收文件页(因为不需要swap I/O)
- 回收足够页面后,水位回到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限制每个容器的内存上限,避免一个容器拖垮整个宿主机。
下面这张图展示了内存回收的整体流程:
调优实践:如何避免OOM Killer
避免OOM Killer的关键是提前干预。我总结了几个实用方法:
- 设置合理的swap空间:swap不是越多越好,但完全没有swap也不行。建议物理内存的0.5-1倍。
- 调整vm.min_free_kbytes:这个值决定了最低水位线。默认值通常偏小,可以适当调大,给系统留出缓冲。
- 使用cgroup内存限制:每个容器或进程组设置内存上限,避免一个进程吃光所有内存。
- 监控关键指标:重点关注
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是最后一道防线。理解了这四者的关系,你就能在系统内存紧张时做出正确的判断和调优。