内存回收机制:LRU、水位与 OOM

内存回收是 Linux 内核在内存紧张时维持系统可用性的核心机制。本文从 LRU 四条链表与 lruvec 讲起,剖析页在 active 与 inactive 之间的迁移规则与 refault 距离估算,详解多代 LRU(MGLRU)的 generation 模型与调优参数,深入 kswapd 三水位、直接回收慢路径与分配标志,覆盖 swap、zswap、zram 三种交换后端对比,最后给出 PSI 压力指标、cgroup 内存回收与 OOM 排查清单。

内存是 Linux 中最不能容忍浪费的资源。内核把空闲内存用于页缓存(page cache)来加速 I/O,把匿名页(anonymous page)驻留在物理内存中避免换出,但一旦可回收内存逼近水位线,内核就必须主动「回收」一部分页面,把空间让给即将到来的分配请求。这套机制就是内存回收(memory reclaim),它决定了系统在压力下是平滑降速还是突然 OOM。

本文从最底层的 LRU 四条链表与 lruvec 讲起,解释页如何在 active 与 inactive 之间迁移、refault 距离如何估算工作集;随后剖析多代 LRU(MGLRU)的 generation 模型、kswapd 三水位与 watermark_scale_factor 调优、直接回收慢路径 __alloc_pages_slowpath 与 ALLOC_* 标志;再对比 swap、zswap、zram 三种交换后端,讲解 PSI 内存压力指标、cgroup 内存回收与 OOM 选择器,最后给出 /proc/meminfo、/proc/vmstat 与 vmstat 的实战排查清单。


一、LRU 链表与 lruvec

内存回收的第一原则是「回收最不可能被再次访问的页面」。内核用一个近似 LRU(Least Recently Used)结构来排序页面的「热度」,所有可回收页都被组织在四条链表上。

1.1 为什么需要回收

内核把物理内存分为两类主要消费者:

  • 页缓存:文件内容缓存,page cache,属于 file-backed 页,干净页可直接丢弃,脏页需先回写
  • 匿名页:进程堆栈、malloc 得到的内存,属于 anon 页,必须换出到 swap 才能回收

当 MemFree 低于某个水位时,分配路径会触发回收;如果回收速度跟不上分配速度,系统进入直接回收(direct reclaim),分配者自己下场扫描页面,延迟飙升。极端情况下触发 OOM killer。

1.2 四条基础 LRU 链表

每个 lruvec 维护五条链表(LRU_UNEVICTABLE 用于不可回收页):

链表类型回收方式
active_anon活跃匿名页换出到 swap
inactive_anon非活跃匿名页换出到 swap
active_file活跃文件页丢弃或回写
inactive_file非活跃文件页直接丢弃或回写
unevictable不可回收页锁定页,如 mlock

新分配的页从链表头部加入,回收器从尾部(LRU 尾)取页,越靠近尾部表示越久未被访问。

1.3 lruvec 与 memcg

lruvec 是「每个 zone 与每个 memory cgroup 组合」的 LRU 容器。全局有 node_lruvec,每个 memcg 在每个 NUMA 节点上也有自己的 lruvec:

struct lruvec {
    struct list_head        lists[NR_LRU_LISTS];
    struct zone_reclaim_stat reclaim_stat;
    atomic_long_t           anon_cost;
    atomic_long_t           file_cost;
    ...
};

cgroup 场景下,回收时先扫描目标 memcg 的 lruvec,这保证了一个 cgroup 的内存压力不会拖垮其他 cgroup。

1.4 页在链表间的迁移

页的「活跃度」不是静态的,内核用两个关键函数调整:

  • mark_page_accessed():页被访问(如 page cache 命中)时调用,若页在 inactive 链表,会尝试提升到 active 链表
  • page_referenced():回收器扫描时检查页的访问位(PTE 的 _PAGE_ACCESSED),若被引用则把页从 inactive 升到 active

内核用「双时钟」策略平衡:active 链表过大时,会把一部分页降级到 inactive;只有 inactive 链表尾部的页才真正被回收。这样热点页不会被误杀。

1.5 refault 距离与工作集估算

