☰
Linux虚拟内存深度解析:从VIRT/RSS到页表与缺页中断
2026/10/13 7:17:45 网站建设 项目流程

在 Linux 上排查内存问题时,很大概率会遇到这么一幕:跑着跑着某个进程的 VIRT 占用已经超过好几个 G,但 RSS 只有几百 M,CPU 也没飙高。新手可能马上怀疑程序写崩了,经验稍微丰富点的人则会先打开 /proc/PID/maps 看一眼,然后心里基本有底。这个“底”来自对进程和虚拟内存关系的理解。操作系统给每个进程画了一张虚拟地址空间的大饼,进程以为自己独占一块连续的内存,实际上内核在背后偷偷做映射、分配物理页、按需把数据调入调出,真正用掉的物理内存远没有虚拟内存数字看起来那么夸张。作为 Linux 运维或开发,不理解这层关系,后面排查内存泄漏、OOM、swap 抖动都会非常吃力。

这篇文章我打算从一个模拟排查场景切入,把进程、虚拟地址空间、页表、缺页中断这些概念串起来,讲清楚它们是怎么配合的,再演示几条常用命令,最后把常见的坑和排障思路整理出来。不管是刚开始学操作系统的小白,还是已经写了好几年代码的开发者,这篇内容都值得花点时间读完。

1. 虚拟内存到底解决什么问题

1.1 多个进程共享物理内存时的不可能三角

先回到最原始的问题:一台服务器只有 8G 物理内存,却要同时跑十个进程,每个进程都觉得自己应该有至少 2G 可用的内存空间。物理内存不够用是常态,但更麻烦的是并发隔离。如果没有虚拟内存机制,两个进程被加载到同一片物理地址,它们互相读到对方的数据,程序行为会变得完全不可控。

虚拟内存机制要做的第一件事,就是给每个进程创造一个独立的、连续的、从零开始的“假地址空间”。进程里的指针、函数跳转、堆栈访问,全部基于这个假地址,也就是虚拟地址。内核负责把虚拟地址翻译成物理地址,并保证两个进程即使使用相同的虚拟地址,也映射到不同的物理页。用酒店来类比:每个房客拿到的房卡都写着“301”,但楼层结构不同,刷卡后电梯把你带到自己房间,另一个房客的“301”可能是另一层的房间。互不干扰,但每个房客都体验到了“完整入住”。

这样设计带来两个额外好处。第一,低层硬件架构不用为每种物理内存布局开发一套程序,应用只面对统一的虚拟地址空间,物理内存颗粒怎么排列,对进程来说不可见。第二,进程与物理地址解耦之后,可以把不常用的部分换出到磁盘,也就是 swap,给其他进程腾出物理页,让物理内存在多个进程之间更合理地调配。

1.2 进程眼里的地址空间:一张巨大假地图

任意进程的虚拟地址空间大致从低地址到高地址分成几块:只读的代码段,保存全局变量和常量的数据段,未初始化的全局数据段 BSS,然后是动态分配堆区域,再往上是文件映射区域和共享库,栈区通常在高地址,再往上还有内核虚拟地址空间,应用不可直接访问。虽然 32 位和 64 位布局细节不同,但基本顺序和权限控制是类似的。

需要特别强调的是,这个地址空间只是“地图”,不是“房子”。地图上画着一块 100M 的堆区,并不代表 100M 物理内存已经被占用,只有进程实际访问到那一页,内核才会真正分配物理页。这也是为什么 VIRT 值往往远大于 RSS 的根本原因之一。

64 位 Linux 下,一个进程的虚拟地址空间有 128T 那么大,日常应用很难把这张地图填满。32 位系统只有 4G 地址空间,所以当年才会有各种“内存不够用”的折腾,比如改内核参数、用 mmap 映射大文件,本质上都在尽量绕开虚拟地址空间太小的限制。现在虽然 64 位普及了,但理解这张地图的边界,仍然是排查大型应用内存问题时不可少的基础。

理解了这个布局,再去看 /proc/PID/maps 的输出就会清晰很多:每一行其实就是这个地址空间里的一段“区域”,记录着起始地址、终止地址、权限位、对应文件或设备。后面我会专门演示。

2. 进程和虚拟内存是怎么绑定起来的

