同样是「内存不够」,内核可能有五种反应:丢缓存、同步回写、限流、杀进程、或让 malloc 失败。本章把 meminfo、脏页回写、OOM 选择算法、overcommit 与 cgroup 限额串成一条能观测、能验证的链路。

学习目标

读完本章,你应当能够:

  1. 逐字段判别 /proc/meminfo 中哪些内存可回收、哪些不可回收,并说明为什么「MemFree 只剩 200 MB」通常是个假警报。
  2. 解释 page cache「既是缓存又是文件的唯一内存副本」这一双重身份,并说明 read 与 mmap 为什么落在同一份页上。
  3. 用 dirty_ratio/dirty_background_ratio 与设备带宽推出回写毛刺的周期,并说清 O_DIRECT 绕过了什么、代价是什么。
  4. 按 oom_badness() 推算任意进程的 oom_score,把 dmesg 里 OOM 日志读到字段级,并判断它属于哪种 OOM。
  5. 准确复述 vm.overcommit_memory 三种模式,用「fork 之后写入才 OOM」解释「malloc 成功不等于内存可得」。
  6. 用 smaps_rollup/pmap/Native Memory Tracking 对内存增长做分诊,说出 valgrind/ASan 的能力边界与 cgroup 限额下的行为差异。

前置知识检查

你需要知道在哪一节补齐
虚拟内存、分页、缺页中断、mmap 的语义见第 8 章 8.2 节、8.6 节
写时复制(copy-on-write, COW)的页表标记与触发路径见第 4 章 4.3 节
page cache、O_DIRECT、VFS 在 I/O 路径上的位置见第 7 章 7.6 节
free/vmstat 指标口径与 si/so 两列含义见第 11 章 11.3 节
cgroup v1/v2 的资源隔离框架、JVM 堆与元空间见第 10 章 10.6 节、第 32 章 32.1 节

12.1 /proc/meminfo:先把「可回收」与「不可回收」分开

MemFree 只统计完全没有被任何用途占用的页,它把「内核随时能扔掉或写回去的页」也排除在外。判读内存的唯一正确姿势是先按回收代价分类,而不是看空闲多少。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
                        MemTotal(内核可管理的物理内存,硬件保留已扣除)
┌──────────────────────────────────────────────────────────────────────┐
│ 不可回收:AnonPages │ PageTables │ KernelStack │ SUnreclaim │ Mlocked  │
│           HugePages │ Shmem(无 swap 时不可回收)                    │
├──────────────────────────────────────────────────────────────────────┤
│ 可回收但有代价:Cached(文件页) │ Buffers │ SReclaimable               │
│   干净页 → 直接丢弃,零 I/O;脏页 → 必须先回写,代价是一次 I/O       │
├──────────────────────────────────────────────────────────────────────┤
│ MemFree:完全空闲的页                                                │
└──────────────────────────────────────────────────────────────────────┘
字段含义可回收性回收路径与代价
MemTotal内核可管理的物理内存—基准量,不等于插满的容量
MemFree完全空闲的页已空闲—
MemAvailable可分配给新用途的估算值(4.14+)已是净值估算口径,见下方算例
Buffers块设备页缓存(文件系统元数据、裸设备读写)可回收干净页直接丢;脏页需回写
CachedNR_FILE_PAGES − SwapCached − Buffers,包含 Shmem大部分可回收干净页丢弃、脏页回写;Shmem 需 swap 才能腾出
SwapCached既在 swap 又在内存的页(换回后未被改写的两份副本)可丢弃直接丢内存副本,这正是它存在的意义
Active/Inactive(anon/file)LRU 四条链表的页数—决定回收顺序,不是占用分类
Unevictable/Mlockedmlock 锁住的页不可回收只能 munlock
AnonPages映射进用户地址空间的匿名页不可回收只有 swap 能腾出
Shmemtmpfs//dev/shm/匿名共享映射占的页(Mapped 是 Cached 的子集,含共享库)基本不可回收无 swap 时等价于匿名页
SlabSReclaimable + SUnreclaim 的总量见下两行是内核对象分配的总账
SReclaimabledentry/inode 等内核缓存对象可回收丢弃对象,下次访问重建(有 CPU 代价)
SUnreclaimtask_struct/mm_struct/socket 缓冲等不可回收只能减少对象数量
PageTables / KernelStack页表页 / 线程内核栈都不可回收页表与虚拟地址空间规模成正比(不是 RSS 的函数);内核栈量级 = 线程数 × 16 KB
Dirty / Writeback已改未回写 / 已提交块层未完成的页可回收必须先回写,一页 = 至少一次 I/O
Committed_AS已被承诺的地址空间总量不可回收(是承诺不是占用)与 CommitLimit 比较,见 12.5
CommitLimit严格模式下允许承诺的上限—SwapTotal + ratio% × (MemTotal − 大页)

Shmem 是这张表里最容易踩的坑。 Cached 的分子是 NR_FILE_PAGES,而它包含 Shmem:往 /dev/shm 写 4 GB,free 的 buff/cache 就涨 4 GB,看起来「反正是缓存,随时能丢」。但 tmpfs 的页没有磁盘后备,要腾出它只能 swap out,没有 swap 的机器上它与匿名页完全等价。

1
2
3
4
5
6
  MemAvailable 的估算形状(si_mem_available() 的思路):
    available  = MemFree − 保留水位;再 + = 文件 LRU − min(其中一半, 水位)
    available += 可回收 slab − min(其中一半, 水位)      ← 只认文件页与可回收 slab
  算例:MemTotal 16 GB、MemFree 200 MB、Cached 9 GB(含 Shmem 4 GB)、水位 300 MB
    乐观估算 ≈ 9.7 GB(所以「只剩 200 MB」是错的);扣掉无 swap 时不可回收的
    Shmem 4 GB 后,真实可用于大额新分配的只有 ≈ 5.7 GB;SUnreclaim 越大越会高估
