进程眼里的连续内存,物理上其实并不相邻:页表在中间做了什么

虚拟内存是内核给每个进程造的一套独立地址空间,进程看到的连续地址并不对应连续物理内存,中间靠页表逐级翻译。页表记录虚拟页到物理页框的映射,多级结构让稀疏地址空间不必为整片虚拟区间买单,TLB 则把常用的翻译结果缓存在 CPU 里。常见误区是把虚拟内存等同于 swap、把缺页异常当成内存不足,或看到 VSZ 很大就以为占了大内存。这也解释了进程为何能“独占”内存,以及内存告警时该看哪个指标。读完你能说清一次访存如何经过页表与 TLB、缺页时内核做了什么,以及 RSS、PSS 与大页的实际含义。


title: 进程眼里的连续内存,物理上其实并不相邻:页表在中间做了什么 date: 2026-11-06T20:00:00+08:00 type: kb tags: [科普] status: published summary: 虚拟内存是内核给每个进程造的一套独立地址空间,进程看到的连续地址并不对应连续物理内存,中间靠页表逐级翻译。页表记录虚拟页到物理页框的映射,多级结构让稀疏地址空间不必为整片虚拟区间买单,TLB 则把常用的翻译结果缓存在 CPU 里。常见误区是把虚拟内存等同于 swap、把缺页异常当成内存不足,或看到 VSZ 很大就以为占了大内存。这也解释了进程为何能“独占”内存,以及内存告警时该看哪个指标。读完你能说清一次访存如何经过页表与 TLB、缺页时内核做了什么,以及 RSS、PSS 与大页的实际含义。 ---

是什么

虚拟内存是内核给每个进程发放的一套独立地址空间。进程发出的 load/store 用的是虚拟地址(VA),真正落到内存条上的是物理地址(PA),中间的翻译由 CPU 里的 MMU 配合页表完成。直观地说,进程拿到的是一本只属于它的门牌对照册:册子上写着“我要访问第 3 号页”,至于这个 3 号页落在物理内存的哪一帧,由册子决定;换一个进程换一本册子,同一个 3 号页就指向另一块物理内存,于是两个进程即使地址相同也互不干扰。

这套机制解决的第一个问题是隔离,第二个问题是让有限的物理内存装下比它更大的地址空间:代码、堆、栈、共享库、文件映射都可以先登记在虚拟地址上,真正访问时再分配物理页。日常遇到的 mmap、malloc 大块分配、fork 后的写时复制、容器里的 OOM、RSS 与 VSZ 的差异,背后都是同一套翻译与缺页流程。

核心原理

先看 x86-64 上最常见的配置:4 KiB 页、4 级页表、48 位虚拟地址。一个 VA 被切成 5 段:最高的 4 个 9 位分别索引 PML4、PDPT、PD、PT 四张表,最低的 12 位是页内偏移。每张表正好 512 项,每项 8 字节,合起来 4 KiB,刚好一页。CR3 寄存器保存顶级表的物理基址,进程切换时内核换掉 CR3,地址空间也就跟着换了。

每个页表项(PTE)不只是一个物理页框号,还带一串权限位:Present 表示这一页是否在内存里,Writable 控制写权限,User 区分用户态与内核态,NX 禁止执行,Accessed 和 Dirty 供回收算法参考。翻译一次 4 KiB 页要逐级读 4 张表,也就是 4 次访存;如果每一级都 miss,CPU 为了取一个字节要先多花 4 次内存访问,这个开销靠 TLB 摊平。

为什么不直接用一张大表?48 位地址、4 KiB 页意味着 2^36 个虚拟页,每项 8 字节,单级页表要 512 GiB,而且每个进程一份,显然不可行。多级表把“连续的数组”换成“稀疏的树”:只用到的地址区间才分配下级表,一个只映射几页的进程,页表开销可以小到十几 KiB。代价是查表变深,以及页表本身仍要占内存——把 48 位空间全部映射满,光最后一级 PTE 就约占物理内存的 0.2%,还没算各级目录。这也是为什么“虚拟地址空间大”不等于“马上占掉同样多的内存”。

