计算机 · 2026 年 11 月 11 日

内存不是越多越好:虚拟内存、页缓存与 OOM Killer

free 显示的剩余内存少,往往不是内存不够。把虚拟内存、页缓存、overcommit 和 OOM Killer 的选择逻辑拆开,才知道该加内存还是改参数。

一台 64G 内存的机器,free -h 显示 available 只剩 1.2G,群里立刻有人提议扩容。同一时刻 dmesg 里没有任何 OOM 记录,所有服务的 RSS 加起来不到 20G。剩下的内存去哪了?答案通常在 buff/cache 那一列。

另一个方向:容器被 OOM Killer 杀掉,监控上的内存曲线却一直很平。因为监控采的是 RSS,而杀进程的是 cgroup 的内存上限,两者根本不是同一个数。

free 的四个数不是一回事#

free -m 的输出里,total 是物理内存总量,free 是完全没有被使用的页,buff/cache 是块设备缓存和可回收的 slab,available 是内核估算的「不触发 swap 就能分配给新进程」的量。

available 才是判断内存够不够的依据。它由 free 加大部分可回收的 page cache,再减去不可回收部分和低水位保留得来。所以 free 很小、available 很大是健康状态:内存被拿去缓存文件了,需要时立刻能回收。

bash
free -m
grep -E 'MemTotal|MemAvailable|Cached|Dirty|Writeback|Committed_AS|CommitLimit' /proc/meminfo

Dirty 是等着回写的页,Writeback 是正在回写的页。Dirty 长期很高说明写入速度超过了磁盘,该查的是 dirty_ratio(默认 20%)和 dirty_background_ratio(默认 10%),而不是加内存。

虚拟内存和常驻内存差在哪#

进程的 VSZ 可以到几十 G,实际驻留的 RSS 可能只有几百 M。VSZ 包含已映射但从未触碰的匿名页、共享库、以及 mmap 的文件区间。malloc 拿到地址不等于拿到物理页,第一次写才触发缺页中断去分配。

RSS 也有水分:多个进程映射同一个共享库,每个进程的 RSS 都把这段算一遍。要看真实占用,用 PSS(按共享比例分摊)或 USS(独占):

bash
smem -t -k -P 'java|nginx'
# 内核 4.14+ 可以直接读汇总,比逐个解析 smaps 快
awk '/^Rss/{print $2/1024 " MB"}' /proc/<pid>/smaps_rollup

采集脚本里用 smaps_rollup,一次系统调用拿到整个进程的汇总。逐个 smaps 里的每个 VMA 去读,进程映射一多就会明显拖慢采集端本身。

还有一个差异来自运行时是否愿意把内存还给内核。glibc 释放大块内存时会用 madvise(MADV_DONTNEED) 还回去,RES 会掉;JVM 的堆即使在 Full GC 之后通常也不缩,RES 只涨不跌。这解释了为什么同样是「内存泄漏」的曲线,有的服务能自己降下来,有的只能靠重启。

页缓存:free 少不是浪费#

Linux 会把空闲内存尽量用于 page cache。读文件时数据从磁盘进缓存,第二次读直接命中;读还有预读(readahead),顺序读时内核会提前把后面的页拉进来。写文件默认也先进缓存并标记 dirty,由回写线程在后台刷盘。

想知道某个文件有多少页真的在缓存里,用 fincore <file>(util-linux 自带),它按 mincore 的结果统计驻留页。排查「为什么这个读请求还是慢」时,它比看整机 free 有用得多。

这意味着两件事。第一,free 少是设计目标,不是故障。第二,OOM 发生前内核会先回收 page cache,回收不动才动手杀进程——所以「缓存把内存吃光了」这个说法在绝大多数情况下不成立。

真正不可回收的是匿名页:堆、栈、匿名 mmap。它们只能换出到 swap,swap 关闭时就只能被杀。判断方向很简单:/proc/meminfo 里 AnonPages 在涨还是 Cached 在涨,两者的处置方式完全相反。

