卷 III · 幻境CH 15深度 15/24

内存的谎言:为什么 malloc 从不失败

你写了几年代码,检查过多少次 malloc 的返回值是不是 NULL在 Linux 上,那个检查几乎永远不会触发。而这不是因为内存充足——恰恰相反,它是内存不足时你的进程会被无声处决的原因。

overcommitVSZ / RSS / PSSOOM killercgroup 限制

一个能自己验证的实验

在一台 8 GB 内存的机器上跑这个:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
  size_t size = 100L * 1024 * 1024 * 1024;   // 100 GB
  char *p = malloc(size);
  printf("malloc(100GB) = %p\n", p);         // ← 会打印一个有效地址
  return 0;
}
$ ./a.out
malloc(100GB) = 0x7f2a4c000010

成功了。在一台 8 GB 的机器上,你「拿到」了 100 GB。

这不是 bug。第 12 章已经埋下了伏笔:malloc 只是在 vma 链表里记了一笔账,一个物理页都没分配。既然没分配,那有没有那么多内存,此刻根本不重要。

这个策略叫 overcommit(超额承诺),是 Linux 的默认行为。

为什么要这么设计

听起来很不负责任,但它有非常实在的理由:程序普遍申请得比用得多。

  • 你开一个 64 KB 的缓冲区,可能只用了前 200 字节。
  • 一个稀疏的哈希表申请了一大片,实际只填了几个槽。
  • fork 一个 4 GB 的进程:如果严格记账,内核必须立刻准备好另外 4 GB(万一子进程把每一页都写了呢)。但实际上子进程通常马上 exec,一页都不会写(第 13 章)。

最后一条是决定性的。没有 overcommit,Redis 的 bgsave、shell 的每一条命令、所有 fork 密集的程序都会频繁失败——哪怕系统内存充裕。

三档策略(/proc/sys/vm/overcommit_memory):