1
2
3
4
# 一次看全分类账:可回收项与不可回收项并列,而不是只看 free 的三行
grep -E '^(MemFree|MemAvailable|Buffers|Cached|Shmem|Dirty|Writeback|SReclaimable|SUnreclaim|PageTables|KernelStack|AnonPages|Committed_AS|CommitLimit)' /proc/meminfo
# `free` 的 buff/cache = Buffers + Cached + SReclaimable,来源不同,别当成一类;只看 free 三行会误判
free -m

重要

free 的 used 由 MemTotal − MemFree − Buffers − Cached − SReclaimable 反推,公式里没有 Shmem,所以 tmpfs 被算进了「缓存」;而 available 直接取 MemAvailable。同一个命令的两列对 tmpfs 态度不一致,这就是「free 输出自相矛盾」的根因。

12.2 page cache 与 buffer:从两套缓存到一份页

buffer cache 与 page cache 曾是两套独立缓存:前者以块设备块为索引,后者以文件偏移为索引,同一份数据可能同时存在于两者,浪费内存且难保一致。Linux 2.4 把两者合并,buffer cache 退化为「块设备页与文件系统元数据」的记账项,即今天的 Buffers;2.6 之后回写改由 per-BDI 的 flusher 线程承担,不再依赖单一 pdflush。合并前的痛点正是「同一份数据可能两份副本,一致性与内存双重浪费」。

page cache 的双重身份有两层读法,两层层面试官都爱追问。第一层,按后备存储分:文件页(file-backed,挂在 address_space 上有磁盘后备)与匿名页(anonymous,无后备,需要 swap)。Shmem 站在中间——记账上它是文件页(进 Cached),回收路径上它按可换出的匿名页处理(需要 swap),所以「page cache 里全是随时可丢的缓存」这个前提本身就是错的。第二层,按角色分:cache 一词暗示「磁盘是真身,内存只是副本」,而写路径恰恰相反——write 先把数据写进 page cache 并标脏,在回写完成前,文件的最新内容只存在于内存里,磁盘上是旧数据。page cache 不是「缓存」,它是文件在内存中的规范表示(canonical copy),磁盘副本反而滞后。这正是 fsync 存在的理由,也是掉电丢数据的窗口来源。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  同一份 4 KB 文件页:两次 read 与一次 mmap 的路径差异
  第一次 read(fd, buf, 4096):sys_read → vfs_read 在 address_space(xarray)里查页
      未命中 ──→ 分配 page cache 页 + 预读(read_ahead_kb 默认 128 KB)──→ 块层
              ──→ 设备:SSD 随机读约 100 μs,机械盘 5~10 ms → 填页
    命中后 ──→ copy_to_user 到用户缓冲区(≈ 0.4 μs);总代价 ≈ I/O 延迟 + 微秒级拷贝
  第二次 read:查页命中,只剩 copy_to_user(系统调用 0.1~1 μs + 拷贝 0.4 μs)
  mmap:mmap() 只建 VMA;首次访问缺页 → filemap_fault → 同一个 address_space
    未命中同样走预读填页;命中后把该页直接映射进页表(无内核→用户拷贝)
  结论:read 与 mmap 填的是同一份 page cache 页,区别只在「数据怎么交给用户」:
    read 是拷贝;mmap 是映射,代价换成缺页 + 页表项,文件被截断时会收到 SIGBUS

注意

「第一次慢、第二次快」的差距来自「一次设备 I/O」与「一次内存拷贝」的物理差距,约 3~5 个数量级,不是 CPU 快慢造成的。两个反直觉点:预读让顺序读的「第一次」也不一定慢;而 mmap 省掉拷贝的代价是缺页与 SIGBUS 风险。

12.3 脏页与回写:延迟毛刺的真正来源

1
2
3
4
5
6
7
  路径 A:write() 写文件
    write → 把用户数据拷进 page cache 页 → 该页被标记 PG_dirty(磁盘上仍是旧数据)
  路径 B:mmap 之后写内存
    首次写 → 页表项只读 → 触发写保护缺页,内核看这份页的共享情况:
               ├ MAP_SHARED 且仅本进程映射 → 清只读位直接写,页保持 PG_dirty
               └ MAP_PRIVATE 或被多进程映射 → 复制私有副本(COW,见第 4 章 4.3 节)
    两条路径最终都把「同一份文件页」变脏,区别只在是否发生 COW。
触发条件默认值谁在执行后果
vm.dirty_background_ratio(或 _bytes)10(%)内核 flusher 线程后台异步回写,应用不被阻塞,但设备带宽被占走
vm.dirty_ratio20(%)触发写操作的进程自己分配新脏页前先同步回写,write 延迟从 μs 跳到 ms~秒
vm.dirty_expire_centisecs / _writeback_centisecs3000(30 s)/ 500(5 s)flusher 线程前者是脏页最长寿命;后者只是唤醒周期,不等于「必须写」
内存压力 / fsync、fdatasync、sync、msync—回收路径与应用自己前者让申请者同步等回写(wa 高与长尾的来源);后者精确控制持久化点,但会形成「fsync 风暴」
1
2
3
4
5
6
  16 GB 内存、background=10%、ratio=20%;盘顺序写 200 MB/s,应用写脏页 400 MB/s
  3.2 GB ┤          ┌┐  ← ratio 触顶:写进程被强制同步回写,write 跳到百 ms~秒
  1.6 GB ┤ ╱╲  ╱╲ ╱  ╲  ← background 触顶:唤醒 flusher,应用不受阻
      0  └────────────────→ 时间
  周期估算:1.6 GB ÷ 400 MB/s = 4 s 到 background 线;之后净积累 400−200 = 200 MB/s,
            3.2 GB ÷ 200 MB/s = 16 s 到 ratio 线;两条线交替 → 锯齿周期约 8 s

