对话:虚拟内存机制 - 每个进程都以为自己独占整个内存


前置知识: [../01-program-execution/03-loading.md] 难度: ⭐⭐⭐ 预计时间: 50分钟 关键词: 虚拟内存, 页表, MMU, 缺页异常, TLB, 物理内存

引入场景

你有没有想过,你的电脑只有16GB内存,但同时运行着浏览器、IDE、数据库、好几个终端——它们都觉得自己有”无限”的内存可用。这怎么可能?而且,一个程序的Bug不会破坏另一个程序的数据——它们之间是怎么隔离的?

答案就是虚拟内存——操作系统最精妙的发明之一。

对话探索

问题1:如果没有虚拟内存,让每个程序直接使用物理地址,会有什么问题?

💭 思考方向:

  • 两个程序都想用地址 0x1000 怎么办?
  • 一个程序有Bug写了错误的地址,会影响别人吗?
  • 编译程序时,能知道运行时的物理地址吗?

📖 参考答案: 没有虚拟内存的世界会很糟糕:

1. 地址冲突

程序A编译时:代码放在 0x1000
程序B编译时:代码也放在 0x1000
→ 不能同时运行!或者需要在加载时重新分配地址(很复杂)

2. 安全隔离缺失

程序A的Bug:往地址 0x5000 写了垃圾数据
如果恰好是程序B的数据区 → 程序B莫名其妙崩溃
如果恰好是操作系统的内存 → 整个系统崩溃

3. 内存碎片

物理内存:[A占用][空闲][B占用][空闲][C占用][空闲]
想运行D,需要连续200MB → 虽然总空闲够,但找不到连续空间

虚拟内存一次性解决了所有这些问题。


问题2:虚拟内存是如何工作的?地址翻译的过程是什么?

💭 思考方向:

  • 每个进程有自己的”虚拟地址空间”
  • 需要一种”翻译”机制把虚拟地址转换为物理地址
  • 谁来做这个翻译?软件还是硬件?

📖 参考答案: 虚拟内存的核心是地址翻译:

程序使用的地址            操作系统维护             实际物理内存
(虚拟地址)                                      (物理地址)
                         ┌─────────┐
0x00400000 ─────────────→│ 页表     │──────→ 0x00034000
(进程A的代码)           │ Page    │        (物理页框)
                         │ Table   │
0x00400000 ─────────────→│         │──────→ 0x00089000
(进程B的代码)           └─────────┘        (不同的物理页框!)

关键概念:

  • 虚拟地址:程序”看到”的地址(每个进程从0开始,互不影响)
  • 物理地址:真实的内存硬件地址
  • 页表(Page Table):虚拟地址→物理地址的映射表,每个进程一份
  • MMU(内存管理单元):CPU中的硬件组件,自动完成地址翻译

翻译过程:

CPU发出虚拟地址
    │
    ▼
MMU查页表
    │
    ├─ 找到映射 → 转换为物理地址 → 访问物理内存
    │
    └─ 没有映射 → 缺页异常(Page Fault)
                    │
                    ├─ 合法访问 → 操作系统分配物理页,更新页表,重试
                    └─ 非法访问 → 段错误(Segmentation Fault)→ 程序崩溃

问题3:页表是什么结构?如果虚拟地址空间有48位(256TB),页表会不会特别大?

💭 思考方向:

  • 最简单的方案:一个大数组,虚拟页号→物理页框号
  • 256TB地址空间 ÷ 4KB页大小 = 640亿条记录?
  • 大多数虚拟地址空间是空的(程序通常只用几百MB)

📖 参考答案: 直接用一级表确实太大。现代系统使用多级页表:

48位虚拟地址的分解(x86-64的4级页表):

虚拟地址: [PML4索引|PDPT索引|PD索引|PT索引|页内偏移]
          9位      9位     9位   9位    12位

PML4 (1级) → PDPT (2级) → PD (3级) → PT (4级) → 物理页框
 512条目      512条目       512条目     512条目

为什么多级表更省空间?

一级表(如果有的话):需要 2^36 条记录 = 512GB!

四级表(实际):
  PML4:  1页 = 4KB(只有几个条目非空)
  PDPT:  可能只有2-3页 = 8-12KB
  PD:    可能只有十几页
  PT:    可能只有几十页
  
  总共:通常只有几十KB到几MB

