资讯动态

从OOM事故看Linux虚拟地址空间与物理内存的真相

发布时间:2026/9/23 5:51:12 来源:尧图企业网站定制
还记得那次深夜的线上故障吗一台服务器内存明明还有大半是空闲的业务进程却突然被系统杀掉了。检查系统日志才发现是内核的 OOM Killer 动了手可物理内存根本没打满这事怎么看都不合理。直到我把进程的 /proc/ /status 翻出来看着 VmSize 那一栏飙到接近 200GB才意识到自己一直犯了个认知错误把虚拟地址空间和物理内存混为一谈了。这正是我想在文章里写透的话题。我会从一个真实排查经历出发拆开 Linux 下虚拟地址空间与物理内存的核心关系再带着你亲手用命令验证一遍最后把我在线上踩过的坑一五一十讲清楚。适合刚接触 Linux 系统编程的开发者、做服务端性能调优的运维以及所有被“内存占用异常”折磨过的同学。1. 项目概述一次OOM事故暴露的虚拟地址空间陷阱1.1 现象复盘物理内存充足进程却被杀当时的情况是这样一台 64GB 物理内存的机器上跑着几个 Java 服务监控面板显示内存水位在 40% 左右晃悠离打满差得远。但其中一个实例突然退出拉日志看到 dmesg 里有一行 Out of memory: Kill process上面的 total-vm 和 anon-rss 两个指标让我彻底愣住了——前者接近 200GB后者却只有几个 GB。也就是说这个进程的虚拟地址空间大得惊人但真正占用的物理内存并不算夸张。这其实暴露了一个很多开发者都没想透的事实Linux 里进程“申请到的虚拟地址空间”和“实际占用的物理内存”是两个没有必然对应关系的量。一个进程完全可以拥有几十 GB 的虚拟地址映射而物理页只占用了其中很小一部分。那为什么虚拟地址空间特别大最后还会被 OOM Killer 选中这就要说到内核的内存 overcommit 策略了。内核在默认情况下采用启发式 overcommit进程申请一块虚拟内存时内核并不会马上去物理内存里找页兑现而是先在你的地址空间里“画一块地”。当物理内存和 swap 总量确实见底、回收又来不及的时候内核才会启动 OOM Killer 做最终裁决。所以严格讲直接触发杀进程的不是“虚拟地址空间大”这个表象而是真实物理内存不足时的兜底机制。但虚拟地址空间过大尤其映射段数量多到页表本身都占用大量内存时确实会加速这个进程走向被杀的结局。1.2 解决问题的关键分清虚拟与物理那次事故之后我把脑子里很多模糊的概念重新梳了一遍。可以先记住一个非常直白的划分物理内存RAM硬件上真实存在的内存颗粒容量Linux 把它切成 4KB 大小的“页帧”来管理。虚拟地址空间每个进程独立拥有一套连续的地址编排从 0 到用户空间上限它不是一个具体容量而是一套“逻辑地址视图”。两者之间的桥梁是页表page table和 MMU。进程访问一个虚拟地址时CPU 不会直接拿这个地址去访问内存条而是先通过页表翻译成物理地址。用一个生活化的类比来记物理内存相当于仓库里真实的货架虚拟地址空间则是仓库管理员手里的“拣货单”。拣货单上可以写 200 个货位但实际货架只有 64 个柜子只要你不真去取货拣货单写多少都行。当你真正去某个货位取货时仓库管理员内核才会安排物理货架腾出位置。这个类比不完全严格但用来理解“虚拟地址申请很便宜、物理页访问才真实消耗”已经足够了。2. 深入Linux内存机制虚拟地址空间与物理内存如何协同2.1 进程的虚拟地址空间长什么样Linux 在 x86-64 体系结构下虚拟地址空间被分为用户空间和内核空间。用户空间通常从 0 开始一直延伸到 0x00007fffffffffff大小约 128TB内核空间占据高地址区域用户态程序无法直接访问。用户空间内部也不是一整块而是按用途分成很多区域。从低地址到高地址大致有这些主要区域代码段text存放机器指令通常是只读可执行。数据段data/bss存放已初始化和未初始化的全局变量。堆heap通过 brk 系统调用扩展传统的 malloc 小分配经常落在这里。内存映射区mmap 区域通过 mmap 创建的文件映射和匿名映射共享库、动态链接器、线程栈都会出现在这里。栈stack存放局部变量和函数调用信息从高地址向低地址增长。内核使用 struct mm_struct 来管理进程的整个地址空间其中每一段连续的、属性一致的区间用 struct vm_area_structVMA表示。你可以把 VMA 理解为地址空间里的“地块”每块地有自己的起始地址、结束地址、读写执行权限、是否匿名、是否文件映射等属性。平时一个cat /proc/self/maps就能看到当前 shell 进程的 VMA 列表。下面是一段典型的 maps 输出节选00400000-0040e000 r-xp 00000000 fd:01 1234567 /usr/bin/cat 0060c000-0060e000 rw-p 0000c000 fd:01 1234567 /usr/bin/cat 7f2f8b458000-7f2f8b624000 r-xp 00000000 fd:01 7654321 /usr/lib/x86_64-linux-gnu/libc-2.31.so 7f2f8b624000-7f2f8b624000 r--p 001c0000 fd:01 7654321 /usr/lib/x86_64-linux-gnu/libc-2.31.so 7f2f8b742000-7f2f8b744000 rw-p 00000000 00:00 0 7fff9d8d6000-7fff9d90e000 rw-p 00000000 00:00 0 [stack]每行第一段是虚拟地址范围中间是权限位 rread、wwrite、xexecute、pprivate后面是文件内偏移和设备号最后是文件映射路径。看到这么多段地址你就明白了一个进程的虚拟地址空间从来不是一条线性的“内存条”而是一块块由内核登记的地块。2.2 物理内存的分页与分配策略物理内存这一侧Linux 同样以“页”为管理单位。默认页大小通常是 4KB但服务器上也可以启用 2MB 甚至 1GB 的透明大页THP或显式大页HugeTLB用来减少页表项数量、降低 TLB miss但代价是内存分配延迟增加和碎片风险不是无脑开启的。物理页帧的空闲管理由伙伴系统buddy system负责。核心思路是把空闲页帧按 2 的幂次方组织成不同的链表order 0 对应 1 页order 1 对应 2 页order 2 对应 4 页依此类推。分配内存时优先从刚好能满足大小的 order 里取没有就向更高的 order 借一块再拆成两半释放时则尝试把相邻页帧合并成更大的块。这样做的最大好处是尽量减少外部碎片也让大块连续内存的分配成为可能。但伙伴系统是按页粒度工作的内核内部大量小对象比如 inode、dentry、task_struct如果整页分配浪费会非常严重。所以内核又加了一层 slab 分配器专门缓存相同类型的对象分配时直接复用已经初始化好的内存对象不需要每次都去伙伴系统里拆页。这也是为什么你在 slabtop 里能看到一堆内核对象占用。物理内存还会被划分成不同的 zone比如 DMA、DMA32、Normal、Movable。在 NUMA 架构下每个 CPU 节点有自己的内存控制器访问本地内存比跨节点快得多分配策略会倾向于本地化。理解这些虽然不直接帮你调业务代码但看 /proc/buddyinfo、/proc/pagetypeinfo 的时候不会完全看不懂。2.3 虚拟地址到物理地址的翻译过程核心机制是一张页表。进程的每个虚拟页4KB都可以在页表里对应一个物理页帧号PFN。x86-64 的 Linux 使用四级页表PGD、PUD、PMD、PTE。地址转换时CPU 的 MMU 会按虚拟地址的高位逐级索引最终在 PTE 里拿到物理页帧号加上页内偏移后得到真正的物理地址。这个过程对用户程序完全透明。为了避免每次访问都遍历多级页表CPU 还内置了 TLBTranslation Lookaside Buffer相当于翻译结果的缓存。TLB 命中时虚拟地址到物理地址的转换几乎是零开销一旦 TLB miss就要重新遍历页表。这也是为什么“页越大TLB 覆盖范围越大”在性能优化里很有存在感。当页表里找不到映射时会触发缺页异常Page Fault。按严重程度缺页异常通常这么分Minor Fault软缺页PTE 已经建立但页不在工作集里或者物理页其实已经存在但映射没建立。比如第一次访问刚 mmap 的区域后内核只需分配一个物理页并填好 PTE代价很小。Major Fault硬缺页真正要从磁盘读取页内容比如读取文件映射中尚未加载的页或者从 swap 换入内存。代价非常高可能几百微秒甚至毫秒级。Invalid Fault非法访问访问了根本没有 VMA 覆盖的地址或者权限不对比如对只读区域尝试写入内核会向进程发送 SIGSEGV也就是段错误。这个分类特别重要。排查性能问题时如果进程的 Major Fault 数量持续增长说明内存回收太积极或者 swap 异常需要从缓存策略和内存配比上找原因。3. 实操验证亲手看透虚拟地址空间与物理内存3.1 用free与/proc/meminfo看物理内存真实水位理论讲再多不如亲手敲命令。先看整体物理内存水位free -h输出里的 total、used、free、shared、buff/cache、available 各有含义。很多人只盯着 used 和 free其实最容易踩坑。几个关键点used 不等于“不可回收的占用”它已经扣除了部分可回收的缓存。free 是真正完全空闲的物理页但这个数字小不代表内存紧张。buff/cache 是内核用物理页承载的文件缓存和块设备缓存这部分在内存紧张时可以被回收。available 是内核估算出来“还可以立即分配给新程序”的内存这个值才是最该关注的水位。再看更细的信息grep -E MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree /proc/meminfoMemAvailable 是内核根据 page cache、内存碎片等情况估算出来的可用量通常会比 MemFree 大不少。如果 MemAvailable 长期很低哪怕 MemFree 还剩一点程序申请内存也可能因为分配不到满足条件的页而触发内核强回收甚至 OOM。另外还要注意 swap 的余量swap 几乎耗尽时系统离 OOM 也不远了。判断内存是否真的紧张我习惯连续观察几分钟而不是扫一眼vmstat 2 10重点看 siswap in和 soswap out两列。只要这两列频繁出现非零值说明物理内存已经扛不住了系统在不停换页性能会断崖式下降。这时候再去结合 available 和进程 RSS 分布看方向就对了。3.2 用pmap与/proc/pid/maps拆解进程虚拟地址空间物理内存看完再看单个进程的虚拟地址空间。先找一个目标进程比如 PID 为 1 的 systemd或者你自己写的测试程序pmap -x 1pmap -x 会输出进程里每一段映射的起始地址、大小、RSS、Dirty 和映射用途。这套输出比 top 里的 VIRT 精准得多能让你一眼看出虚拟地址空间到底被哪些区域占着。如果某段映射的 Kbytes 巨大但 RSS 很小说明进程只是申请了虚拟地址空间物理页还没分配如果两项差不多说明这段区域被真正用上了。更细的视图来自 /proc/ /maps 和 /proc/ /smaps。maps 是 VMA 的明细smaps 则在每个 VMA 下列出 Rss、Pss、Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty 等字段。在多进程场景下PssProportional Set Size尤其好用因为它把共享库占用的内存按进程数做了分摊比 RSS 更接近“这个进程独立承担了多少物理内存”的事实。线上排查时我喜欢组合使用这几条命令cat /proc/pid/status | grep -E VmPeak|VmSize|VmRSS|VmSwap cat /proc/pid/smaps_rollupVmSize 是整个虚拟地址空间大小VmRSS 是当前映射到物理内存的总量VmSwap 是被换出到 swap 的量。如果 VmSize 增长很快但 VmRSS 平稳可能是虚拟空间泄漏比如持续 mmap 但没释放如果 VmRSS 不断走高那才更可能是真正的物理内存问题。3.3 mmap小实验亲手制造一次缺页异常理论验证最有效的方式是自己写个小程序。下面这段代码用 mmap 申请 256MB 匿名内存然后逐页写入观察 mmap 之后和写入之后虚拟地址空间与物理内存的差异#include stdio.h #include sys/mman.h #include unistd.h int main() { size_t len 256 * 1024 * 1024; char *addr mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (addr MAP_FAILED) { perror(mmap); return 1; } printf(mmap address: %p\n, (void *)addr); printf(after mmap:\n); fflush(stdout); system(grep -E VmSize|VmRSS /proc/self/status); for (size_t i 0; i len; i 4096) { addr[i] 1; } printf(after touching all pages:\n); fflush(stdout); system(grep -E VmSize|VmRSS /proc/self/status); sleep(30); munmap(addr, len); return 0; }编译运行gcc -o touch_mem touch_mem.c ./touch_mem实际输出大致是这样mmap address: 0x7f8e6a000000 after mmap: VmSize: 261684 kB VmRSS: 2024 kB after touching all pages: VmSize: 261684 kB VmRSS: 264260 kB即使数字因环境而异趋势也一定很明显mmap 后 VmSize 立刻包含了 256MB而 VmRSS 几乎没有变化逐页写入后VmRSS 才涨上去。这个实验直观证明了前文的核心结论虚拟地址空间的占用先发生物理页的分配是在真正访问页面时才发生的也就是“按需换页”。如果想进一步观察缺页次数可以在程序里打印 /proc/self/stat 的第 9 列和第 10 列分别对应 minor fault 和 major fault 的累计次数。写入循环过程中 minor fault 会飞速上涨而 major fault 在内存充足时基本为 0。注意测试用的内存量一定要控制好别在内存紧张的服务器上跑一个几 GB 的版本。我自己就干过蠢事在 2GB 内存的开发机上直接分配 8GB结果把 swap 打满最后被 OOM Killer 请出了会话。测试环境可以乱来生产环境千万不要模拟这种操作。4. 常见内存问题排查与避坑实录4.1 VSZ过高虚拟地址空间大不等于内存泄漏日常运维里最容易被误判的就是 top 里的 VIRT。一个 Java 进程 VIRT 显示几十 GB很容易让人紧张。实际上Java 虚拟机为了提高运行效率会预留大片虚拟地址空间比如 JIT 编译区、线程栈、各种 arenaGo 语言运行时也可能因为每个 goroutine 的栈和堆 arena 管理导致 VIRT 偏高Nginx 等使用共享内存的中间件同样会让 VIRT 看起来很大。判断是否存在虚拟地址空间异常膨胀可以按这个顺序查记录 VIRT 变化曲线watch -n 5 grep VmSize /proc/ /status用 pmap -x 找出哪些映射区域占用大量虚拟地址。观察这些区域的 RSS 是否同步增长。如果只是 VmSize 涨、RSS 不动且 maps 文件里映射段数量或长度持续增加通常是出现了地址空间级泄漏常见于某些库频繁 mmap 但忘记 munmap。我遇到过的一个典型场景某个基础库在每次解析配置时都会 mmap 一个几百 MB 的字典文件解析完只关闭文件描述符却没有 munmap。于是每次配置热更新就涨几百 MB VIRT一两天后 VmSize 冲到几 TB。虽然 RSS 不高但页表项数量大到足以拖垮内核的地址空间管理。修复方式就是在所有 mmap 的路径上补上 munmap。4.2 OOM Killer物理内存真的不够时的处理内核触发 OOM Killer 时会选择一个 oom_score 最高的进程杀掉。oom_score 综合考虑进程的 RSS、页表大小、CPU 时间等因素同时允许用户通过 /proc/ /oom_score_adj 调整进程被选中的概率。像数据库这类重要服务可以在 systemd 或容器配置里设置 oom_score_adj 为负值降低被杀风险反过来一些缓存型进程可以设正值让内核优先考虑回收它。排查 OOM 的标准动作我建议按这几步来查证据dmesg -T | grep -i out of memory 或 journalctl -k --since 1 hour ago。日志里会记录被杀进程的 total-vm、anon-rss、file-rss、oom_score。查水位free -m 看 available 和 swap 余量sar -r 看历史趋势。查进程遍历 /proc/*/status 找到 RSS 最大的前几个进程。查策略cat /proc/sys/vm/overcommit_memory。默认是 0启发式如果被设成 1所有申请都允许物理内存耗尽时更容易触发 OOM如果设成 2系统会按 overcommit_ratio 拒绝超额申请。定措施清理泄漏、用 cgroup 限制、调整 overcommit、优化堆参数。有一类 OOM 很容易被忽略页表内存过大。进程的虚拟地址空间里如果映射段数量极多页表本身也会占用物理内存。这部分内存在系统里看着像“内核占用”普通进程的 RSS 不高但整体物理内存确实被消耗了。遇到这种情况调 vm.max_map_count 可以缓解根治还是得减少映射段数量。4.3 内存泄漏定位RSS异常增长的排查套路如果进程的 VmRSS 持续增长最可能是真的发生了内存泄漏。常规定位思路用 pidstat -r -p 1 持续打印 minflt/s、majflt/s、VSZ、RSS确定增长曲线。用 smaps_rollup 看总 RSS再用 smaps 定位到具体映射文件或匿名区域。如果匿名内存增长很快用 valgrind --toolmassif 跑一遍复现场景分析堆上对象分配。对 Java 进程可以结合 jcmd GC.heap_info 或 NMT 查看堆内堆外内存。分享一个我踩过的坑有个服务每天固定时段 RSS 涨几百 MB排查很久都找不到堆里的大对象后来发现是某个网络库在每次建立连接时创建一个线程线程栈默认 8MB连接不关闭就导致线程和栈内存持续增加。从 heap 角度永远查不到必须结合线程数一起看top -H -p pid或者ps -eLf | wc -l一对比真相立刻浮出水面。另外配合 cgroup 的 memory.events 和 memory.current 可以判断进程是否在容器或 systemd 范围内触发了回收事件比如 memory.events 里的 oom 或 oom_kill 计数。这比直接看宿主机 free 更精准尤其是容器化部署场景不看 cgroup 层根本定位不到问题。4.4 swap与内存回收为什么内存会“越用越多”很多新手看到 free 显示 buff/cache 占了很多就觉得“内存不够了”想手动清缓存。其实 page cache 是内核利用空闲物理内存缓存磁盘内容用来加速重复读取的。真正需要判断的是 available 是否充足。如果 available 充足page cache 大一点反而是好事。当物理内存不足时内核会通过 kswapd 等后台线程回收页。回收顺序大致是先回收 page cache 里的干净页再写回脏页回收最后才把匿名页换出到 swap。所以你会看到一种现象内存压力大时free 和 available 都下降但 buff/cache 会先被压缩等缓存回收殆尽才大量使用 swap随后 si/so 会显著上升。不建议在生产环境随手执行echo 1 /proc/sys/vm/drop_caches。这个操作只会清缓存不会增加 available 之外的物理内存还会损失缓存带来的读写加速效果引发瞬时性能抖动。更合理的调整思路是两个参数vm.swappiness控制内核回收匿名页与文件页的倾向。默认通常是 60对非 swap 场景来说偏大服务器上可以调到 10 甚至 0减少主动把进程匿名页换出到 swap 的概率。vm.vfs_cache_pressure控制内核回收目录项和 inode 缓存的积极性默认 100值越大越积极。但参数调整只能缓解根本解决思路还是减少内存占用或增加物理内存。我自己习惯在每台服务器上采集 /proc/pressure/memory 的 PSI 指标当 memory 的 some 和 full 指标长期偏高说明内存回收已经影响到业务了这时候再决定扩容或优化心里才有底。下面把排查时常遇到的现象整理成一个速查表方便对照现象可能原因优先排查VIRT 很大但 RSS 低正常运行时的虚拟地址预留pmap -x 看映射来源VIRT 和 RSS 同时涨真实内存分配增加smaps 定位增长区域available 很低但 free 高page cache 大或内存碎片观察 MemAvailable 走势free 充足却发生 OOMovercommit 或页表内存过大dmesg 看 total-vm/anon-rsssi/so 频繁非零物理内存不足正在 swapvmstat 与 sar -r 联合分析major fault 持续增长换页严重或缓存回收过度pidstat -r 观察 majflt/s把虚拟地址空间和物理内存的关系彻底想明白之后再看很多“内存问题”都会简单很多先确认你看的到底是 VIRT 还是 RSS再看 available 是不是真的紧张最后才去怀疑泄漏。以前我排查内存问题喜欢一上来就抓大堆栈现在我会先花两分钟跑 free、vmstat、pmap 三个命令基本能把问题定位到八成。最后再分享一个小习惯给所有进程监控里加上 VmSize 和 VmRSS 的趋势线分开放不要混在一起。很多由虚拟地址空间引发的怪问题就是靠这两条线的差值异常才被提前发现的。希望这篇文章能帮你少走一些弯路遇到“内存满了但 free 还高”这类问题不再头皮发麻。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价