当页缓存被回收后又被重新读入,称为一次 refault。内核记录「页被回收时的时间戳」与「重新读入时的时间戳」,差值就是 refault 距离(refault distance):

  • refault 距离小于 active 链表长度:说明该页本应被保留在工作集中,内核会把它直接放到 active 链表顶部
  • refault 距离大于工作集:说明该页确实冷,放回 inactive

/proc/vmstat 中的 workingset_refault、workingset_activate、workingset_nodereclaim 就对应这套统计。它是判断「工作集是否大于可用内存」的关键信号。


二、多代 LRU 机制与 MGLRU

Linux 6.1 引入的 MGLRU(Multi-Gen LRU) 是对传统双链表 LRU 的重写,Google 的 Yu Zhao 主导,旨在解决大内存机器上 LRU 扫描成本过高的问题。

2.1 传统双链表 LRU 的缺陷

传统 LRU 只有 active 与 inactive 两级,问题在于:

  1. 扫描成本与内存规模成正比:页越多,找到足够冷页需要扫描的页越多
  2. 缺乏「年龄」概念:无法区分「刚变冷」与「冷了很久」的页
  3. 抖动:工作集略大于内存时,页面反复换入换出

2.2 generation 模型

MGLRU 把页分为多个 generation(代),默认 MAX_NR_GENS = 4。最年轻的代是 gen 0,最老的是 gen 3:

  • 新分配的页进入最年轻的代
  • 回收器周期性「老化」:每次 aging 时,代号递增,最老的代成为回收候选
  • 页被访问时会被「提升」到更年轻的代

回收时优先扫描最老的代,只有最老代中未被引用的页才会被回收。这样一次扫描就能确定「哪些页最冷」,无需反复遍历整个 LRU。

2.3 /sys/kernel/mm/lru_gen/ 参数

# 查看 MGLRU 状态
ls /sys/kernel/mm/lru_gen/

# enabled:1 表示已启用
cat /sys/kernel/mm/lru_gen/enabled

# min_ttl_ms:页的最小存活时间(毫秒),默认 1000
# 提高它可以减少「刚加载就被回收」的抖动
cat /sys/kernel/mm/lru_gen/min_ttl_ms

# 每个 NUMA 节点的代统计
cat /sys/kernel/mm/lru_gen/gen_kdamon 2>/dev/null || true

min_ttl_ms 是最重要的调优旋钮。它规定页至少存活多久才可能被回收,能有效缓解工作集略大于内存时的抖动。设为 0 表示不限制。

2.4 启用与调优

MGLRU 通过内核配置 CONFIG_LRU_GEN 编译,运行时可用启动参数 lru_gen.enabled=1 或 sysfs 开关:

# 运行时启用(部分发行版支持)
echo 1 > /sys/kernel/mm/lru_gen/enabled

# 查看内核是否编译了 MGLRU
grep -i lru_gen /boot/config-$(uname -r) 2>/dev/null || \
  zgrep -i lru_gen /proc/config.gz 2>/dev/null || true

# 查看是否已在运行
dmesg | grep -i "multi-gen" || true

MGLRU 在大型内存机器、容器密集场景下通常能带来更低的 pgscan 与更稳定的延迟。但在小内存设备上收益有限,需实测。


三、kswapd 与内存水位

水位(watermark)是内核决定「何时开始回收」的阈值。每个 zone 有三个水位,由 min_free_kbytes 推导。

3.1 三水位 min low high

水位含义触发动作
min最低保留水位分配必须等到 min 之上,否则直接回收
low低水位唤醒 kswapd 后台回收
high高水位kswapd 回收到此水位后休眠

分配器在 get_page_from_freelist() 中检查:若空闲页低于 low,唤醒 kswapd;若低于 min,走慢路径触发直接回收。三水位之间留有间隙,避免 kswapd 频繁启停。

3.2 /proc/zoneinfo 解读

# 查看各 zone 的水位与空闲页
cat /proc/zoneinfo | grep -E "Node|zone|min|low|high|free|managed|nr_free_pages"

# 输出片段:
#   Node 0, zone   Normal
#     pages free     18320
#     min      1043
#     low      1303
#     high     1563
#     managed  262144

managed 是该 zone 的总页数,free 是当前空闲页数。若 free 长期贴近 min,说明系统处于持续压力。

