内存的谎言:为什么 malloc 从不失败
你写了几年代码,检查过多少次 malloc 的返回值是不是 NULL?在 Linux 上,那个检查几乎永远不会触发。而这不是因为内存充足——恰恰相反,它是内存不足时你的进程会被无声处决的原因。
一个能自己验证的实验
在一台 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——两个数字都没错,只是问的不是同一件事。
第 13 章说过了,这里再强调一次,因为它太常见了:
所有进程的 RSS 之和会远超物理内存,因为共享库和共享内存在每个进程里都算了一遍。用 smem 看 PSS,或者直接看 /proc/meminfo 的全局数字。
OOM killer:一次成功的分配被事后撤销
现在到了 overcommit 的代价。
内核承诺了超出实际的内存。如果大家真的都来兑现——所有进程都开始写它们申请的内存——物理页会不够。这时候怎么办?
没法怎么办。缺页处理程序拿不到物理页,而它没有办法「返回一个错误」:触发它的只是一条 mov 指令,那条指令没有返回值的概念(第 13 章讲 SIGBUS 时是同一个困境)。
内核唯一能做的,是杀掉某个进程腾地方。
把整条链串起来:
malloc(1GB)成功返回——内核只是记了个账。- 你的代码检查了返回值,非 NULL,一切正常。
- 几分钟后,你执行
buf[i] = x——一行再普通不过的赋值。 - 触发缺页,内核发现没有物理页可分了。
- 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:
| 全局 OOM | cgroup OOM | |
|---|---|---|
| 触发条件 | 整机物理内存耗尽 | 这个 cgroup 超过 memory.max |
| 杀谁 | 全局分数最高的 | ★ 只在这个 cgroup 内部挑 |
| 宿主受影响吗 | 是 | 不受影响,宿主可能还很空闲 |
| 怎么看 | dmesg | memory.events 里的 oom_kill 计数 |
这解释了那个经典的困惑:「宿主机内存明明还剩一半,我的容器却 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 到此结束。第二重假象拆完了——你现在知道自己那些地址是编的、内存是赊的、连「分配成功」都只是一句承诺。
下一卷拆第三重:你以为你在读一个文件,其实你只是拿到了一个号码。