sleep 之外的元凶往往就是它:write/fsync 的 P99 出现周期性尖峰时,先核对 dirty_ratio 与设备顺序写带宽是否匹配。fsync 风暴是同一机制的另一面:多个线程各自 fsync,每个都要求「我这份数据落盘」,内核为满足它们把随机小 I/O 塞满设备队列,所有调用者的延迟一起劣化。对策是把持久化点合并(组提交)、把 fsync 从每写一次降到每批一次,必要时用 sync_file_range 把「提交到块层」与「等待落盘」拆开。

O_DIRECT 为什么绕过 page cache,有三条彼此独立的理由:一是消掉双重缓冲,数据由设备 DMA 直接落到用户缓冲区,省掉「内核页→用户缓冲区」的拷贝与一份等量物理内存;二是把回写时机拿回自己手里,数据库自带 WAL 与刷盘策略,不需要内核按 dirty_ratio 替它决定何时写盘;三是避免污染缓存,全表扫描、备份这类只读一次的大 I/O 会把热页挤出去。代价同样明确:失去预读与缓存命中,地址、长度、偏移必须按逻辑块对齐(通常 512 B 或 4 KB),非对齐小 I/O 退化为同步等待;而且它依然经过块层与 I/O 调度器,它绕过的是 page cache,不是内核。

1
2
3
4
5
# Dirty 是"欠磁盘的账",Writeback 是"已在路上的账",两者同时高说明回写跟不上写入
grep -E '^(Dirty|Writeback|Cached|MemFree|MemAvailable)' /proc/meminfo
# 瞬时值会抖动,累计计数才说明"是不是一直在回写";ratio 决定刹车点,expire 决定寿命
grep -E 'nr_dirty|nr_writeback|nr_dirtied|nr_written|pgpgout' /proc/vmstat
sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs

警告

「fsync 返回就代表数据到了磁盘」是错的。fsync 保证的是「数据已提交给设备并按要求等待设备完成」,设备自身易失写缓存是否真的落盘,取决于设备是否支持并启用了 FLUSH/FUA。所以「fsync 之后仍可能丢数据」是真实存在的工程问题。

12.4 OOM killer:选择算法与日志读法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  分配请求 → zone 水位检查:free < low 就唤醒 kswapd(后台异步回收,不阻塞申请者)
        ├─ 回收出足够页 → 分配成功
        └ free < min → 直接回收(direct reclaim):在申请者上下文里同步跑
             ├─ 回收成功 → 分配成功,但耗时从 μs 涨到 ms 级(抖动)
             └ 回收失败(干净文件页丢完、匿名页无 swap、slab 不可回收)
                  → out_of_memory():全局 OOM 候选集 = 所有可杀进程
                                     memcg OOM 候选集 = 该 cgroup 内进程
                       → 按 oom_badness() 打分,最高者收 SIGKILL;oom_reaper 随后
                         异步回收其内存(4.13+),不必等它自己退出
  oom_badness()(mm/oom_kill.c):points = RSS页数 + swapents页数 + 页表页数
    adj = p->signal->oom_score_adj(取值 [−1000, 1000]);points += adj × totalpages / 1000
  对外可见的 oom_score = points × 1000 / totalpages
  即:oom_score ≈ (RSS + swap + 页表)页数 / 总页数 × 1000 + oom_score_adj
  两条边界:adj == −1000 → 直接返回 LONG_MIN,永久免杀;adj == +1000 → 请优先杀我
计入项是否进入 oom_badness()设计动机 / 陷阱
RSS(get_mm_rss)是直接占用不可回收内存,是「内存大户」的主要信号
swapents(MM_SWAPENTS)是否则一个把 8 GB 换出去的进程会显得比 1 GB 的进程「更轻」,而它仍占着 swap
页表(mm_pgtables_bytes / PAGE_SIZE)是页表既不可回收又常被忽略;大地址空间进程的页表可达数百 MB
slab(task_struct、socket 缓冲等)否流传最广的误传。slab 大户不会被惩罚,只能靠 oom_score_adj 手工补偿
oom_score_adj是(线性加到页数上)唯一的运维控制杆;−1000 是硬免杀。nice 不参与;内核线程与正在退出的进程由 oom_unkillable_task 排除

为什么「占内存最多的进程」通常被杀:oom_score 对 RSS 与 swap 单调递增,大户分数天然最高。但「通常」不等于「必然」——大户把 oom_score_adj 设成 −1000、或者它在受限 cgroup 内(memcg OOM 只看 cgroup 内部,宿主还有几十 GB 空闲也会杀它)、或者触发了 memory.oom.group 整组杀,都会破坏这个直觉。

1
2
3
4
5
6
7
# oom_score 是"结果",oom_score_adj 是"输入":能调的只有后者
cat /proc/1234/oom_score /proc/1234/oom_score_adj
# −1000 让内核直接跳过它(数据库/缓存进程常用);+500 表示"宁可先杀我"。
# 该值会被 fork/exec 继承,所以调父进程往往能覆盖整棵子进程树
echo -1000 > /proc/1234/oom_score_adj
# 不改进程而改 systemd 单元:OOMScoreAdjust=500 重启后仍生效;choom -p 1234 是等价入口
systemctl edit app.service

