48. 虚拟内存:页表、TLB、缺页中断

虚拟内存这个话题,说实话,很多C++程序员觉得跟自己没关系。毕竟我们写的是应用层代码,内存管理有操作系统兜底。但我在做性能优化和内存泄漏排查时,发现不懂虚拟内存,很多问题根本解释不通。

举个例子,你new了一个对象,拿到一个地址。这个地址是物理地址吗?不是。它是虚拟地址。你的程序从头到尾,看到的都是虚拟地址。物理内存长什么样,你的程序根本不知道。这就是虚拟内存的核心思想——给每个进程一个独立的、连续的地址空间,让它们以为自己独占整个内存。

页表:虚拟地址到物理地址的映射

虚拟地址怎么变成物理地址?靠页表。

操作系统把虚拟内存和物理内存都切成固定大小的块,叫「页」。通常是4KB。页表就是一张映射表,记录每个虚拟页对应哪个物理页。

我当年刚接触这个概念时,觉得页表就是个数组,虚拟页号做下标,直接查物理页号。后来发现太天真了。32位系统,虚拟地址空间4GB,按4KB分页,有1M个页。每个页表项4字节,光一级页表就要4MB。每个进程一个页表,100个进程就是400MB。这还不算多级页表带来的额外开销。

实际用的是多级页表。x86-64架构通常用4级页表。什么意思?就是查一次不行,要查4次。每一级找到下一级的基地址,最后一级才找到物理页号。

核心要点: 多级页表节省内存。因为大部分虚拟地址空间是空的,不需要分配页表项。只有真正用到的地址范围,才分配对应的页表层级。

TLB:加速地址转换的硬件缓存

多级页表查4次才能得到物理地址,这性能能忍?当然不能。所以CPU搞了个硬件缓存,叫TLB(Translation Lookaside Buffer)。

TLB直接缓存虚拟页号到物理页号的映射。查TLB命中,一次搞定。没命中,才走页表查询流程。

我记得有一次排查性能问题,发现某个模块的内存访问特别慢。用perf一看,TLB miss率高达15%。正常应该在1%以下。后来发现是内存分配太分散,导致工作集太大,TLB根本装不下。解决方案是改用大页(2MB页),TLB能覆盖的内存范围瞬间大了512倍。

实战技巧: 如果你的程序有大量随机内存访问,且性能敏感,考虑用mmap的MAP_HUGETLB或者透明大页。TLB命中率提升,效果立竿见影。

缺页中断:按需加载的基石

虚拟内存还有一个关键机制——缺页中断。进程访问一个虚拟地址,页表里没有对应的物理页,CPU就会触发缺页中断,操作系统接管。

缺页中断分几种情况:

  • 首次访问: 页面还没加载到物理内存。操作系统从磁盘(swap或文件)读入。
  • 页面被换出: 物理内存不够,操作系统把不常用的页换到磁盘。再次访问时重新换入。
  • 写时复制(COW): fork出来的子进程和父进程共享页面,标记为只读。谁要写,就触发缺页中断,复制一份。

我曾经在项目中遇到一个诡异的内存泄漏。程序跑着跑着,物理内存占用越来越高,但用valgrind查不到泄漏。后来发现是缺页中断处理太频繁,导致大量内存碎片和页表膨胀。说白了,就是程序在不停地触发缺页,操作系统疲于奔命。

避坑指南: 我曾经见过有人用memset初始化一大块刚mmap的内存,以为能加速。结果反而触发大量缺页中断,性能暴跌。mmap返回的页面是虚拟的,真正访问时才分配物理页。memset会逐页触发缺页,还不如让操作系统按需加载。

虚拟内存与C++程序员的关联

你可能会问,这些底层机制跟我写C++代码有什么关系?关系大了。

  • 内存分配器: malloc/new底层调用brk或mmap。mmap分配的内存,虚拟地址连续,物理地址不一定。大块内存用mmap,小块用brk。
  • 内存占用分析: 用top看RES(常驻内存)和VIRT(虚拟内存)。VIRT很大不代表物理内存占用高,可能只是映射了文件但没访问。
  • 性能优化: 内存访问模式影响TLB命中率。尽量顺序访问,减少随机跳跃。数据结构紧凑排列,提高空间局部性。

我个人习惯在性能调优时,先看/proc/self/status里的VmPeak和VmRSS。如果VIRT远大于RES,说明程序分配了很多虚拟内存但没实际使用。这时候要检查是不是有内存泄漏或者过度预分配。

核心流程:一次内存访问的完整路径

下面这张图展示了CPU访问一个虚拟地址时,从TLB到页表再到物理内存的完整流程:

虚拟内存访问流程 CPU 虚拟地址 TLB TLB命中 物理地址 物理内存 页表查询 多级页表遍历 缺页中断 磁盘/Swap 加载页面 更新页表 更新TLB 正常路径 缺页路径 磁盘I/O 回写/更新

流程其实不复杂。CPU发出虚拟地址,先查TLB。命中直接拿物理地址访问内存。没命中就去查页表。页表里找到物理页,更新TLB,然后访问内存。如果页表里也没有,触发缺页中断,操作系统从磁盘加载页面,更新页表和TLB,再重新执行指令。

嗯,这里要注意:缺页中断是非常昂贵的操作。一次磁盘I/O的代价是纳秒级的CPU指令的几万倍。所以程序如果频繁触发缺页中断,性能会急剧下降。

总结

虚拟内存、页表、TLB、缺页中断,这四者构成了现代操作系统内存管理的基石。作为C++工程师,理解这些机制能帮你写出更高效、更稳定的程序。

我个人建议,遇到内存相关的诡异问题,先想想是不是虚拟内存层面出了问题。比如内存占用异常高,可能是页表膨胀;性能突然下降,可能是TLB miss率飙升;程序卡顿,可能是缺页中断太频繁。这些问题的根因,往往不在应用层代码里。

一句话记住: 虚拟地址是假的,物理地址是真的,TLB是加速器,缺页中断是兜底。你的程序看到的都是假象,但操作系统帮你把假象变成了现实。

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