核心思想:只为已使用的虚拟地址范围分配页表条目。进程不用的地址空间,对应的页表根本不存在。


问题4:每次内存访问都要查4级页表(16次内存访问),不会很慢吗?

💭 思考方向:

  • CPU做一次内存访问需要约100ns
  • 如果每次都查4级,等于慢了4倍
  • 有没有缓存机制?

📖 参考答案: TLB(Translation Lookaside Buffer)——页表缓存:

CPU发出虚拟地址
    │
    ▼
先查TLB(硬件缓存,极快,~1ns)
    │
    ├─ TLB命中(~99%的情况) → 直接获得物理地址
    │
    └─ TLB未命中(~1%) → 查完整页表(慢,~100ns × 4级)
                          更新TLB

TLB通常只有64-1024个条目,但因为时间局部性和空间局部性,命中率高达95-99%。

时间局部性:最近访问的地址很可能再次被访问(循环、频繁调用的函数)
空间局部性:访问某地址后,附近地址很可能被访问(数组遍历、结构体)

问题5:虚拟内存和程序员有什么直接关系?Go程序员需要关心吗?

💭 思考方向:

  • Go的GC和内存分配器在虚拟内存之上工作
  • mmap的原理
  • 为什么Go程序的RSS(常驻内存)比看起来的小?

📖 参考答案:

Go程序员需要了解的虚拟内存知识:

  1. Go的内存分配策略:

    Go运行时一次性向OS申请大块虚拟内存(比如几百MB)
    这不会立即占用物理内存!(只是虚拟地址映射)
    实际使用时才按需分配物理页
    → 所以top命令看到的VIRT很大,但RSS很小
    
  2. 理解内存指标:

    VIRT(虚拟内存):进程映射的虚拟地址总量 → 不必紧张
    RSS(常驻内存):实际占用的物理内存 → 这才是真正的消耗
    
  3. mmap的应用:

    // os.ReadFile内部可能用mmap来读大文件
    // 不是把整个文件复制到内存,而是建立映射
    data, _ := os.ReadFile("big_file.dat")

Go语言实践

package main
 
import (
    "fmt"
    "os"
    "runtime"
)
 
func main() {
    // 查看当前内存统计
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
 
    fmt.Printf("虚拟内存(Sys): %.2f MB\n", float64(m.Sys)/(1024*1024))
    fmt.Printf("堆已分配: %.2f MB\n", float64(m.HeapAlloc)/(1024*1024))
    fmt.Printf("堆从系统获取: %.2f MB\n", float64(m.HeapSys)/(1024*1024))
 
    // 分配一些内存
    data := make([]byte, 100*1024*1024) // 100MB
    _ = data
 
    runtime.ReadMemStats(&m)
    fmt.Printf("\n分配100MB后:\n")
    fmt.Printf("虚拟内存(Sys): %.2f MB\n", float64(m.Sys)/(1024*1024))
    fmt.Printf("堆已分配: %.2f MB\n", float64(m.HeapAlloc)/(1024*1024))
 
    // 查看进程的内存映射(Linux)
    pid := os.Getpid()
    fmt.Printf("\n查看进程 %d 的内存映射:\n", pid)
    fmt.Println("运行: cat /proc/<pid>/maps")
}
# 运行程序
$ go run main.go
 
# 查看进程的虚拟内存映射
$ cat /proc/$(pgrep -f your_program)/maps
# 你会看到:
# 地址范围          权限  偏移    设备  inode  路径
# 00400000-00800000 r-xp  ...    ...   ...   /path/to/binary  ← 代码段
# 00800000-00900000 rw-p  ...    ...   ...   /path/to/binary  ← 数据段
# 7f0000000000-...  rw-p  ...    ...   ...   [heap]           ← 堆
# 7ffe00000000-...  rw-p  ...    ...   ...   [stack]          ← 栈
 
# 查看进程内存使用概况
$ cat /proc/$(pgrep -f your_program)/status | grep -E "VmSize|VmRSS"
# VmSize: 虚拟内存大小
# VmRSS:  物理内存大小

小结

⭐ 核心要点:

  1. 虚拟内存的三大价值:地址隔离(安全)、地址统一(简化编程)、按需加载(节约物理内存)
  2. 多级页表 + TLB:用层次结构节省空间,用硬件缓存保证速度
  3. Go程序员关注RSS而非VIRT:Go运行时申请大块虚拟内存但按需使用物理内存

关联阅读