旧的 oom_adj(−17~15)已废弃,内核只保留线性映射。memory.oom.group 是 cgroup v2 的补充:置 1 后整个 cgroup 被当作整体杀掉,避免「杀掉一个 worker、剩下的继续在同一限额里互抢、反复 OOM」的半死状态。

dmesg 里的 OOM 日志按四段读:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  # 第 1 段:谁触发了分配失败 —— 触发者不一定是被杀者
  [12345.678901] java invoked oom-killer: gfp_mask=0x140dca(GFP_HIGHUSER_MOVABLE), order=0
  # 第 2 段:当时的内存全貌 —— 判断"是真没内存"还是"全是不可回收的匿名页"
  [12345.678920] Mem-Info: active_anon:2100000 inactive_anon:1500000 active_file:120000
                 inactive_file:90000 dirty:1200 writeback:340; Normal free:12000kB low:12000kB
  # 第 3 段:这是哪种 OOM、哪个 cgroup、牺牲者是谁
  [12345.678970] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,
                 mems_allowed=0,global_oom,task_memcg=/system.slice/app.service,
                 task=java,pid=4321,uid=1000
  # 第 4 段:牺牲者的内存账 —— anon-rss 才是真正被吃掉的量
  [12345.678980] Out of memory: Killed process 4321 (java) anon-rss:7800000kB, file-rss:20000kB
                 pgtables:16000kB oom_score_adj:0;随后 oom_reaper: now anon-rss:0kB

constraint 字段直接决定「这是哪种 OOM」,也是判断容器问题最快的入口:CONSTRAINT_NONE 配 global_oom 表示整机耗尽,候选集是所有可杀进程;CONSTRAINT_MEMCG 表示某 cgroup 的 memory.max 触顶,候选集只是该 cgroup 内的进程;CONSTRAINT_MEMCG_GROUP_OOM 表示 memory.oom.group=1 生效;CONSTRAINT_CPUSET 表示该 cpuset/nodemask 允许的节点耗尽。在此之前日志还会打印一张 Tasks state (memory values in pages) 表,列出每个候选进程当时的 rss、pgtables_bytes、swapents 与 oom_score_adj——这正是把上面的打分公式变成可验证事实的地方。

小心

最容易把答案答崩的一句是「OOM killer 杀掉内存占用最多的进程」。准确表述是:在候选集内按 oom_badness() 打分,oom_score_adj 为 −1000 的进程被排除,分数最高者被杀;而候选集在 cgroup 场景下只是该 cgroup 内的进程。 面试官会接着追问「如果所有候选都是 −1000 怎么办」——内核仍会勉强选一个,或走 panic_on_oom,所以不要给所有关键业务都设 −1000。

12.5 vm.overcommit_memory:malloc 成功不等于内存可得

模式__vm_enough_memory() 的行为适用场景代价
0(启发式,默认)请求量 > MemFree + 文件页缓存 − Shmem + 可回收 slab + swap 时拒绝绝大多数服务只挡得住明显不可能的请求,防不住 OOM
1(总是允许)除明显无效请求(超过 TASK_SIZE 等)一律通过需要 fork 大进程、机器学习/大数据任务完全失去分配防护,OOM 推迟到真正缺页时才发生
2(严格)Committed_AS + 请求 > CommitLimit 即返回 ENOMEM需要「分配即确定」的多租户环境极易误伤正常程序,必须按实际用量调 overcommit_ratio
1
2
3
4
5
6
  CommitLimit 的算法(只在 mode 2 下是真上限):
    若 vm.overcommit_kbytes ≠ 0:CommitLimit = SwapTotal + overcommit_kbytes
    否则:CommitLimit = SwapTotal + (MemTotal − 大页) × overcommit_ratio/100(默认 50)
  例:MemTotal 16 GB、SwapTotal 4 GB、ratio 50、无大页 → CommitLimit = 4 + 8 = 12 GB
      若 Committed_AS = 11.5 GB,进程再 mmap 1 GB 私有可写映射 → 直接 ENOMEM
      (进程实际用了多少内存,这个检查完全不看)

根因在于「分配」与「可用」是两件事:malloc/mmap 只改地址空间记账(VMA 与页表雏形),物理页要等第一次写入触发缺页时才真正分配(见第 8 章 8.3 节)。所以「申请 8 GB 只用 1 MB」完全没问题:超过 mmap_threshold(默认 128 KB)的请求走 mmap(MAP_PRIVATE|MAP_ANONYMOUS)(见第 8 章 8.8 节),只建 VMA 不分配物理页,Committed_AS 已 +8 GB 而 MemFree 几乎没变,只有被写到的那一页在缺页时占 4 KB。推论是:两个都申请 8 GB 且都真的写入的进程,必然有一个先触发 OOM。

fork 是同一机制的另一面,也是「为什么 fork 大进程之后写入才 OOM」的完整答案:

1
2
3
4
5
6
7
  父进程 RSS 8 GB,调用 fork():COW 的机制细节见第 4 章 4.3 节
    1) 复制页表 —— 页表页本身要分配,量级约 RSS/512 × 8 B ≈ 128 MB,可忽略但不为零,
       所以"fork 完全不要内存"也不准确
    2) 把父子两边所有匿名页的 PTE 都改成只读;物理页仍只有一份,引用计数 +1
  → fork() 立即返回,物理内存与 Committed_AS 都几乎没变
  → 父或子任一方写入 → 写保护缺页 → 分配新页并复制内容
  → 从这一刻起物理内存开始逼近 2 倍;可回收量不够,就在这里 OOM

Redis 官方文档(持久化章节)明确建议在需要 BGSAVE 的机器上设置 vm.overcommit_memory=1,理由正是这条链路:严格模式下 fork 需要为新进程预留足够承诺额度,RSS 很大的实例会被直接拒绝,后台保存因此失败。反过来,模式 1 的代价也真实存在:malloc 会一路成功到缺页那一刻才失败,进程可能已经跑了很久才崩。