有人习惯用 echo 3 > /proc/sys/vm/drop_caches 来「释放内存」。它只是把缓存丢掉,下一次读还得从磁盘重新拉,除了做基准测试,生产上找不到用它的理由。

OOM Killer 是怎么挑人的#

内核在分配内存失败、且回收后仍无法满足时触发 OOM。日志长这样:

text
Out of memory: Killed process 28471 (java) total-vm:8388608kB, anon-rss:7518204kB, file-rss:1024kB
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/app,task=java,pid=28471,uid=1000

第一行是被杀进程的内存构成,第二行是判定上下文。task_memcg=/app 说明这是 cgroup 级别的 OOM,不是整机 OOM,处理方式完全不同:整机 OOM 要找出真正的大户,cgroup OOM 要调 memory.max 或者修内存泄漏。

/proc/<pid>/oom_score 是当前得分,oom_score_adj 可调(-1000 到 1000)。把关键进程设成 -1000 能显著降低被杀概率,但代价是别的进程替它死,不是护身符。

得分是按进程占用内存的比例算的,这带来一个反直觉的结果:一个刚启动、RSS 还很小但马上要吃满内存的进程,得分很低,它会一直膨胀到把别人挤死才被盯上。所以「OOM 杀错了人」这件事,多数时候不是内核判断错,而是被杀时它确实最大。

cgroup v2 下看计数:

bash
cat /sys/fs/cgroup/app/memory.max
cat /sys/fs/cgroup/app/memory.current
cat /sys/fs/cgroup/app/memory.events   # oom / oom_kill 计数

memory.events 里 oom 是触发次数,oom_kill 是真正杀掉的次数。前者大于后者,说明出现过「差点被杀、但回收成功」的情况,这是扩容的早期信号,比等进程真死要早得多。

几个容易踩的边界#

vm.overcommit_memory=0 是启发式,允许适度超额;=1 永远答应 malloc,风险自担;=2 严格按 CommitLimit,容易让正常进程申请失败。Redis 这类会 fork 的进程在 =2 下经常起不来。

vm.swappiness 不是「用不用 swap」的开关,而是回收匿名页相对 page cache 的倾向。设 0 也只是降低倾向,不等于禁用。

还有透明大页(THP)。开启 always 时,进程的 RSS 会按 2M 对齐膨胀,top 里的 RES 看着比实际用量大不少。要准确统计就关掉它,或者用 AnonHugePages 单独核对。

cgroup v2 还有一层 memory.high。超过它不会立刻触发 OOM,而是把进程放进强制回收状态,速度变慢但能继续跑。用它做缓冲,比直接把 memory.max 卡死在峰值上更稳。

指标 含义 怎么用
free 完全空闲的页 小是正常的,别当告警
available 可安全分配的量 判断够不够看这个
Cached 页缓存 可回收,涨不一定是坏事
AnonPages 匿名页 不可回收,涨才是真吃内存
Dirty 待回写页 高说明盘慢,不是内存问题

用 PSI 看真实压力#

/proc/pressure/memory 给出 some 和 full 两行,avg10 是过去 10 秒的阻塞时间占比。some 表示有任务因内存不足停住,full 表示所有可运行任务都被卡住——后者非零,说明内存已经不是紧张而是告急了。

cgroup v2 的每个组也有自己的 memory.pressure。容器内存快不够时,先看这个值再决定扩不扩,比盯 RSS 曲线直观。它只统计本组任务的阻塞,所以能区分「是容器自己撑爆」还是「宿主整体紧张」。

结论#

  • 判断内存够不够看 available,不看 free;free 小、cache 大是正常状态。
  • VSZ 是承诺,RSS 是现状,PSS 才接近真实占用;采集脚本优先用 smaps_rollup。
  • 先区分整机 OOM 和 cgroup OOM,前者查大户,后者改 memory.max 或修泄漏。
  • memory.events 的 oom 计数是扩容前的预警,比等进程被杀更早。
  • 加内存之前先确认占用是匿名页还是可回收缓存,否则扩完还是同一条曲线。

完

#Linux#内存#OOM#排障
回到顶部 ↑