2.1 内核里的账本:mm_struct 与 vm_area_struct

进程的虚拟内存不是散落的,而是由内核中的几个核心数据结构组织管理。进程描述符(task_struct)里有一个指向内存描述符 mm_struct 的指针,mm_struct 维护着整个进程地址空间的全局信息,比如页表根指针、代码段和数据段的地址范围、当前 brk 堆顶位置等。

地址空间里的每一段连续区域,内核用 vm_area_struct 来描述,简称 VMA。一个 VMA 就是一块连续的虚拟地址区间,带着起始地址、结束地址、读写执行权限、标志位以及关联的文件信息。比如 libc 共享库被映射到某个地址段,那一段就是一个 VMA。mm_struct 通过链表和红黑树把这些 VMA 组织起来,查找某虚拟地址属于哪个 VMA 时,走红黑树可以很快完成。

这个结构对现代操作系统设计极其重要。当进程调用 mmap 时,内核只是在地址空间里找一段空余区间,插入一个 VMA,并不会立刻分配物理内存。真正分配物理页的动作,要等到缺页异常发生。所以可以说,VMA 是“需求分配”策略的基础,没有它,内核就没办法只靠一个简单堆指针管理错综复杂的地址区间。

排障时 VMA 同样实用。比如某个进程的 maps 文件连续出现一千多个同样大小的匿名段,那多半是线程栈或者某个用户的缓存池。看到 LLVM 或 Python 这类运行时异常时,VMA 数量暴涨也会拖慢性能,因为每次缺页都要在红黑树里定位区间。这些都属于“从数据结构出发看现象”的经验。

2.2 页表和缺页中断:虚拟到物理的翻译与按需分配

有了 VMA 只是画好了地图,进程还需要一个机制来把虚拟地址翻译成物理地址,这个机制就是页表。页表按照固定大小把虚拟地址空间切成页,通常 4K 一页,页表项记录每个虚拟页对应的物理页帧编号、权限、是否在内存等信息。现代 CPU 查页表时会借助 TLB 缓存加速,这个环节对性能影响非常大。

如果虚拟页没有对应的物理页,即页表项为空或者标识为不在内存,情况就分好几种。最常见的是首次访问刚通过 malloc 得到的区域,此时页表项还没有建立,CPU 触发缺页中断,内核在中断处理中判断这个虚拟地址是否落在某个 VMA 内。如果在,就从伙伴系统分配一个物理页,建立映射,并把页面内容按需初始化,然后重新执行触发缺页的指令;如果不在任何 VMA 内,那大概率是野指针,进程收到段错误 SIGSEGV 退出。

按需分配这么设计,一是省内存,二是省启动时间。进程启动时往往很多函数和代码路径不会走完,完全没有必要一次性把所有代码页和数据页全部加载到物理内存。缺页中断可分为两种:major page fault 表示磁盘读取或 swap 换入,需要等待 IO;minor page fault 只是物理页尚未建立,分配页即可。性能排查时,这两个计数的比例很能说明问题。

多级页表也是虚拟内存机制里绕不过去的一环。64 位系统如果只用单级页表,一张表要存下天文数字的页表项,内存根本撑不住。Linux 在 x86-64 上默认用四层页表,每一层只保存下一级的索引,大量空洞区域不需要创建任何页表项。这个设计和文件系统的目录树很像:一个庞大目录结构里只有少数深层目录真存在文件,中途的空目录不需要建完整节点。理解层级之后,你就明白为什么一个进程虚拟空间再大,只要不实际使用,页表开销始终有限。

2.3 共享内存和写时复制:用来省物理页的经典招数

进程之间往往有很多共同的数据,比如共享库的只读代码段。如果每个进程都复制一份,物理内存马上就爆了。所以内核允许同一个物理页同时映射到多个进程的虚拟地址空间,不同进程的页表项指向同一个物理页帧。这样共享库只需要加载一份,大家用虚拟地址看到的是同一份数据。

fork 创建子进程时,虚拟地址空间本来是一份完整拷贝,但内核用写时复制(Copy on Write,COW)把这个动作变成“先把父进程的物理页共享给子进程,把页表项标记成只读”,一旦任意一方尝试写入,就触发缺页中断,内核为写入方分配一个新物理页并复制原内容。好处很明显:如果子进程马上 exec 运行另一个程序,之前那些页面一个都不需要复制,fork 速度非常快。