1
2
3
4
5
# 先确认模式与两个来源(overcommit_kbytes 优先于 overcommit_ratio);CommitLimit
# 与 Committed_AS 是同一本"承诺账"的两端:上限与已承诺
cat /proc/sys/vm/overcommit_memory /proc/sys/vm/overcommit_ratio /proc/sys/vm/overcommit_kbytes
grep -E '^(CommitLimit|Committed_AS|MemTotal|SwapTotal)' /proc/meminfo
sysctl -w vm.overcommit_memory=1   # 立即生效、重启失效;持久化写 /etc/sysctl.d/

12.6 swap 的取舍与 vm.swappiness 的真实含义

swap 不是「内存不足的补救」,而是「冷页的扩展层」。 回收的目标是把访问频率低的页腾出来给高频页;没有 swap 时匿名页就失去了全部回收路径,压力下唯一能动的只有文件页与可回收 slab,一旦文件页丢完,回收失败,直接进 OOM。有 swap 时冷匿名页可以写出去,物理内存这个维度被扩展为「物理内存 + swap 容量」,往往还能把 page cache 命中率抬起来——这才是 swap 的主要价值。真正让系统变慢的只有「热页被换出」:一次换入等于一次随机读,SSD 约 100 μs,机械盘 5~10 ms。

1
2
3
4
5
6
7
8
  内核 get_scan_count()(mm/vmscan.c)的核心两行:
      anon_prio = swappiness;   file_prio = 200 - anon_prio;
  含义:本轮要回收 N 页时,扫描匿名 LRU 与文件 LRU 的页数之比 ≈ anon_prio : file_prio。
  它是"两类页的相对权重",不是"用不用 swap 的开关"。
  关闭 swap 的后果:1) 匿名页失去回收路径,只剩文件页与可回收 slab 可动;2) 匿名页
  占大头的负载在文件页丢完后直接回收必然失败 → OOM 比有 swap 时更早;3) mode 2 下
  CommitLimit 直接减少整个 SwapTotal(12 GB → 8 GB);4) 页表与大页更难分配。
  换来的是延迟可预测(不会插入 100 μs~10 ms 的换入),但必须接受"OOM 更早"。
vm.swappiness匿名 : 文件 扫描权重实际表现
00 : 200尽量不扫匿名页,只在文件页实在不够时才动;不等于禁用 swap
101 : 19数据库常见的保守取值
60(默认)3 : 7通用折中
1001 : 1匿名与文件同等对待,即常规意义上的「最激进」
200200 : 05.x 起允许的极端值,几乎只扫匿名页(较老内核上限为 100)
1
2
3
4
5
# si/so 持续非零说明内存压力已经是"热"的;pgmajfault 同步上涨说明工作集装不下
vmstat 1
grep -E 'pswpin|pswpout|pgmajfault|pgsteal' /proc/vmstat
# 单个进程被换出多少:VmSwap 才是"它被换出过"的直接证据
grep -E 'VmRSS|VmSwap|RssAnon|RssFile|RssShmem' /proc/1234/status

警告

cgroup v2 没有 memory.swappiness(v1 曾有 memory.swappiness,但在 cgroup 上下文里语义不完整)。容器里想调整回收偏好只能用宿主的 vm.swappiness,或在 cgroup 内用 memory.swap.max、memory.high 间接控制。回答「在容器里把 memory.swappiness 设成 0」是错的。

12.7 内存泄漏与异常增长的定位

第一步不是找泄漏,而是分诊:RSS 涨有三种完全不同的原因,处置方式也完全不同。

1
2
3
4
5
6
7
8
9
  RSS 涨了
   ├─ Anonymous 与 Private_Dirty 同步、单调上升,跨过 GC/低峰期也不回落
   │    ──→ 真泄漏(堆、栈、JNI、native 库)
   ├─ File / Shared_Clean 上升,Anonymous 基本平
   │    ──→ page cache 或文件映射,不是泄漏;上限由内核压力自然约束
   ├─ Rss 平但 VmSize 猛涨,或 Rss 涨而 Anonymous 平
   │    ──→ glibc arena、线程栈(虚拟 8 MB/按需)、mmap 碎片
   └─ Shmem 涨 ──→ tmpfs、/dev/shm、匿名共享映射;无 swap 时等价于泄漏
  强证据是单调性:泄漏不是"一次快照很大",而是"跨过 GC 周期与业务低峰后仍然只涨不落"。
1
2
3
4
5
6
7
# smaps_rollup(4.14+)一次读出汇总值。为什么不用 smaps:几百 GB 堆的进程上它会输出
# 上万行并长时间持有 mmap_lock,采样本身就可能拖慢业务
grep -E '^(Rss|Private_Clean|Private_Dirty|Anonymous|Shmem):' /proc/1234/smaps_rollup
# 跨时间点重复采样:Anonymous 与 Private_Dirty 同步单调上升才是泄漏的形状
while true; do date; grep -E '^(Rss|Anonymous|Private_Dirty):' /proc/1234/smaps_rollup; sleep 60; done
# pmap -x 已按段列出 RSS/Dirty 并给出 total,最适合两次快照 diff
pmap -x 1234 > /tmp/pmap.1; sleep 300; pmap -x 1234 > /tmp/pmap.2; diff /tmp/pmap.1 /tmp/pmap.2