3.3 watermark_scale_factor 与 watermark_boost_factor

默认水位按 min_free_kbytes 计算,在大内存机器上可能偏低。两个 sysctl 可调整:

# 水位缩放因子,单位万分之一,默认 10(即 0.1%)
# 提高它可以让内核更早开始回收,避免突发分配触发直接回收
sysctl vm.watermark_scale_factor
echo 150 > /proc/sys/vm/watermark_scale_factor

# 水位提升因子,用于吸收突发大分配(如 THP)
sysctl vm.watermark_boost_factor
echo 15000 > /proc/sys/vm/watermark_boost_factor

watermark_scale_factor 的语义是:low 水位与 min 的间距 = managed / 10000 * scale_factor。调大它相当于加大缓冲带。

3.4 kswapd 唤醒与 pgscan/pgsteal

kswapd 是每个 NUMA 节点一个的后台内核线程。它被唤醒后扫描 LRU,pgscan 记录扫描页数,pgsteal 记录成功回收页数:

# 观察 kswapd 的回收效率
grep -E "pgscan_kswapd|pgsteal_kswapd|pgscan_direct|pgsteal_direct" /proc/vmstat

# 计算回收效率(steal/scan),低于 30% 说明在扫描大量冷页
awk '/pgscan_kswapd/ {s=$2} /pgsteal_kswapd/ {t=$2} END {printf "efficiency: %.1f%%\n", t*100/s}' /proc/vmstat

若 pgscan_kswapd 远大于 pgsteal_kswapd,说明扫描了很多页却回收很少,通常是工作集过大或水位设置不当。


四、直接回收与分配慢路径

当 kswapd 来不及回收时,分配者自己进入慢路径执行直接回收。这条路径是延迟抖动的主要来源。

4.1 快速路径与 get_page_from_freelist

分配页的第一步是 get_page_from_freelist()。它遍历 zone 的 freelist,检查水位:

static struct page *
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, ...)
{
    for_each_zone_zonelist_nodemask(zone, z, ac->zonelist, ...) {
        if (!zone_watermark_fast(zone, order, mark, ...))
            continue;                 /* 水位不足,跳过该 zone */
        page = rmqueue(ac->preferred_zoneref->zone, zone, order, ...);
        if (page)
            return page;              /* 快速路径命中 */
    }
    return NULL;                      /* 进入慢路径 */
}

快速路径不触发任何回收,纯粹从 freelist 取页。只有它失败时才进入 __alloc_pages_slowpath()。

4.2 __alloc_pages_slowpath

慢路径做四件事:

  1. 唤醒 kswapd:让后台线程并行回收
  2. 重试快速路径:给 kswapd 一点时间
  3. 直接回收:__alloc_pages_direct_reclaim(),调用 try_to_free_pages()
  4. OOM:若所有尝试失败且 ALLOC_NOFAIL 未设置,触发 OOM killer
# 观察直接回收频率,非零且持续增长说明 kswapd 跟不上
grep -E "pgscan_direct|pgsteal_direct|allocstall" /proc/vmstat

4.3 ALLOC_* 标志

gfp_mask 与 ALLOC_* 标志控制回收行为:

标志含义
ALLOC_WMARK_MIN使用 min 水位(默认)
ALLOC_WMARK_LOW使用 low 水位
ALLOC_WMARK_HIGH使用 high 水位
ALLOC_NO_WATERMARKS忽略水位,用于紧急分配
ALLOC_HARDER更激进,用于高优先级分配
ALLOC_OOM允许触发 OOM
ALLOC_NOFAIL分配不得失败(如 __GFP_NOFAIL)
ALLOC_CPUSET遵守 cpuset 限制

4.4 node_reclaim 与 pgscan_direct

除了全局回收,node_reclaim() 提供 NUMA 节点本地回收:当本地节点分配失败时,先尝试回收本地节点的页,避免跨节点访问带来的延迟。

# 节点本地回收统计
grep -E "pgscan_direct|nr_slab_reclaimable" /proc/vmstat

# 查看 NUMA 命中与本地回收
numastat 2>/dev/null | head -20 || cat /sys/devices/system/node/node*/numastat 2>/dev/null | head -20

