虚拟内存与分页:虚拟地址空间、页表、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触发缺页中断。内核在中断处理里做三件事:
- 检查虚拟地址是否合法(在进程的地址空间内)
- 分配物理页面(从空闲列表或交换分区)
- 更新页表,建立映射
缺页中断是性能杀手。我优化过一个服务,发现大量时间花在缺页处理上。后来用大页+预分配(madvise的MADV_WILLNEED)解决了。
小技巧:用madvise告诉内核你的访问模式。比如MADV_SEQUENTIAL表示顺序访问,内核会提前预取;MADV_RANDOM表示随机访问,内核会减少预取。
SVG:虚拟内存与分页核心流程
避坑指南
我总结几个常见问题:
- 不要假设虚拟地址等于物理地址:调试时打印的地址是虚拟的,不能直接用于DMA等硬件操作
- 大页不是银弹:分配失败会回退到普通页,程序可能变慢。我建议用透明大页
- mmap的内存不会立即占用物理内存:写代码时别以为mmap完就万事大吉,实际物理内存是在访问时才分配的
- TLB刷新代价高:频繁切换进程会导致TLB刷新,影响性能。可以考虑用大页减少TLB压力
警告:mmap分配的内存,如果忘记munmap,会导致虚拟地址泄漏。虽然进程退出时会自动回收,但长期运行的服务会慢慢耗尽地址空间。我曾经在监控系统里发现一个服务地址空间涨到几百TB,最后排查发现是mmap没释放。
好了,虚拟内存和分页就讲到这里。记住一句话:虚拟内存是操作系统给每个进程的“假象”,页表是翻译官,TLB是加速器,大页是优化手段,mmap是灵活的工具。理解这些,你就能写出更高效、更稳定的C++程序。