glibc malloc 的 arena 是「虚拟内存膨胀」最常见的解释:每个线程第一次分配内存时会绑定一个 arena,arena 总数上限是 8 × CPU 核数(64 位;32 位为 2 × 核数),可用 MALLOC_ARENA_MAX 限制。每个 arena 有自己的一片堆,free 之后内存通常留在 arena 的空闲链表里不还给内核——64 核机器上线程多、每个线程都分配过内存,就可能出现几十个 arena 各持有几十 MB 的局面,VmSize 几十 GB、RSS 也虚高。设 MALLOC_ARENA_MAX=2 或 4 是常见做法,代价是 malloc 锁竞争上升;malloc_trim(0)、malloc_info(3) 可用来观察与归还。

Java 侧的对账方式是两套数据交叉验证(堆内细节见第 32 章 32.1 节):

1
2
3
4
5
6
# 启动时加 -XX:NativeMemoryTracking=summary(约 5%~10% 开销)才能读下面这项;
# 它把 RSS 拆成 Java Heap / Class / Thread / Code / GC / Internal / Other 等
jcmd 1234 VM.native_memory summary
# 带 :live 会先触发一次 Full GC(STW),生产慎用;不带 :live 会把已是垃圾的对象
# 也统计进来 —— 这是"假泄漏"最常见的一种
jmap -histo:live 1234 | head -30

判据很简单:RSS 涨而 NMT Total 不涨,说明涨的不在 JVM 账上,要去查 glibc arena、线程栈、第三方 native 库;反之若 NMT 的 Other/Internal 持续上涨,通常指向 DirectByteBuffer、JNI 或未被跟踪的 native 分配。

手段能看到什么运行时开销适用边界
valgrind(memcheck/massif)越界、未初始化读、泄漏点与调用栈;堆快照时间序列10~50× 减速、内存 2×+单测/CI/可复现脚本;生产不可用
AddressSanitizer(-fsanitize=address)编译期插桩的越界、UAF、泄漏约 2× 减速、内存 2~3×预发/压测可用;必须重编译
smaps_rollup/pmap/malloc_info进程级内存分布,不改代码接近零(smaps 会短暂持锁)生产首选;只能说「哪个区在涨」,不能定位到代码行
perf/eBPF(kmem:kmalloc 等)分配点与调用栈的实时采样低~中生产可用;依赖内核版本与权限
jcmd Native Memory TrackingJVM 视角的 native 内存分类账5%~10%只覆盖 JVM 自己的分配

小心

valgrind 与 ASan 只跟踪经过 malloc/new 的分配(ASan 还插桩栈与全局变量),对 mmap 直接调用、JVM/Go 的 GC 堆、内核页缓存完全无效。把 valgrind 当「万能内存泄漏工具」,在 JVM/Go 服务上一次都查不出来。

12.8 cgroup 内存限制下的行为

cgroup v2 文件(v1 对应文件)语义触顶后的行为
memory.max(v1 memory.limit_in_bytes)硬限先在本 cgroup 内直接回收;回收失败才触发 cgroup OOM killer
memory.high软限/节流阈值不杀进程:超限分配被限速(申请者睡眠等回收),memory.events 的 high 计数增长
memory.swap.max(v1 memory.memsw.limit_in_bytes)swap 上限超限后匿名页不能被换出,等价于该 cgroup 内「不可回收匿名页」变多
memory.oom.group整组杀开关置 1 时 cgroup 内所有进程一起被杀,避免半死状态反复 OOM
memory.events事件计数high/max/oom/oom_kill/oom_group_kill;判断「被限流」还是「被杀」的唯一可靠依据
memory.stat 关键项(v2 名 / v1 名)读法
anon / rss匿名页(含 JVM 堆);它涨才是内存真的被吃掉
file / cache、shmempage cache 与 tmpfs、/dev/shm、匿名共享映射;都偏向可回收,涨落通常不决定 OOM
slab、sock、kernel_stack内核对象与 socket 缓冲(sock 高常见于连接数大);线程内核栈量级 = 线程数 × 16 KB
pgmajfault、workingset_refault主缺页次数;工作集被驱逐后又被访问——后者是「限额太小」最强的信号,比前者更准
pswpin/pswpout、oom/oom_kill该 cgroup 的换入换出量;触发 OOM 次数与被杀进程数

容器 OOM 与宿主 OOM 的关键区别不在打分算法,而在候选集与观测源:容器内 /proc/meminfo、free 显示的是宿主数据(/proc/meminfo 未做 namespace 隔离),所以「容器里看内存还剩几十 GB,容器却被 OOM 杀了」并不矛盾。容器内存的唯一权威来源是 cgroup 文件。

1
2
3
4
5
# 容器里判断内存,唯一权威来源是 cgroup,而不是 free / /proc/meminfo
cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current /sys/fs/cgroup/memory.events
grep -E '^(anon|file|slab|sock|workingset_refault|pgmajfault|oom_kill) ' /sys/fs/cgroup/memory.stat
# 反查某个进程属于哪个 cgroup:把它和 dmesg 里的 task_memcg 字段对上
cat /proc/1234/cgroup

提示

「memory.max 触顶是先回收还是直接 OOM」答三句话:charge 时先尝试本 cgroup 内的回收,回收不出来再走 memcg OOM killer;memcg OOM 的候选集只是该 cgroup 内的进程,仍然用 oom_badness() 打分;用 memory.events 的 max/oom/oom_kill 三个计数区分「只是触顶」还是「真的杀了人」。

本章小结

