虚拟内存与分页:虚拟地址空间、页表、TLB、大页、mmap与匿名映射

各位同学,今天我们来聊聊虚拟内存和分页。说实话,这是整个内存管理里最核心的一块。我当年刚接触这部分时,觉得它就是个抽象概念,直到在项目中踩了坑,才真正理解它的重要性。

虚拟地址空间:程序眼中的“假象”

每个进程都以为自己独占整个内存空间。在64位系统上,这个空间大到16EB(exabytes)。当然,物理内存没这么大。操作系统只是给每个进程画了个大饼——虚拟地址空间。

虚拟地址空间通常分成两部分:

  • 用户空间:从0x0000000000000000到某个分界点(比如0x00007fffffffffff),应用程序在这里运行
  • 内核空间:剩下的高位地址,只有内核能访问

我见过不少新手问:“为什么指针可以指向那么大的地址?”其实你看到的地址是虚拟的,不是真实的物理地址。操作系统负责把虚拟地址翻译成物理地址。

页表:地址翻译的“字典”

虚拟地址怎么变成物理地址?靠页表。页表就是一张映射表,记录了每个虚拟页面对应的物理页面。

现代系统通常使用多级页表。为什么?因为单级页表太浪费内存了。你想想看,如果页大小是4KB,虚拟地址空间是48位,那页表项得有2^36个。每个页表项8字节,光页表就要512GB内存——这谁受得了?

多级页表就好多了。它只分配实际用到的页表层级,没用的部分不分配。以x86-64的四级页表为例:

// 虚拟地址分解(48位)
// 9位 | 9位 | 9位 | 9位 | 12位
// PML4 | PDP | PD | PT | 页内偏移

每个级别索引9位,最后12位是页内偏移。这样页表项总数大大减少。我在项目中遇到过一个问题:一个进程mmap了1GB内存,但只访问了前4KB,结果页表只分配了必要的层级,内存占用很小。这就是多级页表的优势。

TLB:加速地址翻译的“缓存”

页表在内存里,每次地址翻译都要查内存,太慢了。CPU引入了TLB(Translation Lookaside Buffer),专门缓存最近用过的页表项。

TLB命中时,地址翻译几乎零开销。TLB未命中时,CPU得去查页表,那可就慢了——一次页表遍历可能要访问好几次内存。

我调过一些性能敏感的程序,发现TLB缺失率高了,性能直接掉一个数量级。怎么优化?减少页表遍历次数。比如用大页

大页:减少TLB压力的利器

默认页大小是4KB。如果程序用了2MB的连续内存,那需要512个页表项。TLB只有几十个条目,很容易被刷掉。

大页(Huge Pages)把页大小提高到2MB甚至1GB。一个页表项就能覆盖大片内存,TLB利用率大幅提升。

页大小 覆盖2GB内存需要的页表项数 TLB压力
4KB 524,288 极高
2MB 1,024
1GB 2 极低

我建议在数据库、大型计算这类场景里用大页。不过要注意,大页分配是连续的物理内存,系统碎片多了可能分配失败。我曾经在线上环境遇到过这个问题——大页分配失败,程序直接OOM。后来我改用透明大页(THP),让内核自动管理,省心不少。

mmap与匿名映射:灵活的内存管理

mmap是Linux里最强大的内存映射接口。它可以把文件映射到进程的虚拟地址空间,也可以创建匿名映射——不关联文件,纯粹分配内存。

匿名映射的典型用法:

#include <sys/mman.h>
#include <unistd.h>

// 分配1GB匿名内存
size_t size = 1UL << 30;  // 1GB
void* addr = mmap(NULL, size, 
                  PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS,
                  -1, 0);

if (addr == MAP_FAILED) {
    perror("mmap failed");
    exit(1);
}

// 使用内存...

// 释放
munmap(addr, size);

mmap分配的内存是惰性分配的。你调用mmap时,内核只是预留了虚拟地址空间,并没有真正分配物理页面。只有当你实际读写这些页面时,才会触发缺页中断,内核才分配物理内存。

这个特性很有用。我做过一个缓存系统,用mmap分配了很大的虚拟地址空间,但实际只用了其中一小部分。内存利用率很高。

关键点:mmap的MAP_ANONYMOUS标志表示匿名映射,不关联文件。MAP_PRIVATE表示写时复制,子进程继承映射时,父子进程各自独立修改。

缺页中断:虚拟内存的“最后一公里”

当程序访问一个虚拟地址,但对应的物理页面不在内存里时,CPU触发缺页中断。内核在中断处理里做三件事:

  1. 检查虚拟地址是否合法(在进程的地址空间内)
  2. 分配物理页面(从空闲列表或交换分区)
  3. 更新页表,建立映射

缺页中断是性能杀手。我优化过一个服务,发现大量时间花在缺页处理上。后来用大页+预分配(madvise的MADV_WILLNEED)解决了。

小技巧:用madvise告诉内核你的访问模式。比如MADV_SEQUENTIAL表示顺序访问,内核会提前预取;MADV_RANDOM表示随机访问,内核会减少预取。

SVG:虚拟内存与分页核心流程

虚拟内存与分页核心流程 虚拟地址空间 每个进程独享 用户空间 + 内核空间 大小:48位地址空间 页表遍历 多级页表 PML4 → PDP → PD → PT 每级9位索引 最后12位页内偏移 TLB加速 TLB 缓存最近页表项 命中:零开销 未命中:查页表 缺页中断 物理内存 实际物理页面 分配策略:惰性 大页:2MB/1GB 大页(Huge Pages) 减少TLB压力 适合大型应用 mmap / 匿名映射 灵活分配,惰性物理页 大页减少页表项 mmap分配虚拟地址

避坑指南

我总结几个常见问题:

  • 不要假设虚拟地址等于物理地址:调试时打印的地址是虚拟的,不能直接用于DMA等硬件操作
  • 大页不是银弹:分配失败会回退到普通页,程序可能变慢。我建议用透明大页
  • mmap的内存不会立即占用物理内存:写代码时别以为mmap完就万事大吉,实际物理内存是在访问时才分配的
  • TLB刷新代价高:频繁切换进程会导致TLB刷新,影响性能。可以考虑用大页减少TLB压力

警告:mmap分配的内存,如果忘记munmap,会导致虚拟地址泄漏。虽然进程退出时会自动回收,但长期运行的服务会慢慢耗尽地址空间。我曾经在监控系统里发现一个服务地址空间涨到几百TB,最后排查发现是mmap没释放。

好了,虚拟内存和分页就讲到这里。记住一句话:虚拟内存是操作系统给每个进程的“假象”,页表是翻译官,TLB是加速器,大页是优化手段,mmap是灵活的工具。理解这些,你就能写出更高效、更稳定的C++程序。


公众号:蓝海资料掘金营,微信deep3321