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倍。
缺页中断:按需加载的基石
虚拟内存还有一个关键机制——缺页中断。进程访问一个虚拟地址,页表里没有对应的物理页,CPU就会触发缺页中断,操作系统接管。
缺页中断分几种情况:
- 首次访问: 页面还没加载到物理内存。操作系统从磁盘(swap或文件)读入。
- 页面被换出: 物理内存不够,操作系统把不常用的页换到磁盘。再次访问时重新换入。
- 写时复制(COW): fork出来的子进程和父进程共享页面,标记为只读。谁要写,就触发缺页中断,复制一份。
我曾经在项目中遇到一个诡异的内存泄漏。程序跑着跑着,物理内存占用越来越高,但用valgrind查不到泄漏。后来发现是缺页中断处理太频繁,导致大量内存碎片和页表膨胀。说白了,就是程序在不停地触发缺页,操作系统疲于奔命。
虚拟内存与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,然后访问内存。如果页表里也没有,触发缺页中断,操作系统从磁盘加载页面,更新页表和TLB,再重新执行指令。
嗯,这里要注意:缺页中断是非常昂贵的操作。一次磁盘I/O的代价是纳秒级的CPU指令的几万倍。所以程序如果频繁触发缺页中断,性能会急剧下降。
总结
虚拟内存、页表、TLB、缺页中断,这四者构成了现代操作系统内存管理的基石。作为C++工程师,理解这些机制能帮你写出更高效、更稳定的程序。
我个人建议,遇到内存相关的诡异问题,先想想是不是虚拟内存层面出了问题。比如内存占用异常高,可能是页表膨胀;性能突然下降,可能是TLB miss率飙升;程序卡顿,可能是缺页中断太频繁。这些问题的根因,往往不在应用层代码里。
公众号:蓝海资料掘金营,微信deep3321