关键结论一句话表述常见错误
判读内存看可回收性MemFree 只说明「完全空闲」,Cached + SReclaimable 才是可回收池看到 MemFree 小就喊内存不足
Cached 含 Shmemtmpfs 没有磁盘后备,无 swap 时不可回收把 buff/cache 整块当成「随时能丢」
page cache 不是缓存脏页回写前,内存里才是文件的最新版本认为「写文件后磁盘就是新的」
回写毛刺有周期dirty_ratio 与设备带宽不匹配时产生周期性延迟尖峰把 write 的长尾归因到应用代码或 GC
O_DIRECT 绕过 page cache省一次拷贝与一份内存,换来对齐约束与失去预读说「O_DIRECT 绕过内核」或「一定更快」
oom_score 只看三项RSS + swap + 页表,再线性加 oom_score_adj;slab 不计入把 slab 也算进公式,或说「杀内存最多的」
malloc 成功不等于可用分配只记地址空间,物理页在首次写入的缺页里才分配用 malloc 返回值判断「内存够不够」
fork 后写入才 OOMCOW 只复制页表,物理页在任一方写入时才分裂说「fork 会立刻复制全部内存」
swappiness 是权重不是开关匿名页与文件页的扫描比是 swappiness : 200 − swappiness说「swappiness = 0 就是禁用 swap」
关 swap 让 OOM 更早匿名页失去回收路径,CommitLimit 也变小说「关掉 swap 能避免 OOM」
泄漏看单调性Anonymous 与 Private_Dirty 同步单调上升且不回落用一次快照的大小宣告泄漏

自测题

Q1(判断)一台 16 GB 机器上 free -m 显示 free 只有 200 MB、buff/cache 有 9.2 GB。同事说「内存只剩 200 MB,必须马上加内存」。请判断并给出核算过程与需要额外查的字段。

答案与解析

不对,但也不能简单说「缓存可以随时丢」。核算分三步:buff/cache = Buffers + Cached + SReclaimable,三项回收代价不同(干净页可直接丢弃,脏页必须先回写,可回收 slab 丢弃后要重建);关键补充字段是 Shmem——Cached 的分子 NR_FILE_PAGES 包含它,而 tmpfs 无磁盘后备、无 swap 时不可回收,若 Shmem 是 4 GB,真正可用于大额新分配的只有约 9.2 − 4 + 0.2 ≈ 5.4 GB;最后还要看 SUnreclaim 与 Mlocked,MemAvailable 不替你扣这两项。

错在哪:把「空闲」当成「可用」。面试官会追问「MemAvailable 可信吗」——答「它是内核按 MemFree − 保留水位 + 文件 LRU 的一半以上 + 可回收 slab 的一半以上 做的估算,看到 Shmem 大时必须手工打折」。

Q2(计算)MemTotal 16 GB、SwapTotal 4 GB、vm.overcommit_ratio = 50、无大页、vm.overcommit_kbytes = 0、vm.overcommit_memory = 2,当前 Committed_AS = 11.5 GB。此时 mmap 2 GB 私有可写匿名映射会成功吗?改成 mode 0 呢?

答案与解析

CommitLimit = SwapTotal + (MemTotal − 大页) × 50% = 4 + 8 = 12 GB;请求后 Committed_AS = 13.5 GB > 12 GB,mode 2 直接 ENOMEM,与此刻 MemFree 有多少完全无关。改成 mode 0 会成功:启发式只看 MemFree + 文件页缓存 − Shmem + 可回收 slab + swap 是否大于请求量,2 GB 轻松通过,而且物理页要等写入时才缺页分配——「申请成功」与「能用」是两件事。

面试官会追问「overcommit_ratio 为什么默认 50」——因为要留出内核自身、页表、slab 与不可回收匿名页的空间;设成 100 之后系统会大量出现「malloc 成功但缺页时被杀」。常见错法是把 CommitLimit 算成 MemTotal × ratio(漏掉 swap),或说「mode 2 检查实际空闲内存」。

Q3(推导)为什么 oom_badness() 要把 swapents(已换出的页)和页表页一起算进 oom_score?只算 RSS 会有什么后果?slab 为什么不在公式里?

答案与解析

swapents:只算 RSS 的话,一个把 8 GB 匿名页换出去的进程 RSS 可能只剩 1 GB,看起来「很轻」,但它仍占着 8 GB swap,正是压力的制造者;把它计入是让「内存占用」在两个物理介质上连续可比。页表页:页表既不可回收又容易被忽略,一个 map 了 1 TB 地址空间的进程 RSS 可能很小,但仅 PTE 数组就是 1 TB / 4 KB × 8 B = 2 GB,不把它计入这类进程就成了打不死的黑洞。slab 不计入——这是流传很广的误传;后果是 slab 大户(例如持有大量 socket 缓冲的进程)不会被惩罚,生产上只能用 oom_score_adj 手工补偿,或去查 SUnreclaim/sock 的归属。

面试官会追问「oom_score_adj 怎么加进去」——points += adj × totalpages / 1000,把 −1000~1000 折算成「总共多少页」再线性叠加,所以它是相对量:adj = 100 在 16 GB 与 1 TB 机器上代表的页数完全不同。

Q4(设计)一个 Java 服务 RSS 在两周内从 4 GB 涨到 12 GB,-Xmx 是 4 GB,从未 OOM。给出定位步骤,并说明每一步排除了什么。

答案与解析
  1. 分诊:grep -E '^(Rss|Anonymous|Private_Dirty|Shmem):' /proc/PID/smaps_rollup 跨低峰期采样多次。Anonymous 与 Private_Dirty 同步单调上升不回落 → 真泄漏;File/Shared_Clean 涨而 Anonymous 平 → page cache,不是泄漏;Rss 平而 VmSize 猛涨 → arena/线程栈/碎片。
  2. 排除 JVM 堆:-Xmx 给了堆上限,用 jcmd PID VM.native_memory summary 对账;若 NMT Total 远小于 RSS 增量,涨的不在 JVM 账上,直接排除 GC 堆、Metaspace、Code Cache。
  3. 怀疑 glibc arena:线程多时 arena 上限是 8 × 核数,free 后的内存留在 arena 不还内核;核对线程数与 MALLOC_ARENA_MAX,设 MALLOC_ARENA_MAX=2 复测。若 NMT 也涨,再用 jmap -histo:live PID 看堆内直方图(不带 :live 会把已是垃圾的对象也算进来,产生「假泄漏」)。
  4. 查堆外与定位到区:看 -XX:MaxDirectMemorySize、DirectByteBuffer 计数与 NMT 的 Other/Internal(这类分配 -Xmx 管不到);最后用两次 pmap -x diff 看是 [heap]、匿名 mmap 段还是某个共享库在涨。