pgscan_direct 持续增长是「内存不足」最直接的证据。


五、交换空间与 zswap 和 zram

匿名页必须换出到交换设备才能回收。现代内核提供三种交换后端:传统 swap、zswap、zram。

5.1 swapfile 与 swap cache

匿名页换出后进入 swap 设备,同时在内核中保留一个 swap cache 条目。若页被再次访问,从 swap 读回:

# 创建并启用 swapfile
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# 查看 swap 使用情况
swapon --show
free -h
cat /proc/swaps

SwapCached 字段表示同时存在于内存与 swap 的页,说明这些页被换出后又读回。

5.2 swappiness

swappiness(0-200,默认 60)控制内核在回收时「偏向文件页还是匿名页」:

# 查看当前值
sysctl vm.swappiness

# 降低它,倾向保留匿名页(适合数据库)
echo 10 > /proc/sys/vm/swappiness

# 提高它,倾向换出匿名页(适合内存紧张的前端)
echo 100 > /proc/sys/vm/swappiness

swappiness 是相对权重而非绝对值。它不决定「是否换出」,只影响 file 与 anon 的回收比例。

5.3 zswap 压缩池 zpool

zswap 是一个内存中的压缩前端:匿名页换出时不直接写盘,而是先压缩存入内存池(zpool),只有池满时才回写到 swap 设备。

# 查看 zswap 状态
grep -r . /sys/module/zswap/parameters/ 2>/dev/null

# 启用 zswap(内核参数或运行时)
# 启动参数:zswap.enabled=1 zswap.compressor=lz4 zswap.max_pool_percent=20
echo 1 > /sys/module/zswap/parameters/enabled
echo lz4 > /sys/module/zswap/parameters/compressor

# 观察压缩效果
grep -E "zswap" /proc/vmstat 2>/dev/null || true

关键参数:zswap.enabled(是否启用)、zswap.compressor(压缩算法)、zswap.max_pool_percent(占用内存上限百分比)、zswap.zpool(后端分配器)。

5.4 zram

zram 创建一个压缩块设备作为 swap,数据完全驻留内存,适合无盘或嵌入式设备:

# 加载模块并创建 zram 设备
sudo modprobe zram
echo lz4 > /sys/block/zram0/comp_algorithm
echo 4G > /sys/block/zram0/disksize
sudo mkswap /dev/zram0
sudo swapon -p 100 /dev/zram0

# 查看压缩比
cat /sys/block/zram0/mm_stat

5.5 三者对比

特性swap 设备zswapzram
存储位置磁盘/SSD内存池 + 后备 swap内存
压缩否是是
需要后备 swap是是否
典型延迟高(I/O)中(压缩)低
适用场景通用减少 I/O无盘/嵌入式

六、内存压力 PSI 指标解析

PSI(Pressure Stall Information)从 Linux 4.20 起提供,量化「任务因资源不足而停顿」的时间比例,是判断内存压力最准确的指标。

6.1 /proc/pressure/memory

cat /proc/pressure/memory

# 输出:
# some avg10=0.00 avg60=0.00 avg300=0.00 total=12345
# full avg10=0.00 avg60=0.00 avg300=0.00 total=6789

avg10/avg60/avg300 是最近 10s/60s/300s 的平均停顿百分比,total 是累计微秒数。

6.2 some 与 full 语义

  • some:至少有一个任务因内存不足而停顿的时间比例。只要发生回收就可能非零
  • full:所有可运行任务都因内存不足而停顿的时间比例。full 非零意味着系统真的「卡住了」

监控时 full 是更重要的告警指标,full avg10 > 5% 通常意味着系统已明显受影响。

6.3 PSI 触发阈值与 poll

PSI 支持阈值触发,可在压力超限时收到事件:

# 当 full 压力在 1s 窗口内超过 100ms 时,3 秒内无新事件则触发
echo "full 100000 3000000" > /proc/pressure/memory
# 然后用 poll/epoll 监听该文件即可

# 查看当前配置
cat /proc/pressure/memory

6.4 cgroup memory.pressure

cgroup v2 为每个 cgroup 提供 memory.pressure,语义与全局一致,但只统计该 cgroup 内的任务:

# 查看某 cgroup 的内存压力
cat /sys/fs/cgroup/myapp/memory.pressure

# 结合 memory.events 查看 high/max 触发次数
cat /sys/fs/cgroup/myapp/memory.events

memory.events 中的 high、max、oom、oom_kill 计数是 cgroup 内存回收的直接证据。


七、OOM 与 cgroup 内存回收

当回收无法释放足够内存时,内核必须杀掉进程来腾出空间,这就是 OOM killer。

7.1 oom_score 与 oom_score_adj

每个进程有一个 oom_score(0-1000),分数越高越可能被杀。它由内存占用、运行时间、特权等计算:

# 查看进程的 oom 分数
cat /proc/<pid>/oom_score
cat /proc/<pid>/oom_score_adj   # 用户可调的偏移,-1000 到 1000

# 保护关键进程(-1000 表示永不被杀)
echo -1000 > /proc/<pid>/oom_score_adj

# 让某进程更容易被杀
echo 500 > /proc/<pid>/oom_score_adj

7.2 cgroup-aware OOM

cgroup v2 的 OOM 是 cgroup 感知的:当某个 cgroup 达到 memory.max 且无法回收时,内核只在该 cgroup 内部选择受害者,而不是全局扫描。这保证了容器 A 的 OOM 不会误杀容器 B。

7.3 memory.max 与 memory.high 与 memory.reclaim

文件语义
memory.max硬限制,超过触发 OOM
memory.high软限制,超过积极回收但不 OOM
memory.min保护线,不回收至该线以下
memory.low软保护线
memory.reclaim手动触发回收指定字节数
# 手动回收 100MB
echo 100M > /sys/fs/cgroup/myapp/memory.reclaim

# 查看当前用量与峰值
cat /sys/fs/cgroup/myapp/memory.current
cat /sys/fs/cgroup/myapp/memory.peak

7.4 memory.oom.group

memory.oom.group 让内核把整个 cgroup 作为 OOM 单位:一旦触发,cgroup 内所有进程一起被杀,避免留下半死不活的状态:

# 启用组 OOM
echo 1 > /sys/fs/cgroup/myapp/memory.oom.group

# 查看 OOM 事件统计
cat /sys/fs/cgroup/myapp/memory.events

八、实战排查与调优清单

8.1 /proc/meminfo 关键字段

cat /proc/meminfo | grep -E \
  "MemTotal|MemFree|MemAvailable|Buffers|Cached|Active|Inactive|SwapCached|SReclaimable|SUnreclaim"
字段含义
MemAvailable估算的可用内存(含可回收部分),最该看的指标
Active / Inactiveactive 与 inactive 链表总大小
SReclaimable可回收的 slab(如 dentry、inode 缓存)
SwapCached同时存在于内存与 swap 的页
Cached页缓存总量

判断内存是否真的紧张,看 MemAvailable 而非 MemFree。MemFree 低但 MemAvailable 高是正常的(缓存占满)。

8.2 /proc/vmstat

grep -E "pgscan_|pgsteal_|pgmajfault|workingset_|allocstall" /proc/vmstat
指标含义
pgscan_kswapdkswapd 扫描页数
pgsteal_kswapdkswapd 回收页数
pgscan_direct直接回收扫描页数(越高越糟)
pgmajfault主缺页次数(换入),高说明 swap 频繁
workingset_refaultrefault 次数,高说明工作集超内存
allocstall分配停顿次数

8.3 vmstat 与 sar

# 每秒采样,观察 si/so(swap in/out)
vmstat 1 10

# si/so 持续非零说明系统在频繁换页
# sar 记录历史内存压力
sar -r 1 5        # 内存使用率
sar -W 1 5        # swap 换入换出
sar -B 1 5        # 缺页与回收统计

8.4 排查清单

现象可能原因排查方向
pgscan_direct 持续增长kswapd 跟不上分配提高 watermark_scale_factor
workingset_refault 高工作集大于可用内存扩容或减少缓存
pgmajfault 高频繁换入降低 swappiness 或加内存
PSI full 非零系统级内存停顿检查 cgroup memory.max
memory.events 中 oom 增长cgroup 超限调整 memory.max 或优化应用
SUnreclaim 持续增长内核内存泄漏slabtop 定位