理解了共享和 COW,你再看 Linux 下 fork + exec 这种经典组合,就会明白它为什么比 Windows 的 CreateProcess 高效得多。另外,mmap 共享内存也依赖类似机制:不同进程的 VMA 都关联到同一个物理页,写的一方改动,读的一方立即可见。

这里也有一个容易翻车的点。用 fork 以后如果父子进程都长时间存在,并且各自持续写入同一批数据,COW 会不断触发复制,写得多内存涨得也快。很多服务框架喜欢用“预 fork + 共享只读数据”的模式,就是希望充分利用共享映射省内存;反过来,如果设计成每个 worker 都保留一堆可变副本,内存就会成倍增长而不自知。

3. 用 Linux 命令把虚拟内存看透

3.1 /proc/pid/maps 与 smaps:逐段查看进程的地址空间

Linux 把内核内部状态以文本方式暴露在 /proc 下,这是调试和监控的第一现场。查看某个进程时,先拿到 PID,然后读 /proc/PID/maps,它每一行对应一个 VMA,格式大概是:

起始地址-结束地址 权限 偏移 主设备:从设备 inode 路径

权限位四个字符分别代表可读、可写、可执行、私有或共享,第三列是文件映射在进程里的偏移量,最后一列如果为空,表示这段是匿名内存,比如堆和栈。

再看 /proc/PID/smaps,它在 maps 基础上多了一大堆统计字段,包括 Rss、Pss、Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty、Swap 等。其中 PSS 是全系统共享页按进程数量比例分摊后的值,这个字段在排查“单个进程到底占多少物理内存”时比 RSS 更有参考价值。

man 手册里写明这些字段的含义,但很多新人往往只盯着数字,不关心维度。比如看到某个匿名段的 RSS 从几 M 涨到了几百 M,再对照时间,基本就能定位堆增长趋势。这个思路比单纯看进程总 RSS 更精细。我自己写脚本判断程序是否泄漏,也会优先取 smaps 里所有 AnonPages 段的和,再用 PSS 做交叉验证,因为共享库那部分 RSS 经常“冤枉好人”。

3.2 pmap 与 top:快速定位内存增长点

纯命令行用户可能更习惯 ps aux 或者 top,但想快速看某个进程的完整虚拟内存分布,pmap -x PID 是最高效的方式。它会列出每个 VMA 的起始地址、大小、RSS、脏页数、权限、映射名。

实际操作中我通常先 top 看整体,哪个进程 VIRT/RSS 不对劲,再用 pmap 看它的内存分布。如果堆区域占了一大片且 RSS 也在涨,多半是不断 malloc 没有释放;如果某个共享库的 RSS 特别大,要怀疑库使用异常;如果出现大量的 mmap 匿名段且大小都一致,有可能是线程栈或者 JIT 缓存。

top 里的 VIRT、RES、SHR 三个字段也要会看。VIRT 是进程虚拟内存总量,RES 是驻留物理内存页大小,SHR 是共享内存大小。三者之间的关系并不能简单用 VIRT = RES + swap 去套,因为虚拟地址空间里还包含从未被访问的预留区、文件映射的未读部分。比较实用的判断技巧是:如果 RES 长时间稳在一个高水位,VIRT 一直涨,通常是地址空间扩张,但物理页没被真正写脏;如果 RES 和 VIRT 一起涨,多半是业务真的在消耗内存。

3.3 做一个模拟观测实验:malloc 之后 RSS 到底什么时候涨

纸上谈兵不如直接动手。我写一个最简单的 C 程序,循环 malloc 并 memset,刷一段时间后就 sleep,用 pmap 观察变化。你会发现 malloc 成功返回时,新分配区域的初始 VMA 可能已经存在,但页表项还没有建立,RSS 不会立刻增加;只有当 memset 真正写入每个页时,物理页才会被分配出来,RSS 才慢慢爬升。

