Linux-25 内核如何管理物理内存:buddy、slab、页回收与 OOM
24 篇讲的是「虚拟地址如何映射到物理页」。这一篇讲那些物理页本身是怎么被管理的 —— 从分配到回收到杀进程。
先看五个问题:
- buddy 分配器怎么对抗外部碎片?为什么它的分配单位必须是 2 的幂?
- 既然有了 buddy,为什么还要 slab?
free显示free只剩几百 MB,是不是内存快满了?为什么available才是关键?- 内核怎么决定换出哪些页?refault 检测解决什么问题?
- OOM Killer 的评分函数为什么这么算?为什么它有时候「杀错」?
1. 物理内存的分层管理
+--------------------------------------------------------------+
| 各子系统的需求 |
| page cache | 匿名页 | 页表 | dentry/inode | task_struct | ... |
+--------------------------------------------------------------+
| |
| 需要【整页】(4KB 的倍数) | 需要【小对象】(几十~几百字节)
v v
+-------------------+ +----------------------------+
| buddy 分配器 | <--------- | slab / slub 分配器 |
| (页级,2^n 页) | 向它批发 | (对象级,按大小分池) |
+-------------------+ +----------------------------+
|
v
+--------------------------------------------------------------+
| zone(内存区域):DMA / DMA32 / Normal / Movable |
+--------------------------------------------------------------+
|
v
+--------------------------------------------------------------+
| NUMA node(物理内存节点) |
+--------------------------------------------------------------+
1.1 zone:为什么要给内存分区
因为不是所有物理内存都等价 —— 某些硬件只能访问低地址:
cat /proc/zoneinfo | grep -E '^Node|^ pages free' | head -12
# Node 0, zone DMA
# pages free 3968
# Node 0, zone DMA32
# pages free 512340
# Node 0, zone Normal
# pages free 1234567
| zone | 范围 | 存在理由 |
|---|---|---|
ZONE_DMA |
< 16MB | 老式 ISA 设备只有 24 位地址线,只能 DMA 到低 16MB |
ZONE_DMA32 |
< 4GB | 32 位 DMA 设备(很多 PCI 设备)只能访问低 4GB |
ZONE_NORMAL |
其余 | 常规内存 |
ZONE_MOVABLE |
可选 | 只放「可迁移」的页(为内存热插拔与大页规整服务) |
ZONE_HIGHMEM |
仅 32 位系统 | 内核虚拟地址空间不够时的补丁(64 位系统已不存在) |
这个划分是硬件约束的直接产物(又一个 18 篇的例子)。它的实际后果是:内存不是一个整体,「还有 10GB 空闲」不代表「能满足一个 DMA32 的分配请求」。
# 分配失败时的日志会明确指出是哪个 zone 不够
dmesg | grep -A 20 'page allocation failure'
# order:3, mode:0x2d20(GFP_ATOMIC|__GFP_COMP), nodemask=(null)
# Node 0 DMA32 free:12345kB min:16000kB low:20000kB high:24000kB
# ^^^^^^^^^^^^ free 低于 min 水位 -> 分配失败
1.2 NUMA:内存也有远近
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3 ...
# node 0 size: 32768 MB
# node distances:
# node 0 1
# 0: 10 21 <- 访问本节点内存的"距离"是 10,跨节点是 21(约 2 倍延迟)
# 1: 21 10
numastat # 每个节点的分配统计
numastat -p $(pgrep -n myapp) # 某进程的内存分布在哪些节点
# NUMA 的实际影响:跨节点访存慢约一倍
# 所以内核默认策略是「优先从本地节点分配」
cat /proc/sys/vm/zone_reclaim_mode # 0 = 本地不够时去别的节点借(而不是先回收本地)
# 绑定策略(延迟敏感服务的常见调优)
numactl --cpunodebind=0 --membind=0 ./myapp # CPU 和内存都绑在节点 0
numactl --interleave=all ./myapp # 交叉分配(吞吐型负载,避免单节点热点)
# ⚠️ Go 服务通常不需要手动绑,但要知道两个现象:
# ① 大内存机器上,跨 NUMA 访问会让延迟分布变宽
# ② 容器如果被调度到跨 NUMA 的 CPU 集合上,性能会有波动
# (K8s 的 topologyManager 就是解决这个的)
2. buddy 分配器
2.1 开篇第一问:它要解决什么
buddy(伙伴)分配器负责「按页分配物理内存」,它的目标有两个而且互相冲突:
目标 A:快速满足【连续多页】的请求
内核经常需要连续物理页:
- DMA 缓冲区(硬件按物理地址访问,必须连续)
- 大页(2MB = 512 个连续页)
- slab 的底层批发(一次要几页)
- 内核栈(每个线程 16KB = 4 个连续页)
目标 B:对抗【外部碎片】
24 篇讲过分页消灭了「虚拟地址的外部碎片」,
但【物理内存的连续性】仍然是稀缺资源 ——
一台运行几个月的机器,可能有 10GB 空闲内存,
却找不出 4 个连续的物理页。
2.2 算法:分裂与合并
内核维护 11 个链表(order 0 ~ 10),order n 的链表挂着「2^n 个连续页」的块
order 0: 4KB order 1: 8KB order 2: 16KB ... order 10: 4MB
── 分配(要 order 2 = 16KB)──
① 看 order 2 链表有没有空闲块 -> 有就直接给,结束
② 没有 -> 看 order 3(32KB)
③ order 3 有一块 -> 【分裂】成两个 order 2 的块
一块给申请者,另一块挂到 order 2 链表
④ order 3 也没有 -> 继续向上找,逐级分裂
── 释放 ──
① 把块放回对应 order 的链表
② 检查它的【伙伴】(buddy)是否也空闲
③ 如果是 -> 【合并】成一个更大的块,升到上一级
④ 递归向上合并
2.3 为什么必须是 2 的幂
这是 buddy 算法的精髓:2 的幂让「找伙伴」变成一次位运算。
两个块是「伙伴」的条件:
① 大小相同(同一 order)
② 物理地址连续
③ 【对齐】到 2 倍大小的边界
于是给定一个块的起始页帧号 pfn 和 order,它的伙伴是:
buddy_pfn = pfn ^ (1 << order)
^^^ 一次 XOR 就算出来了!O(1)
例:order 1(2 页)的块,起始 pfn = 4
buddy_pfn = 4 ^ 2 = 6 -> 伙伴是 pfn 6 开始的那 2 页
合并后:pfn 4 开始的 4 页(order 2),且【天然对齐到 4 的边界】✅
如果不用 2 的幂:
✗ 找伙伴要遍历或查表(不再是 O(1))
✗ 合并后的块可能不对齐 -> 无法继续向上合并
✗ 分裂的结果大小不一致 -> 链表管理复杂化
# 观察每个 order 的空闲块数量
cat /proc/buddyinfo
# Node 0, zone Normal 1234 567 234 89 34 12 5 2 1 0 0
# ^^^^ ^^^ ^^^ ... ^^ ^^
# order 0 ... order 9,10
# ⚠️ 高 order 全是 0 = 【内存碎片化严重】,无法分配大块连续内存
# 此时即使 free 显示还有很多内存,大页分配和某些驱动的 DMA 分配也会失败
# 换个更直观的算法
awk 'NR>1 {printf "%s %s: ", $2, $4; for(i=5;i<=NF;i++) printf "%s ", $i; print ""}' /proc/buddyinfo
# 碎片化指数(0~1000,越高越碎)
sudo cat /sys/kernel/debug/extfrag/extfrag_index 2>/dev/null
sudo cat /sys/kernel/debug/extfrag/unusable_index 2>/dev/null
2.4 碎片化与内存规整
# 手动触发内存规整(compaction):移动页来腾出连续空间
echo 1 | sudo tee /proc/sys/vm/compact_memory
cat /proc/vmstat | grep -E 'compact_'
# compact_migrate_scanned 1234567 扫描了多少页
# compact_free_scanned 2345678
# compact_isolated 123456
# compact_stall 89 ✅ 有多少次分配【被阻塞】等待规整
# compact_fail 12 规整失败次数
# ⚠️ compact_stall 就是 24 篇 7.2 讲的 THP 延迟尖刺的来源:
# 分配大页时若找不到连续空间,会同步触发规整 -> 进程阻塞几百毫秒
# 内核如何减少碎片:按「可迁移性」分组
cat /proc/pagetypeinfo | head -15
# Free pages count per migrate type at order 0 1 2 ...
# Node 0, zone Normal, type Unmovable ... <- 内核数据(不能移动)
# Node 0, zone Normal, type Movable ... <- 用户页、page cache(可移动)
# Node 0, zone Normal, type Reclaimable ... <- 可回收(如 dentry 缓存)
# 思路:把「不能移动的页」聚在一起,避免它们零散地打断大块连续区域
# -> 这样规整时才有腾挪空间
2.5 分配失败的表现
# order 越高越容易失败
dmesg | grep -B2 -A20 'page allocation failure'
# myapp: page allocation failure: order:4, mode:0x40cc0(GFP_KERNEL|__GFP_COMP)
# ^^^^^^^ order 4 = 64KB 连续
# 常见受害者:
# - 网卡驱动分配大的接收缓冲(jumbo frame)
# - 某些文件系统的元数据操作
# - THP 分配(但它会静默回退到 4KB 页)
# ✅ 这就是为什么内核代码里高 order 分配要么用 kvmalloc(失败时回退到 vmalloc),
# 要么必须能容忍失败
# vmalloc 用【虚拟地址连续但物理地址不连续】的方式绕开碎片问题
grep VmallocUsed /proc/meminfo
3. slab/slub:小对象分配
3.1 开篇第二问:为什么 buddy 不够
buddy 的最小分配单位是【一整页】(4KB)。
但内核里绝大多数对象小得多:
struct task_struct ~ 8KB (每个进程一个)
struct file ~ 256 字节 (每个打开的文件一个,20 篇)
struct dentry ~ 192 字节 (每个路径名一个,20 篇 4.1)
struct inode ~ 600 字节
struct sk_buff ~ 240 字节 (每个网络包一个!33 篇)
struct kmalloc-32/64/128/... (通用小块)
如果每个 dentry 都用一整页:
✗ 浪费 95% 的内存(192 字节 vs 4096 字节)
✗ 而 dcache 里可能有几百万个 dentry -> 完全不可行
slab 的三个设计目标:
① 减少内部碎片
一页里塞满同类对象(4096 / 192 = 21 个 dentry 挤在一页里)
② 缓存已初始化的对象
「slab」这个名字来自 Solaris 的原始论文:
对象释放后不立即归还内核,而是【保留在池子里】,
下次分配时直接复用 —— 省掉了初始化开销(构造函数只跑一次)
③ 缓存友好
同类对象紧凑排列 -> 遍历时 cache 命中率高
早期的 slab 还做「着色」(cache coloring):给每个 slab 加不同的偏移,
避免同一 cache set 被反复冲突
3.2 slab / slub / slob
slab(1994,源自 Solaris)
功能完整但【元数据开销大】、每 CPU 队列复杂
|
slub(2007,✅ 现在的默认)
大幅简化:去掉了大部分队列,用 page 结构体本身存元数据
更好的多核扩展性、更少的内存开销
|
slob(极简,用于嵌入式)
只有几百行,牺牲性能换体积
grep -o 'slub_debug\|CONFIG_SLUB' /boot/config-$(uname -r) | head -2
3.3 观察与排查
# ✅ slabtop:按占用排序(内核内存排查的第一工具)
sudo slabtop -o | head -15
# OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
# 891234 891234 100% 0.19K 42440 21 169760K dentry
# 456789 456789 100% 0.58K 16314 28 261024K ext4_inode_cache
# 234567 234567 100% 0.10K 6015 39 24060K buffer_head
# ^^^^^^^^^^^ 每个对象的大小 ^^^^^^^^^^^ 总占用
# 原始数据
sudo head -3 /proc/slabinfo
awk 'NR>2 {print $1, $2, $3, $4}' /proc/slabinfo | sort -k4 -rn | head
# 系统级的 slab 总量
grep -E 'Slab|SReclaimable|SUnreclaim' /proc/meminfo
# Slab: 1234567 kB
# SReclaimable: 1000000 kB <- ✅ 可回收(dentry/inode 缓存,20 篇 4.1)
# SUnreclaim: 234567 kB <- ⚠️ 不可回收(task_struct、页表等)
# 这部分持续增长 = 内核内存泄漏
内核内存泄漏的排查(应用侧看不到,ps 的 RSS 里也没有):
# 现象:free 显示内存不断减少,但所有进程的 RSS 加起来对不上
# ① 先确认是不是内核内存
grep -E 'MemTotal|MemAvailable|Slab|SUnreclaim|KernelStack|PageTables|VmallocUsed' /proc/meminfo
ps -eo rss --no-headers | awk '{s+=$1} END {print "所有进程 RSS 合计: " s/1024 " MB"}'
# 两者差距很大 -> 内核内存(slab / 页表 / vmalloc / 网络缓冲)
# ② 找出增长的 slab(对比两个时间点)
sudo slabtop -o | head -20 > /tmp/slab1; sleep 300
sudo slabtop -o | head -20 > /tmp/slab2; diff /tmp/slab1 /tmp/slab2
# ③ 常见的「不是泄漏」的增长
# dentry / inode_cache 大 -> 正常(20 篇 4.1,可回收,内存压力下自动释放)
# 验证:echo 2 > /proc/sys/vm/drop_caches 之后应该明显下降
# ④ 真正的内核泄漏(需要 CONFIG_DEBUG_KMEMLEAK)
echo scan | sudo tee /sys/kernel/debug/kmemleak
sudo cat /sys/kernel/debug/kmemleak | head -30
4. page cache 与内存回收
4.1 开篇第三问:free 少不是问题
free -h
# total used free shared buff/cache available
# Mem: 15Gi 4.2Gi 231Mi 128Mi 11Gi 10Gi
# ^^^^^ ^^^^^ ^^^^
# 只剩 231MB? 11GB 缓存 实际可用 10GB
「free 很少」是健康的表现,不是问题。 因为空闲内存是浪费的内存 —— 内核会把所有暂时不用的内存拿去做 page cache(20 篇讲过它在 read() 路径里的作用),需要时再回收。
available 的计算(内核在 /proc/meminfo 里直接给出):
≈ free
+ 【可回收的】page cache(不含脏页与被 mmap 锁住的)
+ 可回收的 slab(dentry/inode 缓存)
- 各 zone 的 min 水位(必须保留的应急储备)
-> 所以 available 才是「应用还能拿到多少内存而不触发换页」的答案
# ✅ 正确的内存健康判断
awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END {printf "available: %.1f%% (%.1f GB / %.1f GB)\n", a/t*100, a/1048576, t/1048576}' /proc/meminfo
# available: 65.3% <- 健康
# ⚠️ 更灵敏的指标不是「剩多少」,而是「有没有在换页」
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io----
# r b swpd free buff cache si so bi bo
# 1 0 0 236892 12345 1234567 0 0 4096 512
# ^^ ^^ ✅ si/so 都是 0 = 健康
# 2 3 524288 98765 1234 456789 45 120 8192 2048
# ^^ ^^^ ❌ 正在换页 = 内存真的不够了
4.2 双 LRU:为什么需要两个链表
内核为每个 zone 维护四条 LRU 链表(匿名页和文件页各有 active/inactive 两条):
grep -E 'Active|Inactive' /proc/meminfo
# Active: 6234567 kB
# Inactive: 4567890 kB
# Active(anon): 3456789 kB <- 活跃的匿名页(堆栈)
# Inactive(anon): 234567 kB
# Active(file): 2777778 kB <- 活跃的文件页(page cache)
# Inactive(file): 4333323 kB
为什么不用单一 LRU? 因为一次大扫描会冲掉所有热数据:
场景:数据库正在服务请求(热数据都在 cache 里),
此时有人执行 `tar -czf backup.tar.gz /data`(顺序读 100GB)
单 LRU 的后果:
那 100GB 的数据依次进入 LRU 头部,把数据库的热数据全部挤到尾部并淘汰
-> 备份跑完后,数据库的缓存命中率归零,性能断崖式下跌
-> 而那 100GB 数据【只被读了一次】,留在缓存里毫无价值
这个现象叫「cache 污染」或「scan resistance 缺失」
双 LRU 的解法:
① 新读入的页先进【inactive】链表
② 只有【被再次访问】才提升到 active 链表
③ 回收时【优先从 inactive 尾部】拿
-> 顺序扫描的数据只会在 inactive 里进出,碰不到 active 里的热数据 ✅
┌─────────────── active list ───────────────┐
│ 热数据(被访问过 ≥2 次) │ <- 回收时最后才动
└───────────────────────────────────────────┘
↑ 第二次访问时提升 ↓ 长期不访问时降级
┌─────────────── inactive list ─────────────┐
│ 新读入的页 / 冷数据 │ <- ✅ 回收从这里的尾部开始
└───────────────────────────────────────────┘
↓ 淘汰
4.3 开篇第四问:refault 检测
双 LRU 还有一个缺陷:怎么知道 inactive 链表的长度是否合适?
问题:如果 inactive 太小,真正的热数据可能在「第二次访问之前」就被淘汰了
-> 这个页很快又被读回来(refault)
-> 说明它其实是热的,只是没来得及被提升
这种「刚淘汰就又被读回来」的现象叫 refault(重新缺页),
它是【缓存容量不足】的直接信号 —— 但内核在 4.20 之前无法区分:
「这是第一次读这个页」 vs 「这个页刚被我淘汰又回来了」
Linux 4.20 引入的 refault 检测(workingset):淘汰一个页时,记录当时的「LRU 时钟」(一个全局的淘汰计数器),存在 page cache 的 radix tree 里(用一个特殊的 shadow entry)。
页被再次读入时:
① 发现有 shadow entry -> 这是一次 refault
② 计算「距离」= 当前 LRU 时钟 - 记录的时钟
= 这个页被淘汰后,又有多少页被淘汰了
③ 如果距离 < active 链表的长度
-> 说明「如果 inactive 再大一点,这个页本来不会被淘汰」
-> ✅ 直接把它放进 active 链表(承认它是热的)
-> 并且【缩小 active 的目标大小】,给 inactive 更多空间
# 观察 refault
grep -E 'workingset_refault|workingset_activate|workingset_restore' /proc/vmstat
# workingset_refault_file 1234567 <- 文件页的 refault 次数
# workingset_activate_file 234567 <- 其中被判定为「本该是热的」而直接激活的
# workingset_refault_anon 12345
# ⚠️ refault 持续高增长 = 【内存不足导致缓存反复失效】
# 这比「free 少」灵敏得多,是真正的内存压力信号
# 每个 cgroup 也有(容器场景)
cat /sys/fs/cgroup/myapp/memory.stat | grep workingset
这个机制也是 PSI 指标(第 5 章)的数据基础之一,它让「内存压力」第一次变得可量化。
4.4 回收的触发:水位线与两条路径
cat /proc/zoneinfo | grep -A 4 'Node 0, zone Normal' | head -6
# pages free 1234567
# min 16000 <- ❌ 低于它:分配会阻塞/失败,触发【直接回收】
# low 20000 <- ⚠️ 低于它:唤醒 kswapd【后台回收】
# high 24000 <- ✅ kswapd 回收到这里就停
两条回收路径,性能差异巨大:
① kswapd(后台回收)—— ✅ 好
free 降到 low 水位时被唤醒,在后台异步回收,回到 high 水位才停
对应用【无感知】
② 直接回收(direct reclaim)—— ❌ 坏
free 降到 min 水位时,【申请内存的那个进程自己去做回收】
-> 进程在 alloc_pages() 里【同步阻塞】,可能要几十到几百毫秒
-> 表现为「应用突然卡一下」,而且极难定位
-> 这是内存类长尾延迟的头号原因
# 观察直接回收(这个指标非常重要)
grep -E 'pgscan_direct|pgsteal_direct|allocstall' /proc/vmstat
# allocstall_normal 1234 <- ✅ 有多少次分配【被迫等待直接回收】
# 持续增长 = 内存压力大,应用会有延迟尖刺
grep -E 'pgscan_kswapd|pgsteal_kswapd' /proc/vmstat # 后台回收的量
# 调优:提高水位线,让 kswapd 更早开始工作(用空间换延迟稳定性)
sysctl vm.min_free_kbytes # 默认按内存大小计算
sudo sysctl -w vm.min_free_kbytes=1048576 # 提到 1GB,给 kswapd 更多缓冲
# ⚠️ 设太大会浪费内存,设太小会频繁触发直接回收
# 观察 kswapd 是否忙不过来
ps -eo pid,comm,pcpu | grep kswapd
# kswapd 持续占用 CPU = 回收压力大
4.5 swappiness 的真实含义
cat /proc/sys/vm/swappiness # 60(默认)
它不是「多久开始用 swap」,而是「回收时在『换出匿名页』和『丢弃文件页』之间的权重」(11 篇 7.2 提过):
内核回收内存时有两个选择:
A. 换出匿名页(堆、栈)-> 必须写到 swap(有 I/O 开销,且换回来也要 I/O)
B. 丢弃文件页(page cache)-> 干净页直接丢(零成本),脏页要先写回
swappiness 就是这两者的权重:
0 -> 几乎只丢文件页(但【不等于禁用 swap】,内存极度紧张时仍会换)
60 -> 默认平衡
100 -> 两者同等对待
200 -> 更倾向换出匿名页(内核 5.8+ 允许超过 100)
实践:
数据库/缓存服务(数据在匿名内存里,且延迟敏感)-> 1~10
文件服务器(page cache 就是价值所在)-> 保持默认或调高
K8s 节点 -> 直接关 swap(11 篇 7.3 讲过三个理由)
5. PSI:内存压力的量化
5.1 load average 的不足
13 篇讲过 load average 混合了「等 CPU」和「等 IO」两种瓶颈。它还有一个更大的问题:完全看不到内存压力。
一台机器内存紧张时会发生:
- 大量直接回收(进程同步阻塞)
- 频繁的 refault(缓存反复失效)
- swap 换入换出
但这些可能【完全不体现在 load average 上】:
进程只是在 alloc_pages() 里等了 50ms,既不算 R 也不算 D
5.2 PSI 的设计
PSI(Pressure Stall Information,内核 4.20+)直接测量「因为资源不足而损失的时间比例」:
cat /proc/pressure/memory
# some avg10=0.15 avg60=0.08 avg300=0.02 total=12345678
# full avg10=0.00 avg60=0.00 avg300=0.00 total=234567
cat /proc/pressure/cpu
cat /proc/pressure/io
| 指标 | 含义 |
|---|---|
some |
至少有一个任务因为该资源而停滞的时间占比 |
full |
所有任务都在因该资源停滞的时间占比(= 完全无法推进工作) |
avg10/60/300 |
10 秒 / 60 秒 / 300 秒的移动平均(百分比) |
total |
累计微秒数(自己算速率更精确) |
为什么分 some 和 full?
some 高但 full 低 -> 部分任务受影响,系统整体还在推进(可接受)
full 高 -> ❌ 系统在【空转】,所有任务都在等内存
这是「内存不足」最明确的信号
PSI 相对 load average 的优势:
✅ 直接量化「损失了多少工作时间」,语义明确
✅ 区分 CPU / 内存 / IO 三种资源
✅ 支持 cgroup 级别(能定位到具体容器)
✅ 支持 poll(可以做事件驱动的告警)
# cgroup 级别(容器场景的关键)
cat /sys/fs/cgroup/myapp.service/memory.pressure
cat /sys/fs/cgroup/myapp.service/io.pressure
systemctl status myapp | grep -i pressure # systemd 也会展示
# 实用的告警阈值(经验值)
# memory full avg60 > 1% -> 值得关注
# memory full avg60 > 10% -> 严重,应用已经在明显受损
awk '/^full/{split($2,a,"="); if (a[2]+0 > 1) print "内存压力告警: " $0}' /proc/pressure/memory
# 事件驱动监听(Facebook 的 oomd、systemd-oomd 就基于它)
systemctl status systemd-oomd
cat /etc/systemd/oomd.conf
# ✅ systemd-oomd 的价值:在【内核 OOM Killer 出手之前】,
# 根据 PSI 主动杀掉压力最大的 cgroup —— 比内核 OOM 更可控、更早介入
6. OOM Killer
6.1 它为什么必须存在
OOM Killer 是 overcommit 的必然结果。
内核允许 overcommit(承诺的虚拟内存 > 物理内存 + swap),因为:
- 大部分程序申请的内存远多于实际使用(24 篇的按需分页)
- fork 的 COW 意味着「承诺了双份但实际共享」(21 篇)
- 如果严格禁止 overcommit,内存利用率会极低
但 overcommit 是一张【可能兑付不了的空头支票】:
当所有进程真的开始写入它们申请的内存时,物理内存会耗尽。
此时内核只有三个选择:
① 让分配失败 -> 但很多分配路径【无法处理失败】(比如缺页时已经无法回退)
② 无限阻塞等待 -> 系统完全僵死(比崩溃更糟,连 ssh 都进不去)
③ 【杀掉一个进程】腾出内存 -> OOM Killer
6.2 开篇第五问:评分函数
// mm/oom_kill.c(简化)
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
// 核心:按【占用内存的比例】计分
points = get_mm_rss(p->mm) // RSS(13 篇 2.3)
+ get_mm_counter(p->mm, MM_SWAPENTS) // 被换出的部分
+ mm_pgtables_bytes(p->mm) / PAGE_SIZE;// ✅ 连页表也算进去(24 篇 3.5)
// oom_score_adj 的影响:按 totalpages 的千分比调整
points += p->signal->oom_score_adj * totalpages / 1000;
return points;
}
// 最终的 oom_score 是把它归一化到 0~1000
为什么按「占用比例」而不是「绝对值」或别的标准?
① 目标是「杀一个就能解决问题」
杀掉占用最多的进程,最有可能一次性缓解内存压力。
如果杀小进程,可能杀了十个还不够 —— 那就制造了十次故障而不是一次。
② 为什么不按「运行时间」或「优先级」?
因为 OOM 是【紧急情况】,评分必须简单快速且可预测。
复杂的启发式(比如「这个进程更重要」)内核无法判断 ——
「重要性」是策略,属于用户态(18 篇的机制与策略分离)
-> 所以内核只提供机制(按占用打分)+ 一个策略旋钮(oom_score_adj)
③ 为什么把【页表】也算进去?
因为一个进程可能 RSS 不大但页表巨大(大量稀疏映射),
那些页表同样是不可回收的内核内存(24 篇 3.5)
④ 为什么不杀内核线程和 init?
杀了它们系统直接完蛋(19 篇 6.1 的 PID 1 规则)
# 查看每个进程的 OOM 评分
for p in /proc/[0-9]*; do
s=$(cat $p/oom_score 2>/dev/null)
[[ -n $s && $s -gt 0 ]] && echo "$s ${p##*/} $(cat $p/comm 2>/dev/null)"
done | sort -rn | head -8
# ^^ 分数最高的就是下一个受害者
cat /proc/$(pgrep -n myapp)/oom_score # 0~1000
cat /proc/$(pgrep -n myapp)/oom_score_adj # -1000 ~ 1000(可调)
# 保护关键进程
echo -1000 | sudo tee /proc/$(pgrep -n myapp)/oom_score_adj # 几乎永不被选中
# systemd 里:OOMScoreAdjust=-500(10 篇 8 章的加固项之一)
# 让非关键的批处理优先被杀
echo 500 | sudo tee /proc/$(pgrep -n batchjob)/oom_score_adj
6.3 两层 OOM:cgroup vs 全局
① cgroup OOM(容器超限)
触发:cgroup 的内存用量达到 memory.max
范围:【只杀这个 cgroup 内】的进程
观察:/sys/fs/cgroup/<path>/memory.events 的 oom_kill 计数
② 全局 OOM(整机内存耗尽)
触发:所有 zone 都无法满足分配且回收无效
范围:可能杀【任何】进程
观察:dmesg / journalctl -k
# ── 容器场景的完整排查 ──
# ① 容器退出码 137 = 128 + 9(SIGKILL)(01 篇 6.3 讲过)
docker inspect mycontainer --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
# 137 true
kubectl describe pod mypod | grep -A3 'Last State'
# Reason: OOMKilled
# Exit Code: 137
# ② cgroup 的 OOM 计数
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
# low 0
# high 234 <- 超过 memory.high 的次数(触发了激进回收)
# max 12 <- 达到 memory.max 的次数
# oom 3 <- 发生过 3 次 OOM
# oom_kill 3 <- ✅ 杀了 3 个进程
# ③ 看内存构成,判断是谁在占(这一步最关键)
cat /sys/fs/cgroup/system.slice/myapp.service/memory.stat | grep -E '^(anon|file|slab|sock|kernel)'
# anon 3221225472 <- ✅ 匿名页(堆栈),【不可回收】,主要嫌疑
# file 838860800 <- page cache,可回收
# slab 104857600 <- 内核对象
# sock 8388608 <- socket 缓冲区(连接多时会很大)
# ④ 内核日志里的完整现场
sudo dmesg -T | grep -A 25 'Out of memory'
# Out of memory: Killed process 12345 (java) total-vm:8388608kB,
# anon-rss:7340032kB, file-rss:0kB, shmem-rss:0kB, UID:1000
# pgtables:14848kB oom_score_adj:0
# ^^^^^^^^^^ 注意:页表 14MB 也被计入了评分
# 前面还会 dump 当时【所有进程】的内存占用 —— 这是判断「是否杀对了」的依据
sudo journalctl -k | grep -i -A 25 'out of memory'
6.4 为什么 OOM Killer 有时「杀错」
典型抱怨:「我的数据库被杀了,而那个吃内存的批处理还在跑」
原因分析:
① 评分只看【当前占用】—— 数据库的 buffer pool 就是它的核心,
自然占用最多,于是它得分最高
-> ✅ 解法:给它设 oom_score_adj=-500,同时给批处理设正值
② cgroup OOM 只在【该 cgroup 内】选择
-> 如果数据库和批处理在同一个 cgroup,评分仍按占用比
③ 内核无法知道「业务重要性」(这是策略,18 篇)
-> ✅ 正确的解法不是抱怨内核,而是:
- 用 cgroup 给不同服务【硬隔离】内存额度(memory.max)
- 用 memory.high 让某些服务在超限时先被限速而不是被杀
- 用 systemd-oomd / oomd 在内核出手前按 PSI 做更聪明的选择
- 给关键服务设 OOMScoreAdjust
# 更好的做法:用 memory.high 做「软限制」
# [Service]
# MemoryHigh=1500M <- 超过就激进回收 + 限速(进程变慢但不死)
# MemoryMax=2000M <- 硬上限(超过才 OOM)
# ✅ 这给了应用一个「减速带」,而不是直接的悬崖
systemctl show myapp -p MemoryHigh -p MemoryMax
7. overcommit 策略
cat /proc/sys/vm/overcommit_memory
| 值 | 名称 | 行为 |
|---|---|---|
| 0 | 启发式(默认) | 内核估算,明显不合理的大额申请会被拒绝(比如一次申请超过总内存) |
| 1 | 总是允许 | 不做任何检查,来者不拒 |
| 2 | 严格 | 总承诺量不得超过 swap + 物理内存 × overcommit_ratio |
grep -E 'CommitLimit|Committed_AS' /proc/meminfo
# CommitLimit: 9876543 kB <- 模式 2 下的上限
# Committed_AS: 12345678 kB <- ✅ 当前已承诺的总量(可以超过 CommitLimit!)
sysctl vm.overcommit_ratio # 50(默认)
为什么 Redis 要求 overcommit_memory=1(21 篇 3.3 提过):
Redis 的 BGSAVE 用 fork 做快照。fork 时内核在【启发式模式(0)】下会估算:
「子进程可能需要与父进程等量的内存」
-> 如果 Redis 占了 60% 的内存,这个估算会认为「不够」-> fork 失败(ENOMEM)
-> BGSAVE 无法执行,持久化中断(Redis 会在日志里报错)
但实际上因为 COW,子进程几乎不会真的用那么多内存。
-> 所以要设 overcommit_memory=1,让 fork 无条件成功
代价:真的内存不够时,问题会推迟到「写入内存的那一刻」才暴露(触发 OOM)
-> 这是一个有意识的取舍:用「可能被 OOM」换「fork 一定成功」
# Redis 的官方要求
sudo sysctl -w vm.overcommit_memory=1
echo 'vm.overcommit_memory = 1' | sudo tee /etc/sysctl.d/99-redis.conf
redis-cli info persistence | grep -E 'rdb_last_bgsave_status|rdb_bgsave_in_progress'
# rdb_last_bgsave_status:err <- 如果是 err,检查 overcommit 与内存
8. Go 视角
# Go 服务的内存问题排查顺序(把本篇的工具串起来)
# ① 先确认是应用内存还是内核内存
grep -E 'MemAvailable|Slab|SUnreclaim|PageTables' /proc/meminfo
ps -eo rss --no-headers | awk '{s+=$1} END {print s/1024 " MB"}'
# ② 确认是 Go 堆内还是堆外(13 篇 10.2 的对照表)
curl -s localhost:6060/debug/pprof/heap > heap.prof
go tool pprof -top heap.prof | head
# 对比 runtime.MemStats 的 Sys 与进程 RSS:
# Sys ≈ RSS 且 HeapAlloc 小 -> 堆外(cgo/mmap/goroutine 栈)
curl -s 'localhost:6060/debug/pprof/heap?debug=1' | grep -E '^# (Sys|HeapAlloc|HeapSys|HeapReleased|StackSys) '
# ③ 确认有没有内存压力(本篇的核心指标)
cat /proc/pressure/memory # PSI
grep -E 'workingset_refault|allocstall' /proc/vmstat # refault 与直接回收
vmstat 1 3 # si/so
# ④ 容器场景
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events | grep -E 'high|max|oom'
cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file|slab|sock)'
// 三个必须设的(12/24 篇讲过)
// GOMEMLIMIT=1800MiB 让 GC 感知容器上限(否则必然 OOMKilled)
// GOGC=100 或按需调整
// automaxprocs 让 GOMAXPROCS 感知 cgroup CPU 限额
// 主动降低内存占用的手段
debug.SetMemoryLimit(1800 << 20) // 等价于 GOMEMLIMIT,可运行时调整
debug.SetGCPercent(50) // 更激进的 GC(用 CPU 换内存)
debug.FreeOSMemory() // 立即归还(24 篇 8.2)
一条重要的经验:容器里 MemoryHigh 比 MemoryMax 更有价值。只设 MemoryMax 时,应用是「一路正常然后突然被杀」;配上 MemoryHigh 后,超限时先触发激进回收和限速,Go 的 GC 有机会响应压力 —— 加上 GOMEMLIMIT,通常能避免真正的 OOM。
9. 面试题
Q:buddy 分配器为什么必须用「2 的幂」作为分配单位?
因为2 的幂让「找伙伴」变成一次位运算。两个块是伙伴的条件是「大小相同 + 地址连续 + 对齐到 2 倍大小的边界」,于是给定起始页帧号和 order,伙伴的位置就是 buddy_pfn = pfn ^ (1 << order) —— 一次 XOR,O(1)。
如果不用 2 的幂:找伙伴要遍历或查表(不再是 O(1))、合并后的块可能不对齐(无法继续向上合并)、分裂结果大小不一致导致链表管理复杂化。
buddy 要同时满足两个冲突的目标:快速满足连续多页的请求(DMA 缓冲、大页、内核栈都需要物理连续)和对抗外部碎片。它维护 order 0~10 共 11 个链表,分配时逐级向上找并分裂,释放时递归向上合并。
关键认知是:物理内存的连续性是稀缺资源 —— 一台运行几个月的机器可能有 10GB 空闲内存却找不出 4 个连续物理页。cat /proc/buddyinfo 里高 order 全是 0 就是这个状态,此时大页分配和某些驱动的 DMA 分配会失败。
Q:有了 buddy 为什么还要 slab?
因为 buddy 的最小单位是一整页(4KB),而内核对象小得多:dentry 约 192 字节、file 约 256 字节、sk_buff 约 240 字节。dcache 里可能有几百万个 dentry,如果每个占一页就是 95% 的浪费。
slab 的三个设计目标:减少内部碎片(一页塞 21 个 dentry)、缓存已初始化的对象(释放后留在池里,下次直接复用,省掉初始化 —— 这是 “slab” 这个名字的来源)、缓存友好(同类对象紧凑排列,早期还做 cache coloring 避免冲突)。
现在的默认实现是 slub(2007),它相对原始 slab 大幅简化了元数据和队列,多核扩展性更好。
排查用 slabtop -o(按占用排序)和 /proc/meminfo 的 SReclaimable(dentry/inode 缓存,可回收,大是正常的)vs SUnreclaim(不可回收,持续增长才是内核内存泄漏)。
Q:free 显示 free 只剩 231MB,内存是不是快满了?
不是,这是健康状态。 空闲内存是浪费的内存 —— 内核会把暂时不用的内存全部拿去做 page cache,需要时再回收。
available 才是答案:它约等于 free + 可回收的 page cache + 可回收的 slab - 各 zone 的 min 水位,表示「应用还能拿到多少内存而不触发换页」。
但更灵敏的指标不是「剩多少」,而是「有没有在换页和回收」:
vmstat 1的si/so(换入换出)—— 非 0 就是内存真的不够/proc/vmstat的allocstall_*—— 有多少次分配被迫等待直接回收/proc/vmstat的workingset_refault_*—— 缓存反复失效/proc/pressure/memory的 PSI
Q:为什么页回收要用「双 LRU」而不是单一 LRU?
为了抵抗一次性大扫描造成的 cache 污染。
场景:数据库热数据都在 page cache 里,此时有人 tar -czf backup.tar.gz /data(顺序读 100GB)。单 LRU 下这 100GB 会依次进入链表头部,把数据库的热数据全部挤出去淘汰 —— 而那 100GB 只被读了一次,留在缓存里毫无价值。备份跑完后数据库缓存命中率归零。
双 LRU 的解法:新读入的页先进 inactive 链表,只有被再次访问才提升到 active;回收时优先从 inactive 尾部拿。于是顺序扫描的数据只在 inactive 里进出,碰不到 active 里的热数据。
内核实际维护四条链表(匿名页和文件页各有 active/inactive),因为这两类页的回收成本完全不同(匿名页必须写 swap,干净的文件页可以直接丢)。
Q:refault 检测解决什么问题?
它补上了双 LRU 的一个缺陷:怎么知道 inactive 链表的长度够不够。
如果 inactive 太小,真正的热数据可能在「第二次访问之前」就被淘汰,然后很快又被读回来。内核在 4.20 之前无法区分「第一次读这个页」和「这个页刚被我淘汰又回来了」。
Linux 4.20 的做法:淘汰页时把当时的全局 LRU 时钟记在 page cache 的 shadow entry 里。页被再次读入时,计算「距离 = 当前时钟 - 记录的时钟」(= 它被淘汰后又有多少页被淘汰)。如果距离小于 active 链表的长度,说明「inactive 再大一点它本来不会被淘汰」→ 直接把它放进 active,并缩小 active 的目标大小给 inactive 让空间。
观察 grep workingset /proc/vmstat —— workingset_refault 持续高增长是「内存不足导致缓存反复失效」的明确信号,比「free 少」灵敏得多。它也是 PSI 的数据基础之一。
Q:kswapd 回收和「直接回收」有什么区别?为什么后者是延迟尖刺的来源?
内核为每个 zone 设了三条水位线:
- free < low → 唤醒 kswapd 后台异步回收,回到 high 才停 —— 应用无感知 ✅
- free < min → 直接回收(direct reclaim):申请内存的那个进程自己去做回收,在
alloc_pages()里同步阻塞,可能几十到几百毫秒 ❌
直接回收是内存类长尾延迟的头号原因,而且极难定位(应用侧只看到「某次请求突然慢了」)。观察指标是 /proc/vmstat 的 allocstall_*。
缓解手段是提高 vm.min_free_kbytes,让 kswapd 更早开始工作 —— 用一点内存换延迟稳定性。同时检查 kswapd 是否持续占 CPU(说明回收压力已经超出它的处理能力)。
Q:PSI 相比 load average 好在哪?
load average 有两个问题:混合了「等 CPU」和「等 IO」(13 篇讲过),以及完全看不到内存压力 —— 进程在 alloc_pages() 里等 50ms 既不算 R 也不算 D。
PSI 直接测量「因为资源不足而损失的时间比例」,分 CPU/内存/IO 三种资源,每种给两个数:
some= 至少有一个任务因该资源停滞的时间占比full= 所有任务都在停滞的占比(系统在空转)
some 高但 full 低表示部分任务受影响、系统整体还在推进;full 高就是明确的资源不足信号。经验阈值是 memory full avg60 > 1% 值得关注、> 10% 已经严重受损。
它还支持 cgroup 级别(能定位到具体容器)和 poll(事件驱动告警)—— systemd-oomd/Facebook 的 oomd 就基于它,在内核 OOM Killer 出手之前主动杀掉压力最大的 cgroup,比内核 OOM 更早介入也更可控。
Q:OOM Killer 的评分函数为什么按「内存占用比例」算?
因为目标是「杀一个就能解决问题」 —— 杀掉占用最多的进程最有可能一次性缓解压力。如果杀小进程,可能杀十个还不够,那就制造了十次故障而不是一次。
评分是 RSS + swap 占用 + 页表页数(连页表也算进去,因为一个进程可能 RSS 不大但页表巨大,那同样是不可回收的内核内存),再加上 oom_score_adj × totalpages / 1000。
为什么不用更聪明的启发式(比如「哪个进程更重要」)? 因为 OOM 是紧急情况,评分必须简单快速可预测;而且「业务重要性」是策略,内核无法判断 —— 所以它只提供机制(按占用打分)+ 一个策略旋钮(oom_score_adj),这又是 18 篇的机制与策略分离。
Q:为什么 OOM Killer 有时「杀错」(杀了数据库而不是批处理)?怎么办?
因为评分只看当前占用 —— 数据库的 buffer pool 就是它的核心,自然得分最高。内核无法知道业务重要性。
正确的应对不是抱怨内核,而是在用户态表达策略:
- 给关键服务设
OOMScoreAdjust=-500,给批处理设正值 - 用 cgroup 给不同服务硬隔离内存额度(
MemoryMax) - 更重要的是配
MemoryHigh—— 只设MemoryMax时应用是「一路正常然后突然被杀」,配上MemoryHigh后超限时先触发激进回收和限速,给了应用一个减速带而不是悬崖(配合 Go 的GOMEMLIMIT通常能避免真正的 OOM) - 用
systemd-oomd按 PSI 做更早、更聪明的选择
容器场景的排查链:退出码 137(=128+9)→ memory.events 的 oom_kill 计数 → memory.stat 看内存构成(anon 不可回收是主要嫌疑,file 可回收,sock 在连接多时会很大)→ dmesg 里的完整现场(会 dump 当时所有进程的占用)。
Q:为什么 Redis 要求 vm.overcommit_memory=1?
Redis 的 BGSAVE 用 fork 做快照。在**默认的启发式模式(0)**下,内核 fork 时会估算「子进程可能需要与父进程等量的内存」—— 如果 Redis 已占 60% 内存,这个估算会判定「不够」并让 fork 失败(ENOMEM),导致持久化中断。
但实际上因为 COW(21 篇),子进程几乎不会真用那么多内存。所以设 overcommit_memory=1(不做任何检查)让 fork 无条件成功。
代价是有意识的取舍:真的内存不够时,问题会推迟到「写入内存的那一刻」才暴露(触发 OOM)—— 用「可能被 OOM」换「fork 一定成功」。
更本质地说,OOM Killer 本身就是 overcommit 的必然结果:内核允许承诺超过物理内存(因为按需分页和 COW 让大部分承诺不会兑付),当承诺真的被兑付时,内核只有三个选择 —— 让分配失败(但很多路径无法处理失败)、无限阻塞(系统僵死,比崩溃更糟)、或者杀一个进程。
小结
- zone 的划分是硬件约束的产物(DMA 设备的地址线限制)→ 「还有 10GB 空闲」不代表能满足一个 DMA32 分配
- buddy 用 2 的幂是为了让找伙伴变成一次 XOR;
/proc/buddyinfo高 order 全 0 = 碎片化严重 - slab 存在是因为 buddy 最小 4KB 而内核对象只有几百字节;
SReclaimable大是正常的,SUnreclaim持续涨才是泄漏 free少是健康的,available才是答案;但最灵敏的是si/so、allocstall、workingset_refault、PSI- 双 LRU 是为了抗 cache 污染(一次
tar不该冲掉数据库的热数据);refault 检测让「inactive 是否太小」变得可测量 - 直接回收(
allocstall)是内存类长尾延迟的头号原因,靠提高min_free_kbytes让 kswapd 早点干活 - PSI 的
full才是明确的资源不足信号;systemd-oomd靠它在内核 OOM 之前介入 - OOM 评分按占用比例是为了「杀一个就够」;内核不判断业务重要性(那是策略)→ 用
OOMScoreAdjust+MemoryHigh减速带 - OOM Killer 是 overcommit 的必然结果;Redis 要
overcommit_memory=1是用「可能被 OOM」换「fork 一定成功」
下一篇讲 用户态分配器:从 brk 到 Go 的 mspan —— malloc 为什么不直接用系统调用、glibc 的 arena 如何应对多线程竞争、free 之后内存为什么不还给操作系统、以及 Go 的 mcache/mcentral/mheap 三级结构如何完全绕开 libc。
xingliuhua