相关阅读

  • https://plumephp.com/os-page-replacement-algorithms/ —— 页面置换算法的理论基础与 LRU 近似实现对比
  • https://plumephp.com/os-virtual-memory/ —— 虚拟内存、页表与缺页异常处理的内核路径
  • https://plumephp.com/os-linux-memory/ —— Linux 内存管理全景,含分配器与回收的整体视图

延伸阅读

  1. Linux Kernel Documentation: Documentation/admin-guide/mm/(concepts.rst、swap_numa.rst、idle_page_tracking.rst)
  2. Linux Kernel Documentation: Documentation/filesystems/proc.rst(/proc/meminfo、/proc/vmstat 字段说明)
  3. Mel Gorman, “Understanding the Linux Virtual Memory Manager” — 页分配与回收章节
  4. LWN.net: “Multi-generational LRU” 系列文章(Yu Zhao 的 MGLRU 设计说明)
  5. LWN.net: “Toward a better memory-pressure stall indicator” 与 PSI 相关文章
  6. man 5 proc、man 8 vmstat、man 1 sar 手册页

# ============================================================
# 完整可运行示例:内存回收压力观测脚本
# 无需 root(读取 /proc 与 /sys),在 Linux 上直接运行
# 每隔 2 秒采样一次,连续采样 15 次
# ============================================================

echo "=== 内存回收压力观测(按 Ctrl-C 退出) ==="
printf "%-8s %-10s %-12s %-12s %-10s %-10s\n" \
  "TIME" "MemAvail" "pgscan_drct" "pgsteal_drct" "PSI-full" "SwapUsed"

prev_scan=0
prev_steal=0

for i in $(seq 1 15); do
    now=$(date +%H:%M:%S)

    # 1. 可用内存(单位 MB)
    memavail=$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo)

    # 2. 直接回收累计计数
    scan=$(awk '/pgscan_direct/ {print $2}' /proc/vmstat)
    steal=$(awk '/pgsteal_direct/ {print $2}' /proc/vmstat)

    # 3. PSI full avg10
    psi_full=$(awk '/^full/ {gsub(/avg10=/,"",$2); print $2}' /proc/pressure/memory)

    # 4. swap 使用量(MB)
    swapused=$(awk '/SwapTotal/ {t=$2} /SwapFree/ {f=$2} END {printf "%d", (t-f)/1024}' /proc/meminfo)

    printf "%-8s %-10s %-12s %-12s %-10s %-10s\n" \
      "$now" "$memavail" "$scan" "$steal" "$psi_full" "$swapused"

    prev_scan=$scan
    prev_steal=$steal
    sleep 2
done

echo ""
echo "=== 汇总分析 ==="

# 直接回收效率:steal/scan 越低说明扫描大量冷页
awk '
/pgscan_direct/ {s=$2}
/pgsteal_direct/ {t=$2}
END {
    if (s > 0)
        printf "direct reclaim efficiency: %.1f%%\n", t*100/s
    else
        printf "no direct reclaim observed (healthy)\n"
}' /proc/vmstat

# refault 与主缺页
grep -E "workingset_refault|pgmajfault|allocstall" /proc/vmstat

echo ""
echo "=== 各 zone 水位 ==="
awk '
/^  Node/ {node=$0}
/zone/    {zone=$0}
/min/     {printf "%s %s min=%s ", node, zone, $2}
/low/     {printf "low=%s ", $2}
/high/    {printf "high=%s\n", $2}
' /proc/zoneinfo

echo ""
echo "=== 提示 ==="
echo "1. pgscan_direct 持续增长 => 提高 vm.watermark_scale_factor"
echo "2. workingset_refault 高   => 工作集大于可用内存,需扩容"
echo "3. PSI full avg10 > 5      => 系统级内存停顿,检查 cgroup 限制"
echo "4. pgmajfault 高           => 频繁换入,考虑降低 vm.swappiness"

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. ARM64 体系结构与内核实现
  2. 内核网络栈:sk_buff、NAPI 与 XDP
  3. eBPF 开发实战:CO-RE 与 libbpf