虚拟内存:为什么能“看到”比物理内存更大的空间

在 64 位机器上随便写个程序,它的地址空间理论上有 16EB(百亿亿字节)那么大;就算是一台只有 16GB 内存的机器,你也可以让一个进程 malloc 出几十 GB 的空间而不立刻崩溃。更日常的是:你同时开着浏览器、IDE、好几个 Docker 容器,把它们"声称需要"的内存加起来,早就超过了机器的物理内存——可机器还在好好跑。

这显然不是因为内存条变魔术变大了。物理内存就那么多,一个字节都没多。那程序"看到"的那个远大于物理内存的空间,究竟是怎么变出来的?

程序拥有的是一个巨大的逻辑地址空间,但任一时刻真正占用物理内存的,只是其中很小的一部分。

这一篇我们先把虚拟内存的运转机制讲透——什么叫按需装入、一次缺页到底发生了什么、页表里靠哪个标志位区分"在不在内存"、缺页处理走哪几步——看清这套机制之后,再回答那个核心问题:它凭什么能让程序用上"看起来比物理内存更大"的空间。它建立在前面分页机制的地基之上,是分页能力的一次华丽兑现。

一、先破除幻觉:大地址空间不等于大物理占用

理解虚拟内存的第一步,是把两件常被混为一谈的事彻底分开:

  • 虚拟地址空间的大小:进程的逻辑地址能编到多大。这只是一个"编号范围",是地址位数决定的上限,跟实际占了多少内存毫无关系;
  • 物理内存的实际占用:进程的页里,真正被装进物理内存(页框)的那部分有多大。

打个比方:你拥有一本几千页的字典(巨大的虚拟地址空间),但你的书桌(物理内存)一次只摊得开几页。你不需要把整本字典同时摊在桌上——用到哪一页,才把哪一页翻开放到桌上。字典再厚,桌子小一点也够用。

虚拟内存做的就是这件事:给进程一个很大的"逻辑空间编号范围",但只把它当前真正用到的那几页,放进有限的物理内存里。 剩下的页,要么还没被访问过(根本没分配物理内存),要么暂时被挪到了磁盘上。

这就是它能"看到更大空间"的根本——大的是地址编号范围,不是物理占用。而支撑这个把戏的关键前提,正是上一篇分页机制带来的能力:逻辑页和物理页框可以任意映射、按页为单位独立管理。 没有分页,虚拟内存就无从谈起。

二、按需装入:页只在被访问的那一刻才进内存

虚拟内存的核心运转机制叫按需装入(demand paging),又叫请求调页。规则极其简洁:

一个页,只有在它第一次真正被访问的那一刻,才被装入物理内存。在那之前,它要么在磁盘上,要么压根还没分配。

这彻底改变了"加载程序"的含义。在没有虚拟内存的世界里,运行一个程序必须先把它完整地装入内存才能开始执行。而在按需装入下,启动一个程序时,操作系统几乎不预先装入任何页,只是建立好地址空间的"账本"(页表),然后就直接跳到入口开始执行——

  • CPU 执行第一条指令,访问指令所在的页 → 这个页不在内存 → 触发装入 → 装进来 → 继续;
  • 接着访问某个全局变量所在的页 → 不在内存 → 装入 → 继续;
  • ……程序就这样边跑边把用到的页一个个调进来

那些从头到尾没被执行到的代码(比如错误处理分支、你从没点过的功能),它们的页可能永远不会被装入。这正是按需装入的精髓:不为"可能用到"付钱,只为"真正用到"付钱。

三、页表里的关键开关:有效位

要实现"访问时才发现页不在内存",硬件必须有办法判断某个页到底在不在物理内存里。这个判断,靠的是页表项里的一个标志位——有效位(valid bit,也叫存在位 present bit)

回忆分页篇:页表项里除了物理页框号,还带一组标志位。其中有效位的含义是:

  • 有效位 = 1:这个页当前在物理内存里,页表项里的页框号有效,可以直接转换访问;
  • 有效位 = 0:这个页当前不在物理内存里(在磁盘上,或尚未分配),页框号无效。

于是地址转换时多了一道判断:硬件查页表拿到页表项后,先看有效位。如果是 1,照常拼接出物理地址访问;如果是 0,硬件就触发一个异常——这个异常,就是大名鼎鼎的缺页异常(page fault)

缺页异常不是程序出错,而是虚拟内存机制主动设计出来的"信号":它告诉操作系统"有个页现在需要、但还不在内存,请把它弄进来"。

请务必区分:缺页(page fault,正常的按需调页)和非法访问(如越界、写只读页,对应段错误 SIGSEGV)是两回事。前者是机制的正常运转,后者才是真正的错误。

四、缺页处理的完整流程:一次缺页到底发生了什么

缺页异常触发后,控制权交给操作系统的缺页处理程序。这是虚拟内存最关键的一段机制,我们把它完整走一遍:

flowchart TD A["CPU 访问某逻辑地址<br/>查页表"] --> B{"页表项有效位 = 1?"} B -->|"是, 页在内存"| C["正常转换<br/>拼接出物理地址访问"] B -->|"否, 缺页异常"| D["陷入操作系统<br/>缺页处理程序"] D --> E{"有空闲页框?"} E -->|"有"| G["选一个空闲页框"] E -->|"没有"| F["运行页面置换算法<br/>选一个牺牲页换出<br/>(脏页需写回磁盘)"] F --> G G --> H["从磁盘把所需页<br/>读入该页框"] H --> I["更新页表: 填入页框号<br/>有效位置 1"] I --> J["重新执行<br/>刚才那条触发缺页的指令"] J --> C

逐步拆解这条流程:

  1. 判断是否缺页:CPU 查页表,发现目标页有效位为 0,触发缺页异常,陷入操作系统;
  2. 找一个物理页框:操作系统需要一块空闲页框来装这个页。如果有空闲页框,直接用;如果物理内存已满,就必须运行页面置换算法,选一个"牺牲页"换出去腾位置(如果这个牺牲页被修改过、是"脏页",还得先把它写回磁盘);
  3. 从磁盘调入:把所需的页从磁盘(可执行文件、或交换区 swap)读进刚腾出的页框。这是一次磁盘 I/O,极其耗时
  4. 更新页表:把这个页的页表项填上新的页框号,并把有效位置 1;
  5. 重新执行指令:操作系统返回,重新执行刚才那条因缺页而中断的指令。这次页已经在内存了,转换顺利通过。

注意第 5 步的"重新执行"很关键——触发缺页的那条指令当时并没真正完成,处理完缺页后它会被重新执行一遍,对程序来说这一切完全透明,它根本不知道自己刚被中断过、磁盘刚转了一圈。这种透明性正是虚拟内存的优雅之处。

而这张图也顺势引出了下一篇的主角:第 2 步里"内存满了,选哪个牺牲页换出",就是页面置换算法要回答的问题。 缺页处理是舞台,置换算法是台上的主角之一。

五、局部性:虚拟内存能高效运转的隐藏前提

看到这里你可能会担心:如果每次访问都缺页、都要等磁盘 I/O,那程序岂不是慢得没法用?磁盘比内存慢成千上万倍,频繁缺页是灾难性的。

虚拟内存之所以实际上跑得飞快,靠的是程序行为的一个普遍规律——局部性原理(locality)

  • 时间局部性:刚被访问的数据/指令,很可能在不久后再次被访问(比如循环体、热点函数);
  • 空间局部性:刚被访问的位置附近,很可能马上也被访问(比如顺序遍历数组、顺序执行指令)。

因为局部性,程序在一段时间内的访问其实高度集中在少数几个页上。这意味着:只要把这少数活跃页留在物理内存里,绝大多数访问就能直接命中、根本不缺页;只有当程序"切换工作焦点"时才偶尔缺一次页。于是缺页是稀疏的、可摊薄的,磁盘 I/O 的代价被均摊到海量的内存命中里,整体性能依然很高。

虚拟内存不是凭空创造容量,而是用"时间换空间":靠局部性保证大多数访问命中内存,只在少数时刻支付磁盘调页的代价。

这也解释了一个重要的反直觉点:访问超大虚拟空间并不总是快的。 一旦你的访问模式破坏了局部性(比如在远大于内存的数据上做完全随机访问),活跃页就会超出物理内存能容纳的范围,缺页频繁发生,性能断崖式下跌——这就是下一篇要讲的"抖动"。局部性是虚拟内存优雅的前提,一旦它失效,优雅立刻变成灾难。

六、回答开篇:到底是怎么"变大"的

现在把机制串起来,正面回答标题的问题。虚拟内存能让程序看到比物理内存更大的空间,是三件事共同作用的结果:

  1. 地址空间是逻辑编号,本就可以很大:64 位地址给了天文数字般的编号范围,这只是"能编多少号",不消耗物理内存;
  2. 按需装入让物理占用只跟"实际用到的页"挂钩:你 malloc 几十 GB,只要不去碰它,那些页大多没有真正分配物理内存(甚至连缺页都还没触发);真正占物理内存的只有你访问过的活跃页;
  3. 磁盘作为后备扩展了"可容纳的总量":当活跃页超过物理内存时,不活跃的页可以被换出到磁盘(swap),物理内存腾给更需要的页。于是物理内存 + 磁盘交换区共同支撑起了这个大空间。

所以"看到更大空间"= 大的逻辑编号范围 + 按需只装活跃页 + 磁盘兜底换页。三者缺一不可,而它们全都站在分页机制"页可任意映射、按页独立管理"的肩膀上。

七、和工程实践 / 后端开发的联系