策略行为
0启发式(默认「明显离谱」的拒绝,其余放行。一次申请超过总内存 + swap 就拒
1总是同意永不拒绝。Redis 官方推荐这个——因为它的 fork 会被启发式误判
2严格记账承诺总量不超过 swap + 物理内存 × overcommit_ratio。超了就返回 NULL
▶ 动手 · 内存记账台

下面这台机器把四个内存指标摆在一起。操作建议:把「真正碰过的比例」拖到 10%–20%(这是很多服务的真实形态),再把进程数往上加,看什么时候翻车。然后切到「overcommit 关」,看 malloc 的行为怎么变。

四个数字,问「占多少内存」时先问是哪个

这是运维和性能分析里最常见的误读来源:

指标含义典型值差距什么时候看
VSZ申请了多少地址空间可能是 RSS 的几十倍★ 几乎没用。别拿它报警
RSS真的占了多少物理页基准有用,但共享页被重复计算
PSS共享页按使用者数均摊比 RSS 小★ 想知道「全系统真实占用」时用它
USS只算私有最小回答「杀掉它能腾出多少」

为什么 VSZ 那么虚?因为它把所有从没碰过的映射都算进去了:预留的堆、线程栈的虚拟大小、映射进来但只用了几页的共享库。一个 JVM 的 VSZ 可能是 20 GB,RSS 只有 2 GB——两个数字都没错,只是问的不是同一件事。

⚠ 千万别把 RSS 加起来

第 13 章说过了,这里再强调一次,因为它太常见了:

所有进程的 RSS 之和会远超物理内存,因为共享库和共享内存在每个进程里都算了一遍。用 smem 看 PSS,或者直接看 /proc/meminfo 的全局数字。

OOM killer:一次成功的分配被事后撤销

现在到了 overcommit 的代价。

内核承诺了超出实际的内存。如果大家真的都来兑现——所有进程都开始它们申请的内存——物理页会不够。这时候怎么办?

没法怎么办。缺页处理程序拿不到物理页,而它没有办法「返回一个错误」:触发它的只是一条 mov 指令,那条指令没有返回值的概念(第 13 章讲 SIGBUS 时是同一个困境)。

内核唯一能做的,是杀掉某个进程腾地方

◆ 为什么 OOM 永远无法被你的代码处理

把整条链串起来:

  1. malloc(1GB) 成功返回——内核只是记了个账。
  2. 你的代码检查了返回值,非 NULL,一切正常。
  3. 几分钟后,你执行 buf[i] = x——一行再普通不过的赋值。
  4. 触发缺页,内核发现没有物理页可分了。
  5. OOM killer 挑一个进程,发 SIGKILL

关键在于:你无法捕获 SIGKILL(第 21 章)。没有异常、没有返回值、没有回调、没有任何清理的机会。进程就是没了

所以 OOM 不是一次失败的分配,它是一次成功的分配在事后被撤销。这就是为什么你写再多 if (p == NULL) 也拦不住它。

它挑谁?

内核给每个进程算一个 oom_score,大致正比于它占了多少内存(/proc/PID/oom_score)。分最高的死。

你可以用 oom_score_adj(−1000 到 +1000)调整:

$ echo -1000 > /proc/1234/oom_score_adj   # 永远别杀它
$ echo 500 > /proc/5678/oom_score_adj     # 优先杀这个

但要小心「优先杀谁」这个直觉。OOM killer 倾向于杀内存占用最大的进程——而那往往正是你最重要的那个(数据库、主服务)。它按「杀了能腾出最多空间」来选,不按「谁最不重要」来选。

出事之后,证据在 dmesg 里:

$ dmesg | grep -i oom
[12345.678] Out of memory: Killed process 4242 (java)
            total-vm:8388608kB, anon-rss:6291456kB, file-rss:0kB, UID:1000
            pgtables:12800kB oom_score_adj:0

如果你的服务「莫名其妙消失了」而日志里什么都没有,第一件事就是看这里。没有 stack trace、没有 onDestroy、没有优雅关闭钩子——因为 SIGKILL 不给任何机会。

容器里的 OOM 是另一回事

这里有个很多人栽过的区分。容器内存超限时触发的是 cgroup 的 OOM,不是全局 OOM:

全局 OOMcgroup OOM
触发条件整机物理内存耗尽这个 cgroup 超过 memory.max
杀谁全局分数最高的只在这个 cgroup 内部挑
宿主受影响吗不受影响,宿主可能还很空闲
怎么看dmesgmemory.events 里的 oom_kill 计数

这解释了那个经典的困惑:「宿主机内存明明还剩一半,我的容器却 OOM 了」——因为它撞的是自己的天花板,跟宿主还剩多少毫无关系。

⚠ 容器里最常见的 OOM 剧本

你给容器限了 512 MB,给 JVM 设了 -Xmx256m,然后还是被杀了。因为堆只是 RSS 的一部分

组成部分受 -Xmx 管吗典型大小
Java 堆256 MB
Metaspace(类元数据)不管50–200 MB
线程栈(每线程 1 MB 虚拟)不管按实际使用,几十 MB
JIT 代码缓存不管几十 MB
GC 自身的数据结构不管堆的 5%–20%
NIO 直接内存 / Netty 池不管可能很大

加起来轻松超过 512 MB。容器限的是 RSS,而 -Xmx 只管得到其中一块。

现代 JVM(JDK 10+)会自动读 cgroup 限制并据此设置堆大小(-XX:+UseContainerSupport,默认开),这缓解了问题,但你仍然需要给非堆部分留出余量——经验值是-Xmx 设成容器限制的 50%–70%,而不是 80%+。

↑ 回到应用层

「Android 的低内存杀手,和这个是一回事吗?」

思路一样,实现不同,而且 Android 的做法更聪明。

内核的 OOM killer 是最后一道防线——等到内存真的耗尽才动手,那时系统已经在颠簸了(第 12 章),用户体验早就崩了。

Android 的 lmkd预防性的:它通过 PSI(Pressure Stall Information)监控内存压力,在系统开始变卡之前就动手杀后台进程。分数来自 Activity 生命周期——空进程最先死,前台进程最后死。

这就是为什么 onSaveInstanceState 那么重要,也是为什么 Android 敢让每个 App 一个进程:它假设进程随时会消失,并把这个假设写进了整个生命周期 API 里。

这是个很值得学的设计思路:与其在资源耗尽时崩溃,不如提前主动降级,并让上层 API 承认这件事会发生。

这一章的一句话

Linux 默认超额承诺内存,所以 malloc 几乎从不失败——这让 fork 和稀疏分配变得可行。代价是内存真的不够时,进程不是在分配时优雅失败,而是在某次普通的赋值语句上被 SIGKILL 无声处决:一次成功的分配被事后撤销,而 SIGKILL 无法被捕获。

卷 III 到此结束。第二重假象拆完了——你现在知道自己那些地址是编的、内存是赊的、连「分配成功」都只是一句承诺。

下一卷拆第三重:你以为你在读一个文件,其实你只是拿到了一个号码。