内存是 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 两级,问题在于:
- 扫描成本与内存规模成正比:页越多,找到足够冷页需要扫描的页越多
- 缺乏「年龄」概念:无法区分「刚变冷」与「冷了很久」的页
- 抖动:工作集略大于内存时,页面反复换入换出
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
慢路径做四件事:
- 唤醒 kswapd:让后台线程并行回收
- 重试快速路径:给 kswapd 一点时间
- 直接回收:
__alloc_pages_direct_reclaim(),调用try_to_free_pages() - 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 设备 | zswap | zram |
|---|---|---|---|
| 存储位置 | 磁盘/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 / Inactive | active 与 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_kswapd | kswapd 扫描页数 |
pgsteal_kswapd | kswapd 回收页数 |
pgscan_direct | 直接回收扫描页数(越高越糟) |
pgmajfault | 主缺页次数(换入),高说明 swap 频繁 |
workingset_refault | refault 次数,高说明工作集超内存 |
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 内存管理全景,含分配器与回收的整体视图
延伸阅读
- Linux Kernel Documentation:
Documentation/admin-guide/mm/(concepts.rst、swap_numa.rst、idle_page_tracking.rst) - Linux Kernel Documentation:
Documentation/filesystems/proc.rst(/proc/meminfo、/proc/vmstat字段说明) - Mel Gorman, “Understanding the Linux Virtual Memory Manager” — 页分配与回收章节
- LWN.net: “Multi-generational LRU” 系列文章(Yu Zhao 的 MGLRU 设计说明)
- LWN.net: “Toward a better memory-pressure stall indicator” 与 PSI 相关文章
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"
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。