TLB 是 MMU 旁边的一张小缓存,存的是“虚拟页号到物理页框号加权限”的近期结果。命中时一两个周期就能拿到物理地址,未命中才走上面那 4 次访存,并把结果回填。TLB 项数有限,常见只有几十到上千项;按 4 KiB 一页算,全命中最多也就覆盖几 MB 的工作集,所以大数组随机访问、指针追逐这类负载很容易被 TLB miss 拖慢。

现代 CPU 用 PCID 给 TLB 项打上地址空间标签,进程切换时不必把整张 TLB 刷掉;但只要内核改的是仍然有效的映射(munmap、mprotect、COW、页迁移),其他核上可能还缓存着旧翻译,通常就要发 IPI 做 TLB shootdown。这个操作是跨核同步,代价在微秒级,核越多越明显;分配器频繁 mmap/munmap 造成的大片失效,有时比分配本身还贵。

大页是另一条路:2 MiB 页用一个 PMD 项替掉 512 个 PTE,同样的 TLB 项数能覆盖 512 倍的地址范围,页表层级也少走一级;x86-64 还支持 1 GiB 页。代价是内部碎片(只用了 100 KiB 也要占 2 MiB)、分配时的连续内存压力,以及透明大页在 khugepaged 合并、拆分时的延迟抖动。所以大页不是“开了就快”,得看工作集是否大而连续、是否能接受尾延迟。