这个实验强烈建议新手做一次,因为它能直观回答“虚拟内存大但物理内存没涨”这个问题。程序里人为设置一个大量内存申请,比如申请 1G,再看 VIRT 涨了 1G,RSS 只有几百 K;继续 memset 到一半,RSS 涨了 512M。非常直观。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> int main() { size_t size = 1024 * 1024 * 1024; // 1G char *p = malloc(size); printf("malloc done, VIRT will jump, RSS not yet\n"); sleep(5); memset(p, 1, size / 2); printf("memset half, RSS should around 512M\n"); sleep(30); return 0; }

编译运行的同时另开终端执行 pmap -x PID,或者直接看 /proc/PID/status 里的 VmRSS 字段。这个实验在普通机器上没有任何危险性,最多占几百 M 物理内存,跑完进程退出就释放了。它把虚拟内存和物理内存的“时间差”表现得清清楚楚:分配地址空间是瞬间的事,真正写页才会触发物理页分配。

4. 虚拟内存机制下的典型问题和排查方法

4.1 内存泄漏:只看 VIRT 涨不够,RSS 同步涨才危险

很多开发遇到的所谓内存泄漏,其实是误报。比如某些框架会保留对象池,第一次大涨之后就不升了,这不是泄漏。真正的泄漏是进程稳稳地持续增长,尤其在一个固定业务循环里,每次循环 RSS 都微涨,长期下来不可收拾。

排查时我喜欢用 /proc/PID/smaps 里的匿名堆段作为第一指标。如果堆段的 RSS 随着业务时间线性上涨,那基本是代码路径里某个 malloc/new 没释放。用 valgrind 或者 ASAN 可以进一步定位,但对线上环境往往太重,更多是从代码 review 和压测复现入手。这里有个经验:动态申请的内存,如果 VIRT 和 RSS 同时上升,说明不仅是分配了地址空间,还真的“写过”,大概率是泄漏;如果 VIRT 涨而 RSS 平稳,先别急着下结论,很可能是某些分配只预留了地址但从未使用,或者是在用 mmap 做缓存池。

再补一个容易被忽略的场景:glibc 的 malloc 会维护多个 arena,线程多了以后空闲内存并不一定会立刻返还给操作系统,而是留在进程堆里待复用。如果压测结束以后 RSS 没有立刻降下来,就断定泄漏,容易冤枉程序。正确做法是看“下一次同量级压测开始时,RSS 是否还会继续往上创新高”,如果稳定在一个平台不再突破,那是缓存复用,不是泄漏。

4.2 OOM Killer 的触发逻辑和定位方法

Linux 运行时如果物理内存紧张,内核会通过 overcommit 策略来决定库里进程能不能继续申请地址空间。默认的启发式策略允许一定程度的超额分配,这不代表物理内存够用,只是虚拟地址空间足够。真正写数据把物理页耗尽时,内核的 OOM Killer 会登场,根据 oom_score 选出得分最高的进程杀掉。

很多同学以为 OOM 就是物理内存满了,其实不完全是。可能只是某个进程对内存的访问模式不健康,或者 cgroup 限额被触发了,文件页回收不及时也会造成短暂紧张。排查套路是先看 dmesg 或 messages 里的 OOM 记录,里面有当时触发事件、占用情况、被杀的进程列表;再看 cgroup 当前 memory.usage_in_bytes;最后确认有没有设置了不可回收的 Page Cache。

经验是:不要一上来就加物理内存。先用 vmstat 看 si/so 判断有没有 swap,再确认是不是某个进程的虚拟地址空间过度膨胀,最后结合 /proc/meminfo 里的 MemFree/Dirty 判断是否 Page Cache 没及时回写。多数情况下,是应用层某个不合理申请把资源拖垮,而不是机器物理资源不足。

OOM 日志里的 oom_score 可以进一步解释为什么被杀的是它而不是别人。分数高的进程通常占内存大、存活时间长、近期 CPU 使用少。所以有时候一个故意闲置的空闲进程反而更容易被选中,这是内核的“经验法则”,不一定代表它是真凶手。排查时要把应用优先级、cgroup 限制和系统全局压力综合起来看,才能得出准确结论。

4.3 swap 抖动与 overcommit 的隐藏坑

过度分配带来最大隐藏坑:malloc 成功不代表你有这么多物理内存。如果代码严格按照“malloc 返回 NULL 就算是内存不足”的假设去优化,在 overcommit 开启时就会失效。用 C 申请 8G 内存,物理机器只有 4G,malloc 可能照样返回非空,一旦真的遍历,系统瞬间卡死,CPU 疯狂触发缺页换入换出,最终有进程被 OOM Killer 干掉。

swap 抖动出现在内存不足以支撑活跃数据时。vmstat 中 si/so 同时长期非零,说明系统在频繁把内存页换到 swap 和从 swap 换回,系统吞吐量急剧下降,响应延迟飙升。这时候很少有人能对某一个进程做精准分析,最好还是调整部署方案、降低单机负载或优化进程内存模型,单纯调大 swap 只会把卡顿时间拉长。

Linux 的 overcommit 有三种模式,0 表示启发式,1 表示总是允许,2 表示禁止超过一定比例。服务器往往默认是 0,所以才会出现“虚拟内存申请成功,但写的时候崩了”的奇观。在做数据库或者 Java 这类内存大户的部署时,建议根据实际业务评估是否需要限制过度分配,但别盲目改成 2,否则很多依赖虚拟内存预分配的框架会直接启动失败。

5. 排障实操中的几个通用技巧

5.1 先看趋势再看绝对值

实际排查内存问题,我很少一上来就抓大 VIRT 的进程,而是先拿 top 按 RES 排个序,看物理内存消耗最高的几个进程。VIRT 高不一定有问题,RES 高也不一定异常,关键是要把两者的增长趋势结合起来,再结合业务压测数据做判断。

如果业务启动时内存就涨到峰值稳住了,那大概率是设计如此。如果随请求量线性增长,就要认真对待。观察改进程的内存趋势最少要看一个完整业务周期,十分钟抓一次快照,手动脚本记录 smaps 汇总值,比临时猜要可靠。

有一点要提醒:只盯着某个瞬间的内存快照排查,容易得出完全相反的结论。曾经有个模拟项目 X 的内存飙升报告,实际上只是启动阶段做缓存预热,半小时后自动稳定;如果只看前十分钟,确实会吓一跳,但拉长到一天再看,曲线非常平。所以排查内存问题,先画趋势,再定位段,最后才用工具深挖。

5.2 用 /proc 快照搭一套轻量监控

写一个简单监控脚本采集 /proc/meminfo、/proc/PID/status 和 /proc/PID/smaps,定时记录内存趋势,这就是你排查问题的第一手资料。关键指标是虚拟地址空间总量、物理驻留大小、共享脏页、匿名内存、swap 使用量。

指标不要贪多,先用 VmRSS 和 AnonPages 两个字段观察整体,再用 pmap 定位到具体段。绝大多数问题都能通过这三个步骤找到范围,剩下的才是上大工具做精确追踪。

顺序很重要:先看整个系统的 MemFree 和 si/so,排除物理压力;再看嫌疑进程的 VmSize 和 VmRSS,判断是虚拟地址膨胀还是物理页增长;最后看 smaps 里的匿名段,锁定是堆、栈还是 mmap 区域。这套顺序看起来简单,但它比随手抓一屏 top 输出有效得多,因为每一步都对应一个机制假设。

5.3 理解内核机制比背命令更值钱

运维命令可以随时查手册,但如果你不理解 mm_struct、VMA、page table 和缺页中断之间的关系,看到 pmap 输出也只能知道“这是地址、那是大小”,很难进一步分析为什么某个段在增长。我建议新手把 kernel 文档里 memory management 那一部分当科普书读一遍,再配几十个实际观测实验,比死记硬背十个命令有效得多。

调试工具方面,strace 看系统调用只能到 mmap/munmap 这一层,perf 可以跟踪缺页事件,但这些都建立在基础概念之上。先把“进程内存 = 虚拟地址空间 + 页表映射 + 物理页帧”这个三元组记在心里,后续学任何工具都会快得多。

我个人在这类问题上踩过最深的坑,是当年误以为 VIRT 就是进程真实内存占用,导致一台机器上很多进程看起来都“超标”,其实只是共享库和预留区撑大了数字。后来把每次 OOM 前后的 /proc 快照保存下来,反复对比,才真正把虚拟内存和物理内存在我脑子里分开。如果你现在也被 VIRT/RSS 困惑,我建议你动手做一次上面的小实验,再把 /proc/PID/smaps 盯几天,很多疑问会自然消散。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询