虚拟内存:为什么能“看到”比物理内存更大的空间
在 64 位机器上随便写个程序,它的地址空间理论上有 16EB(百亿亿字节)那么大;就算是一台只有 16GB 内存的机器,你也可以让一个进程 malloc 出几十 GB 的空间而不立刻崩溃。更日常的是:你同时开着浏览器、IDE、好几个 Docker 容器,把它们"声称需要"的内存加起来,早就超过了机器的物理内存——可机器还在好好跑。
这显然不是因为内存条变魔术变大了。物理内存就那么多,一个字节都没多。那程序"看到"的那个远大于物理内存的空间,究竟是怎么变出来的?
程序拥有的是一个巨大的逻辑地址空间,但任一时刻真正占用物理内存的,只是其中很小的一部分。
这一篇我们先把虚拟内存的运转机制讲透——什么叫按需装入、一次缺页到底发生了什么、页表里靠哪个标志位区分"在不在内存"、缺页处理走哪几步——看清这套机制之后,再回答那个核心问题:它凭什么能让程序用上"看起来比物理内存更大"的空间。它建立在前面分页机制的地基之上,是分页能力的一次华丽兑现。
一、先破除幻觉:大地址空间不等于大物理占用
理解虚拟内存的第一步,是把两件常被混为一谈的事彻底分开:
- 虚拟地址空间的大小:进程的逻辑地址能编到多大。这只是一个"编号范围",是地址位数决定的上限,跟实际占了多少内存毫无关系;
- 物理内存的实际占用:进程的页里,真正被装进物理内存(页框)的那部分有多大。
打个比方:你拥有一本几千页的字典(巨大的虚拟地址空间),但你的书桌(物理内存)一次只摊得开几页。你不需要把整本字典同时摊在桌上——用到哪一页,才把哪一页翻开放到桌上。字典再厚,桌子小一点也够用。
虚拟内存做的就是这件事:给进程一个很大的"逻辑空间编号范围",但只把它当前真正用到的那几页,放进有限的物理内存里。 剩下的页,要么还没被访问过(根本没分配物理内存),要么暂时被挪到了磁盘上。
这就是它能"看到更大空间"的根本——大的是地址编号范围,不是物理占用。而支撑这个把戏的关键前提,正是上一篇分页机制带来的能力:逻辑页和物理页框可以任意映射、按页为单位独立管理。 没有分页,虚拟内存就无从谈起。
二、按需装入:页只在被访问的那一刻才进内存
虚拟内存的核心运转机制叫按需装入(demand paging),又叫请求调页。规则极其简洁:
一个页,只有在它第一次真正被访问的那一刻,才被装入物理内存。在那之前,它要么在磁盘上,要么压根还没分配。
这彻底改变了"加载程序"的含义。在没有虚拟内存的世界里,运行一个程序必须先把它完整地装入内存才能开始执行。而在按需装入下,启动一个程序时,操作系统几乎不预先装入任何页,只是建立好地址空间的"账本"(页表),然后就直接跳到入口开始执行——
- CPU 执行第一条指令,访问指令所在的页 → 这个页不在内存 → 触发装入 → 装进来 → 继续;
- 接着访问某个全局变量所在的页 → 不在内存 → 装入 → 继续;
- ……程序就这样边跑边把用到的页一个个调进来。
那些从头到尾没被执行到的代码(比如错误处理分支、你从没点过的功能),它们的页可能永远不会被装入。这正是按需装入的精髓:不为"可能用到"付钱,只为"真正用到"付钱。
三、页表里的关键开关:有效位
要实现"访问时才发现页不在内存",硬件必须有办法判断某个页到底在不在物理内存里。这个判断,靠的是页表项里的一个标志位——有效位(valid bit,也叫存在位 present bit)。
回忆分页篇:页表项里除了物理页框号,还带一组标志位。其中有效位的含义是:
- 有效位 = 1:这个页当前在物理内存里,页表项里的页框号有效,可以直接转换访问;
- 有效位 = 0:这个页当前不在物理内存里(在磁盘上,或尚未分配),页框号无效。
于是地址转换时多了一道判断:硬件查页表拿到页表项后,先看有效位。如果是 1,照常拼接出物理地址访问;如果是 0,硬件就触发一个异常——这个异常,就是大名鼎鼎的缺页异常(page fault)。
缺页异常不是程序出错,而是虚拟内存机制主动设计出来的"信号":它告诉操作系统"有个页现在需要、但还不在内存,请把它弄进来"。
请务必区分:缺页(page fault,正常的按需调页)和非法访问(如越界、写只读页,对应段错误 SIGSEGV)是两回事。前者是机制的正常运转,后者才是真正的错误。
四、缺页处理的完整流程:一次缺页到底发生了什么
缺页异常触发后,控制权交给操作系统的缺页处理程序。这是虚拟内存最关键的一段机制,我们把它完整走一遍:
逐步拆解这条流程:
- 判断是否缺页:CPU 查页表,发现目标页有效位为 0,触发缺页异常,陷入操作系统;
- 找一个物理页框:操作系统需要一块空闲页框来装这个页。如果有空闲页框,直接用;如果物理内存已满,就必须运行页面置换算法,选一个"牺牲页"换出去腾位置(如果这个牺牲页被修改过、是"脏页",还得先把它写回磁盘);
- 从磁盘调入:把所需的页从磁盘(可执行文件、或交换区 swap)读进刚腾出的页框。这是一次磁盘 I/O,极其耗时;
- 更新页表:把这个页的页表项填上新的页框号,并把有效位置 1;
- 重新执行指令:操作系统返回,重新执行刚才那条因缺页而中断的指令。这次页已经在内存了,转换顺利通过。
注意第 5 步的"重新执行"很关键——触发缺页的那条指令当时并没真正完成,处理完缺页后它会被重新执行一遍,对程序来说这一切完全透明,它根本不知道自己刚被中断过、磁盘刚转了一圈。这种透明性正是虚拟内存的优雅之处。
而这张图也顺势引出了下一篇的主角:第 2 步里"内存满了,选哪个牺牲页换出",就是页面置换算法要回答的问题。 缺页处理是舞台,置换算法是台上的主角之一。
五、局部性:虚拟内存能高效运转的隐藏前提
看到这里你可能会担心:如果每次访问都缺页、都要等磁盘 I/O,那程序岂不是慢得没法用?磁盘比内存慢成千上万倍,频繁缺页是灾难性的。
虚拟内存之所以实际上跑得飞快,靠的是程序行为的一个普遍规律——局部性原理(locality):
- 时间局部性:刚被访问的数据/指令,很可能在不久后再次被访问(比如循环体、热点函数);
- 空间局部性:刚被访问的位置附近,很可能马上也被访问(比如顺序遍历数组、顺序执行指令)。
因为局部性,程序在一段时间内的访问其实高度集中在少数几个页上。这意味着:只要把这少数活跃页留在物理内存里,绝大多数访问就能直接命中、根本不缺页;只有当程序"切换工作焦点"时才偶尔缺一次页。于是缺页是稀疏的、可摊薄的,磁盘 I/O 的代价被均摊到海量的内存命中里,整体性能依然很高。
虚拟内存不是凭空创造容量,而是用"时间换空间":靠局部性保证大多数访问命中内存,只在少数时刻支付磁盘调页的代价。
这也解释了一个重要的反直觉点:访问超大虚拟空间并不总是快的。 一旦你的访问模式破坏了局部性(比如在远大于内存的数据上做完全随机访问),活跃页就会超出物理内存能容纳的范围,缺页频繁发生,性能断崖式下跌——这就是下一篇要讲的"抖动"。局部性是虚拟内存优雅的前提,一旦它失效,优雅立刻变成灾难。
六、回答开篇:到底是怎么"变大"的
现在把机制串起来,正面回答标题的问题。虚拟内存能让程序看到比物理内存更大的空间,是三件事共同作用的结果:
- 地址空间是逻辑编号,本就可以很大:64 位地址给了天文数字般的编号范围,这只是"能编多少号",不消耗物理内存;
- 按需装入让物理占用只跟"实际用到的页"挂钩:你
malloc几十 GB,只要不去碰它,那些页大多没有真正分配物理内存(甚至连缺页都还没触发);真正占物理内存的只有你访问过的活跃页; - 磁盘作为后备扩展了"可容纳的总量":当活跃页超过物理内存时,不活跃的页可以被换出到磁盘(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。
八、动手做一个小实验:观察大地址空间程序
理解"逻辑大、物理小"最直接的方式,是写个程序亲眼看一看。
- 写一个程序,一次性
malloc一块很大的内存(比如 4GB),然后什么都不做、不去访问它,让程序睡眠。用top观察:你会看到VIRT涨了约 4GB,但RES几乎没变——因为那些页还没被访问,根本没占物理内存; - 改一下程序,分配后只访问其中很小一部分(比如每隔很大步长写一个字节)。再看
RES,它会随你实际触碰的页数缓慢上涨——每触碰一个新页,就缺一次页、装一个页框; - 进阶:用
/usr/bin/time -v ./a.out观察Major (requiring I/O) page faults和Minor的数量,对比"只分配不访问"和"访问大量分散页"两种情况的差异。
做完这个实验,第一节的"字典与书桌"、第二节的"按需装入"就从比喻变成了你亲眼盯着 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
- 《深入理解计算机系统》虚拟内存章节