# 第 12 章 内存、OOM 与 Swap


> 同样是「内存不够」，内核可能有五种反应：丢缓存、同步回写、限流、杀进程、或让 `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` 只统计完全没有被任何用途占用的页，它把「内核随时能扔掉或写回去的页」也排除在外。判读内存的唯一正确姿势是先按**回收代价**分类，而不是看空闲多少。

```
                        MemTotal（内核可管理的物理内存，硬件保留已扣除）
┌──────────────────────────────────────────────────────────────────────┐
│ 不可回收：AnonPages │ PageTables │ KernelStack │ SUnreclaim │ Mlocked  │
│           HugePages │ Shmem（无 swap 时不可回收）                    │
├──────────────────────────────────────────────────────────────────────┤
│ 可回收但有代价：Cached(文件页) │ Buffers │ SReclaimable               │
│   干净页 → 直接丢弃，零 I/O；脏页 → 必须先回写，代价是一次 I/O       │
├──────────────────────────────────────────────────────────────────────┤
│ MemFree：完全空闲的页                                                │
└──────────────────────────────────────────────────────────────────────┘
```

| 字段 | 含义 | 可回收性 | 回收路径与代价 |
|---|---|---|---|
| `MemTotal` | 内核可管理的物理内存 | — | 基准量，不等于插满的容量 |
| `MemFree` | 完全空闲的页 | 已空闲 | — |
| `MemAvailable` | 可分配给新用途的**估算**值（4.14+） | 已是净值 | 估算口径，见下方算例 |
| `Buffers` | 块设备页缓存（文件系统元数据、裸设备读写） | 可回收 | 干净页直接丢；脏页需回写 |
| `Cached` | `NR_FILE_PAGES − SwapCached − Buffers`，**包含 `Shmem`** | 大部分可回收 | 干净页丢弃、脏页回写；`Shmem` 需 swap 才能腾出 |
| `SwapCached` | 既在 swap 又在内存的页（换回后未被改写的两份副本） | 可丢弃 | 直接丢内存副本，这正是它存在的意义 |
| `Active`/`Inactive`(anon/file) | LRU 四条链表的页数 | — | 决定回收顺序，不是占用分类 |
| `Unevictable`/`Mlocked` | `mlock` 锁住的页 | **不可回收** | 只能 `munlock` |
| `AnonPages` | 映射进用户地址空间的匿名页 | 不可回收 | 只有 swap 能腾出 |
| `Shmem` | `tmpfs`/`/dev/shm`/匿名共享映射占的页（`Mapped` 是 `Cached` 的子集，含共享库） | **基本不可回收** | 无 swap 时等价于匿名页 |
| `Slab` | `SReclaimable + SUnreclaim` 的总量 | 见下两行 | 是内核对象分配的总账 |
| `SReclaimable` | dentry/inode 等内核缓存对象 | 可回收 | 丢弃对象，下次访问重建（有 CPU 代价） |
| `SUnreclaim` | `task_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 的机器上它与匿名页完全等价。

```text
  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 越大越会高估
```

```bash
# 一次看全分类账：可回收项与不可回收项并列，而不是只看 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
```

> [!IMPORTANT]
> `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` 存在的理由，也是掉电丢数据的窗口来源。

```text
  同一份 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
```

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

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

```text
  路径 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_ratio` | 20（%） | **触发写操作的进程自己** | 分配新脏页前先同步回写，`write` 延迟从 μs 跳到 ms~秒 |
| `vm.dirty_expire_centisecs` / `_writeback_centisecs` | 3000（30 s）/ 500（5 s） | flusher 线程 | 前者是脏页最长寿命；后者只是唤醒周期，不等于「必须写」 |
| 内存压力 / `fsync`、`fdatasync`、`sync`、`msync` | — | 回收路径与应用自己 | 前者让申请者同步等回写（`wa` 高与长尾的来源）；后者精确控制持久化点，但会形成「fsync 风暴」 |

```
  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，不是内核**。

```bash
# 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
```

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

## 12.4 OOM killer：选择算法与日志读法

```text
  分配请求 → 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` 整组杀，都会破坏这个直觉。

```bash
# 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 日志按四段读：

```text
  # 第 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`——这正是把上面的打分公式变成可验证事实的地方。

> [!CAUTION]
> 最容易把答案答崩的一句是「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` |

```text
  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」的完整答案：

```text
  父进程 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` 会一路成功到缺页那一刻才失败，进程可能已经跑了很久才崩。

```bash
# 先确认模式与两个来源（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。

```text
  内核 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` | 匿名 : 文件 扫描权重 | 实际表现 |
|---|---|---|
| 0 | 0 : 200 | 尽量不扫匿名页，只在文件页实在不够时才动；**不等于禁用 swap** |
| 10 | 1 : 19 | 数据库常见的保守取值 |
| 60（默认） | 3 : 7 | 通用折中 |
| 100 | 1 : 1 | 匿名与文件同等对待，即常规意义上的「最激进」 |
| 200 | 200 : 0 | 5.x 起允许的极端值，几乎只扫匿名页（较老内核上限为 100） |

```bash
# si/so 持续非零说明内存压力已经是"热"的；pgmajfault 同步上涨说明工作集装不下
vmstat 1
grep -E 'pswpin|pswpout|pgmajfault|pgsteal' /proc/vmstat
# 单个进程被换出多少：VmSwap 才是"它被换出过"的直接证据
grep -E 'VmRSS|VmSwap|RssAnon|RssFile|RssShmem' /proc/1234/status
```

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

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

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

```
  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 周期与业务低峰后仍然只涨不落"。
```

```bash
# 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 节）：

```bash
# 启动时加 -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 Tracking | JVM 视角的 native 内存分类账 | 5%~10% | 只覆盖 JVM 自己的分配 |

> [!CAUTION]
> `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`、`shmem` | page 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 文件。

```bash
# 容器里判断内存，唯一权威来源是 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
```

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

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 判读内存看可回收性 | `MemFree` 只说明「完全空闲」，`Cached + SReclaimable` 才是可回收池 | 看到 `MemFree` 小就喊内存不足 |
| `Cached` 含 `Shmem` | tmpfs 没有磁盘后备，无 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` 后写入才 OOM | COW 只复制页表，物理页在任一方写入时才分裂 | 说「`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，必须马上加内存」。请判断并给出核算过程与需要额外查的字段。
<details><summary>答案与解析</summary>

不对，但也不能简单说「缓存可以随时丢」。核算分三步：`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` 大时必须手工打折」。

</details>

**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 呢？
<details><summary>答案与解析</summary>

`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 检查实际空闲内存」。

</details>

**Q3**（推导）为什么 `oom_badness()` 要把 swapents（已换出的页）和页表页一起算进 `oom_score`？只算 RSS 会有什么后果？slab 为什么不在公式里？
<details><summary>答案与解析</summary>

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 机器上代表的页数完全不同。

</details>

**Q4**（设计）一个 Java 服务 RSS 在两周内从 4 GB 涨到 12 GB，`-Xmx` 是 4 GB，从未 OOM。给出定位步骤，并说明每一步排除了什么。
<details><summary>答案与解析</summary>

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，大堆上是秒级停顿。

</details>

**Q5**（辨析）有人说「`vm.swappiness = 0` 就是不使用 swap」。请纠正，并解释为什么把 swap 完全关掉反而会让 OOM 来得更早。
<details><summary>答案与解析</summary>

`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 都是极端。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| `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=1` | 12.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.events` | 12.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 节。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch12/  