加分点:全程不提 valgrind——它对 JVM 的 GC 堆无效(见 12.7 的边界表)。面试官会追问「jmap -histo:live 为什么生产慎用」——它会先触发一次 Full GC 造成 STW,大堆上是秒级停顿。

Q5(辨析)有人说「vm.swappiness = 0 就是不使用 swap」。请纠正,并解释为什么把 swap 完全关掉反而会让 OOM 来得更早。

答案与解析

swappiness 进入的是 get_scan_count() 的扫描权重:anon_prio = swappiness、file_prio = 200 − swappiness。它表达的是回收匿名页与回收文件页的相对代价权重,不是「允许或禁止使用 swap」;设为 0 只是把匿名页排到最后,当文件页已不够回收时内核依然会换出匿名页。真要不用 swap,只有删掉 swap 设备/文件。

关闭 swap 让 OOM 更早有两条独立链路:回收路径上匿名页失去唯一回收手段,匿名页占大头的负载在文件页与可回收 slab 丢完后直接回收必然失败,立刻 OOM;分配账本上 mode 2 的 CommitLimit = SwapTotal + ...,去掉 4 GB swap 就是上限直接少 4 GB。换来的收益是延迟可预测,不会突然插入一次 100 μs~10 ms 的换入——这是合理取舍,但要明确接受「OOM 更早」。

面试官会追问「数据库机器到底该不该开 swap」——参考答法:保留一小块 swap(物理内存的 5%~10%)并把 swappiness 调低,既给冷页一条出路,又不让热页被换出;纯关 swap 与放任大 swap 都是极端。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
free 显示 MemFree 只剩几百 MB,这台机器内存不够用了吗?可回收性分类答「是,赶紧加内存」——把「空闲」当「可用」。加分点:主动拆 Cached + Shmem + SReclaimable,指出 Shmem 无 swap 时不可回收12.1
OOM killer 会杀掉哪个进程?oom_badness() 与候选集答「杀内存占用最多的」——漏掉 oom_score_adj 与「cgroup 场景候选集只限本组」两个限定12.4
一个进程持续写文件,为什么写入延迟会周期性出现尖峰?脏页回写与 dirty_ratio答「磁盘瓶颈」——没说清 dirty_ratio 是让写进程自己同步回写,也算不出周期12.3
数据库为什么喜欢用 O_DIRECT?它比 buffered I/O 快吗?O_DIRECT 的动机与代价答「O_DIRECT 绕过内核所以更快」——绕过的是 page cache 不是内核;也答不出对齐要求与失去预读的代价12.3
oom_score 是怎么算出来的?slab 算不算?打分公式的精确形状答「RSS + swap + 页表 + slab」——slab 不在公式里;也说不出 adj × totalpages / 1000 的折算方式12.4
为什么 fork 一个大进程之后,要过一会儿才 OOM?COW 与 overcommit 的结合答「fork 立刻复制全部内存所以马上 OOM」——COW 只复制页表;也说不出 Redis BGSAVE 为什么要 overcommit_memory=112.5
vm.swappiness 是什么?设成 0 是不是就不用 swap 了?回收权重 vs 开关答「设成 0 就禁用 swap」——它是 anon : file 的扫描权重,设为 0 仍会换出;也说不出 200 这个量纲12.6
服务 RSS 一直在涨,你怎么判断是不是内存泄漏?分诊方法论答「跑 valgrind」——对 JVM/Go 无效;也说不出用 smaps_rollup 的 Anonymous/Private_Dirty 单调性做判据12.7
容器里 free 显示还有几十 GB 空闲,为什么容器被 OOM 杀了?cgroup 隔离与观测源答「free 数据错了」——/proc/meminfo 本就没做 namespace 隔离;权威来源是 memory.current/memory.max/memory.events12.8

延伸阅读

  • man 5 proc:/proc/meminfo 字段表、/proc/[pid]/oom_score 与 oom_score_adj、/proc/[pid]/smaps_rollup 的语义。
  • 内核文档 Documentation/admin-guide/sysctl/vm.rst:dirty_ratio、dirty_background_ratio、dirty_expire_centisecs、overcommit_memory、overcommit_ratio、swappiness 的官方定义与量纲。
  • 内核文档 Documentation/admin-guide/cgroup-v2.rst:memory.max、memory.high、memory.low、memory.swap.max、memory.oom.group、memory.stat 各字段的准确语义。
  • 内核源码 mm/oom_kill.c(oom_badness()、dump_header())与 mm/vmscan.c(get_scan_count()):12.4 与 12.6 的公式与日志格式出处。
  • man 2 open(O_DIRECT 对齐要求)、man 2 fsync(持久化语义边界)、man 3 malloc(MALLOC_ARENA_MAX、malloc_trim、malloc_info);Redis 官方文档「Persistence」章节的 vm.overcommit_memory 建议。
  • 交叉阅读:第 8 章 8.2 节、第 4 章 4.3 节、第 7 章 7.6 节、第 11 章 11.3 节、第 32 章 32.1 节。