页表项 Present=0,或者权限不满足,CPU 就抛出缺页异常(#PF),把出错的虚拟地址放进 CR2。注意它是同步异常,不是磁盘或网卡那种外部中断;处理完之后 CPU 会重新执行同一条指令,所以对用户态是透明的。内核先查这个地址落在哪个 VMA(虚拟内存区域)里:不在任何 VMA、或权限不符,就发 SIGSEGV;合法则分几种情况处理。

匿名页第一次写,分配一个清零的物理页;文件映射的页不在 page cache,就从磁盘读进来(还可能触发预读);fork 之后父子共享的私有页被标成只读,任何一方写都会触发 COW,内核复制一份再改成可写;被换出到 swap 的页,则要换回来。其中不需要 I/O 的(首次匿名页、COW、建立页表映射)记为 minor fault,需要读磁盘或 swap 的记为 major fault。两者都叫“缺页”,但一个只是建立映射,一个真的在等 I/O,性能含义完全不同。

下面这段程序把 256 MiB 的匿名映射建好,先打印 VmSize 与 VmRSS,再逐页写一个字节,然后再次打印。mmap 只承诺虚拟地址范围,物理页要到第一次触碰才分配。

#include <stdio.h>
#include <string.h>
#include <sys/mman.h>

static void show(const char *t) {
    char b[256]; FILE *f = fopen("/proc/self/status", "r");
    while (fgets(b, sizeof b, f))
        if (!strncmp(b, "VmSize:", 7) || !strncmp(b, "VmRSS:", 6)) printf("%s %s", t, b);
    fclose(f);
}

int main(void) {
    size_t n = 256UL << 20;
    volatile char *p = mmap(0, n, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) return 1;
    show("before:");
    for (size_t i = 0; i < n; i += 4096) p[i] = 1;
    show("after: ");
    return 0;
}

运行后 VmSize 一开始就多了约 256 MiB,VmRSS 却几乎不变;逐页触碰后 RSS 才涨上来。这也解释了为什么“申请成功”不等于“内存已经到手”:在 overcommit 的默认策略下,malloc/mmap 可以先批出地址,等真正缺页时才发现物理内存不够,最后可能由 OOM killer 收场。

还有一个常被忽略的账本:RSS 统计的是当前驻留在物理内存里的页,共享库会被每个映射它的进程各算一遍;PSS 则把共享页按映射数均摊,所以多进程场景下 PSS 之和更接近真实占用。VSZ 只是虚拟地址空间的大小,和“已经用了多少内存”没有直接等式。

常见考点与误区

  • “虚拟内存就是 swap”:错。虚拟内存是地址翻译与隔离的抽象,swap 只是匿名页的后备存储。机器关掉 swap,虚拟内存照样工作;反过来,没有虚拟内存机制,swap 也无从谈起。
  • “多级页表是为了省内存,所以级数越多越好”:多级省的是稀疏地址空间里未分配的部分,不是无脑加层。级数越多,一次 TLB miss 的 walk 越长,页表占的常驻内存也可能更多;单级 512 GiB 的反例说明的是“不能平坦映射”,不是“层级随便加”。
  • “缺页异常说明内存不够或程序错了”:绝大多数缺页是正常流程:首次触碰匿名页、文件页未缓存、COW、栈增长都会触发。地址非法或权限不符会走到 SIGSEGV;把缺页率高等同于内存泄漏,会查错方向。
  • “TLB 命中就不用页表,TLB 也不会过期”:TLB 只是缓存,页表才是真相。内核改 PTE 后必须失效对应项,多核还要 shootdown;否则一个核可能继续用旧映射读写已经 munmap 的页。
  • “VSZ 大就是占内存多”:VSZ 是虚拟地址空间,保留不等于驻留;RSS 才是驻留页,且共享页重复计数。排查容器内存应看 RSS、PSS 与 cgroup 统计,OOM 评分也不是按 VSZ 算的。
  • “fork 会立刻复制父进程内存”:Linux 用 COW,fork 时只复制页表并把私有页标只读,写时才复制;所以 fork 本身通常不复制数据页,但地址空间很大时复制页表仍可能很慢,且写 2 MiB 大页会一次复制 2 MiB。
  • “大页一定比 4 KiB 页快”:只有工作集大、访问密集且能接受碎片与延迟时收益才明显。小对象、稀疏访问、频繁 fork 的场景,THP 的合并、拆分与 COW 放大反而添乱。
  • “64 位机器就能用满 64 位地址”:x86-64 常见实现只用 48 位,5 级页表才扩到 57 位;用户态通常只拿到低半区。地址空间上限是硬件与内核共同决定的,不是位宽的字面值。

实践建议

  • 先分清指标:/proc/<pid>/status 的 VmSize、VmRSS,/proc/<pid>/smaps_rollup 的 Pss,容器再看 cgroup 的 memory.current 与 memory.stat(anon/file)。定位泄漏看 RSS 与匿名页的增长趋势,不要拿 VSZ 当报警阈值。
  • 把缺页量化出来:/usr/bin/time -v 看 minor/major page faults,getrusage 的 ru_minflt、ru_majflt 做长稳监控,perf stat -e page-faults,dTLB-load-misses 判断是缺页还是 TLB 压力。major fault 多,先查 swap、文件冷读和内存回收;minor fault 多,先查首次触碰、COW 和分配器行为。
  • 大页按需开:延迟敏感的服务先把 THP 设为 madvise,只在明确的大块热数据上用 MADV_HUGEPAGE;数据库、Redis 这类对尾延迟敏感的进程通常更愿意关掉 THP 或用显式 hugetlb。上线前把 AnonHugePages 和 P99 放在一起看,别只看吞吐。
  • 控制 VMA 数量:vm.max_map_count 默认常见为 65530,映射数超限时 mmap 会返回 ENOMEM,看起来像“内存不够”,其实是地址空间管理满了;OpenSearch、Elasticsearch 一类多分片进程会把它调到 262144。应用侧复用缓冲区、批量映射,别把每个小对象都 mmap 一次。
  • fork 前先算账:大 RSS 进程配 COW 虽然不复制数据,但页表复制和后续写放大仍存在;能用 posix_spawn、vfork+exec 就别用 fork 跑长逻辑,子进程 exec 之前尽量少碰内存。
  • 对延迟极敏感的小块内存,可以在启动时用 MAP_POPULATE 预缺页,或用 mlock 锁定,把缺页成本从请求路径挪到启动阶段;但要清楚 mlock 受 RLIMIT_MEMLOCK 限制,锁太多会让系统在内存紧张时更早触发 OOM。
  • overcommit 要显式决策:vm.overcommit_memory=2 能让大额申请更早失败,避免“先成功、后 OOM”,但也可能让正常服务申请不到内存;默认启发式并不保证不超发,容量规划仍要留出页缓存和内核的开销。

一句话总结:虚拟内存把地址与内存解耦,页表负责翻译、TLB 负责加速、缺页负责按需兑现,三者合起来才是完整图景。

相关阅读

← 返回文章志