虚拟内存的机制,直接决定了你怎么看懂线上服务的内存行为。

  • VIRT vs RES(RSS)top 里的 VIRT 是虚拟地址空间大小(声称需要的,可以巨大),RES/RSS 才是真正驻留物理内存的部分。一个进程 VIRT 几十 GB、RSS 几百 MB 是完全正常的——这正是"逻辑大、物理小"的直接体现。监控内存要盯 RSS,不是 VIRT。
  • 缺页指标(page fault)major fault(需要磁盘 I/O 的缺页)飙升,往往意味着内存吃紧、在频繁换页,是性能下降的强信号;minor fault(页已在内存、只是建立映射)则通常无害。
  • mmap 与按需加载:用 mmap 把大文件映射进地址空间,文件并不会立刻全部读入内存,而是访问到哪一页才调入哪一页——这就是按需装入的直接应用,是处理超大文件的常用手法。
  • swap 的双刃剑:swap 让物理内存"撑大"了,但一旦频繁用到 swap(活跃页被换出又换入),延迟会急剧恶化。很多线上服务干脆关掉 swap,就是不愿承受这种不可预测的抖动。
  • 容器内存限制:容器的内存 limit 限制的是 RSS 类的实际使用,超限会触发 OOM Kill。理解"虚拟 ≠ 物理",才能正确设置 limit、读懂 OOM。

八、动手做一个小实验:观察大地址空间程序

理解"逻辑大、物理小"最直接的方式,是写个程序亲眼看一看。

  1. 写一个程序,一次性 malloc 一块很大的内存(比如 4GB),然后什么都不做、不去访问它,让程序睡眠。用 top 观察:你会看到 VIRT 涨了约 4GB,但 RES 几乎没变——因为那些页还没被访问,根本没占物理内存;
  2. 改一下程序,分配后只访问其中很小一部分(比如每隔很大步长写一个字节)。再看 RES,它会随你实际触碰的页数缓慢上涨——每触碰一个新页,就缺一次页、装一个页框;
  3. 进阶:用 /usr/bin/time -v ./a.out 观察 Major (requiring I/O) page faultsMinor 的数量,对比"只分配不访问"和"访问大量分散页"两种情况的差异。

做完这个实验,第一节的"字典与书桌"、第二节的"按需装入"就从比喻变成了你亲眼盯着 top 看到的真实数字。

学习这一部分最容易踩的坑

1. 把虚拟内存当成"凭空变出了更多内存"

它没有创造物理容量。它做的是"逻辑空间很大、物理只装活跃页、磁盘兜底",用时间(偶尔的磁盘调页)换空间。物理内存一个字节都没多。

2. 把 VIRT 当成真实内存占用

进程"占用内存大"指的常是虚拟地址空间(VIRT),和真正驻留物理内存(RSS)不是一回事。看内存压力要盯 RSS 和缺页指标,盯 VIRT 会被巨大的数字吓到。

3. 以为访问大虚拟空间总是快的

只有当活跃工作集装得进物理内存、局部性成立时才快。一旦随机访问击穿局部性、活跃页超出内存,缺页频发,性能断崖下跌——这就是抖动(下一篇详述)。

4. 把缺页异常和段错误混为一谈

缺页(page fault)是按需装入的正常机制,处理完程序继续跑;段错误(SIGSEGV)是访问了非法/无权限地址的真错误。二者都"陷入内核",但一个是正常调页、一个是致命错误,方向完全不同。

总结

虚拟内存的核心并不神秘:它利用局部性,把"看起来很大"和"真正同时在内存里"这两件事分开处理。把机制和结论一起带走:

  • 大的是逻辑地址编号范围,不是物理占用,二者必须分清;
  • 按需装入:页只在第一次被访问时才装入,启动时几乎不预装,边跑边调;
  • 靠页表项的有效位区分页在不在内存,有效位为 0 时访问触发缺页异常——这是机制设计的正常信号,不是错误;
  • 缺页处理流程:判缺页 → 找/腾页框(满了就置换)→ 磁盘调入 → 更新页表有效位 → 重新执行指令,对程序完全透明;
  • 高效运转的前提是局部性:少数活跃页命中内存、稀疏缺页摊薄磁盘代价;局部性失效就会抖动;
  • “看到更大空间” = 大逻辑编号范围 + 按需只装活跃页 + 磁盘换页兜底,全都建立在分页能力之上。

当你能盯着 top 说清 VIRT 和 RES 为什么差那么多、一次 major fault 背后走了哪几步,这一篇就真正属于你了。但虚拟内存的优雅是有条件的——下一篇《页面置换算法》先解决缺页处理里"换谁出去"的核心难题,再下一篇《抖动与工作集》则直面局部性失效时的灾难。

参考资源

  • 《现代操作系统》(Andrew S. Tanenbaum)虚拟内存章节
  • 《操作系统导论》(Operating Systems: Three Easy Pieces)Beyond Physical Memory
  • OSTEP - Beyond Physical Memory: Mechanisms
  • 《深入理解计算机系统》虚拟内存章节