第 12 章 内存、OOM 与 Swap
同样是「内存不够」,内核可能有五种反应:丢缓存、同步回写、限流、杀进程、或让
malloc失败。本章把meminfo、脏页回写、OOM 选择算法、overcommit 与 cgroup 限额串成一条能观测、能验证的链路。
学习目标
读完本章,你应当能够:
- 逐字段判别
/proc/meminfo中哪些内存可回收、哪些不可回收,并说明为什么「MemFree只剩 200 MB」通常是个假警报。 - 解释 page cache「既是缓存又是文件的唯一内存副本」这一双重身份,并说明
read与mmap为什么落在同一份页上。 - 用
dirty_ratio/dirty_background_ratio与设备带宽推出回写毛刺的周期,并说清O_DIRECT绕过了什么、代价是什么。 - 按
oom_badness()推算任意进程的oom_score,把dmesg里 OOM 日志读到字段级,并判断它属于哪种 OOM。 - 准确复述
vm.overcommit_memory三种模式,用「fork之后写入才 OOM」解释「malloc成功不等于内存可得」。 - 用
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 | 内核可管理的物理内存 | — | 基准量,不等于插满的容量 |
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 的机器上它与匿名页完全等价。
| |
| |
重要
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 存在的理由,也是掉电丢数据的窗口来源。
| |
注意
「第一次慢、第二次快」的差距来自「一次设备 I/O」与「一次内存拷贝」的物理差距,约 3~5 个数量级,不是 CPU 快慢造成的。两个反直觉点:预读让顺序读的「第一次」也不一定慢;而 mmap 省掉拷贝的代价是缺页与 SIGBUS 风险。
12.3 脏页与回写:延迟毛刺的真正来源
| |
| 触发条件 | 默认值 | 谁在执行 | 后果 |
|---|---|---|---|
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 风暴」 |
| |
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,不是内核。
| |
警告
「fsync 返回就代表数据到了磁盘」是错的。fsync 保证的是「数据已提交给设备并按要求等待设备完成」,设备自身易失写缓存是否真的落盘,取决于设备是否支持并启用了 FLUSH/FUA。所以「fsync 之后仍可能丢数据」是真实存在的工程问题。
12.4 OOM killer:选择算法与日志读法
| |
| 计入项 | 是否进入 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 整组杀,都会破坏这个直觉。
| |
旧的 oom_adj(−17~15)已废弃,内核只保留线性映射。memory.oom.group 是 cgroup v2 的补充:置 1 后整个 cgroup 被当作整体杀掉,避免「杀掉一个 worker、剩下的继续在同一限额里互抢、反复 OOM」的半死状态。
dmesg 里的 OOM 日志按四段读:
| |
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 |
| |
根因在于「分配」与「可用」是两件事: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」的完整答案:
| |
Redis 官方文档(持久化章节)明确建议在需要 BGSAVE 的机器上设置 vm.overcommit_memory=1,理由正是这条链路:严格模式下 fork 需要为新进程预留足够承诺额度,RSS 很大的实例会被直接拒绝,后台保存因此失败。反过来,模式 1 的代价也真实存在:malloc 会一路成功到缺页那一刻才失败,进程可能已经跑了很久才崩。
| |
12.6 swap 的取舍与 vm.swappiness 的真实含义
swap 不是「内存不足的补救」,而是「冷页的扩展层」。 回收的目标是把访问频率低的页腾出来给高频页;没有 swap 时匿名页就失去了全部回收路径,压力下唯一能动的只有文件页与可回收 slab,一旦文件页丢完,回收失败,直接进 OOM。有 swap 时冷匿名页可以写出去,物理内存这个维度被扩展为「物理内存 + swap 容量」,往往还能把 page cache 命中率抬起来——这才是 swap 的主要价值。真正让系统变慢的只有「热页被换出」:一次换入等于一次随机读,SSD 约 100 μs,机械盘 5~10 ms。
| |
vm.swappiness | 匿名 : 文件 扫描权重 | 实际表现 |
|---|---|---|
| 0 | 0 : 200 | 尽量不扫匿名页,只在文件页实在不够时才动;不等于禁用 swap |
| 10 | 1 : 19 | 数据库常见的保守取值 |
| 60(默认) | 3 : 7 | 通用折中 |
| 100 | 1 : 1 | 匿名与文件同等对待,即常规意义上的「最激进」 |
| 200 | 200 : 0 | 5.x 起允许的极端值,几乎只扫匿名页(较老内核上限为 100) |
| |
警告
cgroup v2 没有 memory.swappiness(v1 曾有 memory.swappiness,但在 cgroup 上下文里语义不完整)。容器里想调整回收偏好只能用宿主的 vm.swappiness,或在 cgroup 内用 memory.swap.max、memory.high 间接控制。回答「在容器里把 memory.swappiness 设成 0」是错的。
12.7 内存泄漏与异常增长的定位
第一步不是找泄漏,而是分诊:RSS 涨有三种完全不同的原因,处置方式也完全不同。
| |
| |
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 节):
| |
判据很简单: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 自己的分配 |
小心
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 文件。
| |
提示
「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,必须马上加内存」。请判断并给出核算过程与需要额外查的字段。
答案与解析
不对,但也不能简单说「缓存可以随时丢」。核算分三步: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。给出定位步骤,并说明每一步排除了什么。
答案与解析
- 分诊:
grep -E '^(Rss|Anonymous|Private_Dirty|Shmem):' /proc/PID/smaps_rollup跨低峰期采样多次。Anonymous与Private_Dirty同步单调上升不回落 → 真泄漏;File/Shared_Clean涨而Anonymous平 → page cache,不是泄漏;Rss平而VmSize猛涨 → arena/线程栈/碎片。 - 排除 JVM 堆:
-Xmx给了堆上限,用jcmd PID VM.native_memory summary对账;若 NMTTotal远小于 RSS 增量,涨的不在 JVM 账上,直接排除 GC 堆、Metaspace、Code Cache。 - 怀疑 glibc arena:线程多时 arena 上限是
8 × 核数,free后的内存留在 arena 不还内核;核对线程数与MALLOC_ARENA_MAX,设MALLOC_ARENA_MAX=2复测。若 NMT 也涨,再用jmap -histo:live PID看堆内直方图(不带:live会把已是垃圾的对象也算进来,产生「假泄漏」)。 - 查堆外与定位到区:看
-XX:MaxDirectMemorySize、DirectByteBuffer计数与 NMT 的Other/Internal(这类分配-Xmx管不到);最后用两次pmap -xdiff 看是[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=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 节。