Skip to content

虚拟内存:让程序以为你有无限内存

你的电脑只有8GB内存,但可以同时打开十几个大型程序,还能玩50GB的游戏。 这是怎么办到的?

答案是虚拟内存——操作系统给每个程序画的一张「大饼」。

什么是虚拟内存?

虚拟内存 = 程序看到的内存视图(虚拟地址空间)+ 实际物理内存 + 磁盘空间

┌─────────────────────────────────────────────────────────────┐
│                      虚拟内存全景                             │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  程序员视角(虚拟地址空间):                                   │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  0x00000000                                            │    │
│  │   代码段                                                │    │
│  │   数据段                                                │    │
│  │   BSS段                                                │    │
│  │   堆(向高地址增长)                                      │    │
│  │                                                        │    │
│  │   ↓                                                    │    │
│  │   ↑                                                    │    │
│  │   栈(向低地址增长)                                      │    │
│  │                                                        │    │
│  │  0xFFFFFFFF                                            │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│                        ↕ MMU翻译                            │
│                                                              │
│  硬件视角(物理地址空间):                                     │
│  ┌──────────────────────────────┐                           │
│  │  物理内存(部分页)             │ ← 热点数据               │
│  │  ┌────┬────┬────┬────┬────┐   │                           │
│  │  │ 页1 │ 页3 │ 页5 │ 页7 │   │                           │
│  │  └────┴────┴────┴────┴────┘   │                           │
│  └──────────────────────────────┘                           │
│                                                              │
│                        ↕ 换入换出                            │
│                                                              │
│  ┌──────────────────────────────┐                           │
│  │  交换空间/页文件(磁盘)        │ ← 冷数据                 │
│  │  ┌────┬────┬────┬────┬────┐   │                           │
│  │  │ 页2 │ 页4 │ 页6 │ 页8 │   │                           │
│  │  └────┴────┴────┴────┴────┘   │                           │
│  └──────────────────────────────┘                           │
│                                                              │
└─────────────────────────────────────────────────────────────┘

虚拟内存的核心思想

1. 程序的独立地址空间

java
// 进程A看到的地址空间
class ProcessA {
    public static void main(String[] args) {
        int[] arr = new int[1000];
        arr[0] = 100;  // 进程的虚拟地址0x7f12340000

        // 但进程A不知道的是:
        // 这个地址可能被MMU翻译成物理地址0x12345000
    }
}

// 进程B也看到同样的地址
class ProcessB {
    public static void main(String[] args) {
        int[] arr = new int[1000];
        arr[0] = 200;  // 进程的虚拟地址0x7f12340000

        // 但MMU会翻译成完全不同的物理地址
        // 两个进程的数组互不干扰!
    }
}

2. 按需加载(Demand Paging)

不是一次性加载整个程序,而是访问到哪一页,才加载哪一页。

传统加载:
┌──────────────────────────────────────────────────────┐
│ 启动程序                                              │
│  ↓                                                   │
│ 加载整个可执行文件(可能100MB)                        │
│  ↓                                                   │
│ 运行                                                │
│                                                      │
│ 问题:程序可能只有10MB在用,却加载了100MB              │
└──────────────────────────────────────────────────────┘

按需加载:
┌──────────────────────────────────────────────────────┐
│ 启动程序                                              │
│  ↓                                                   │
│ 只加载入口点附近的少量页(代码入口、main函数)           │
│  ↓                                                   │
│ 执行main()                                            │
│  ↓                                                   │
│ 访问新页(如调用printf)→ 触发缺页 → 加载那一页        │
│  ↓                                                   │
│ 访问malloc → 触发缺页 → 加载那一页                     │
│  ↓                                                   │
│ 按需加载,直到程序结束                                 │
└──────────────────────────────────────────────────────┘

3. 页面换入换出

当物理内存不足时,把不常用的页换出到磁盘。

java
// 虚拟内存的工作流程
public class VirtualMemorySimulation {
    public static void main(String[] args) {
        // 场景:4GB虚拟内存,8GB物理内存

        // 1. 进程访问虚拟地址VA = 0x1000
        //    MMU查找页表:页0 → 帧3
        //    物理地址PA = 3 × 4096 + 0x1000 % 4096 = 0x3000
        //    命中!

        // 2. 进程访问虚拟地址VA = 0x11000
        //    页号 = 0x11000 / 4096 = 17
        //    MMU查找页表:页17 → 不在内存!
        //    触发缺页异常

        // 3. OS处理缺页:
        //    - 找一个物理帧(可能需要换出其他页)
        //    - 从磁盘加载页17
        //    - 更新页表
        //    - 返回继续执行

        // 4. 进程再次访问VA = 0x11000
        //    MMU查找页表:页17 → 帧7
        //    命中!
    }
}

请求分页系统

虚拟内存最常见的实现方式——按需分页。

页表扩展

┌──────────────────────────────────────────────────────────┐
│                    扩展的页表项                            │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  ┌────┬────┬────┬────┬────┬────────┬──────────┬─────────┐│
│  │有效│读/写│用户│访问│脏位│  保留  │   帧号   │磁盘块号 ││
│  │ 位 │    │ /  │  位 │    │        │          │(换出位置)││
│  │ 1  │ 1  │ 1  │ 1  │ 1  │   6    │   20     │   ?   ││
│  └────┴────┴────┴────┴────┴────────┴──────────┴─────────┘│
│                                                          │
│  有效位 = 0 时:                                          │
│  - 帧号字段无意义                                         │
│  - 磁盘块号字段有意义(页在磁盘哪个位置)                   │
│                                                          │
└──────────────────────────────────────────────────────────┘

缺页处理流程

java
public class PageFaultHandler {
    public void handlePageFault(int pageNumber) {
        // 1. 检查页是否合法
        if (!isValidPage(pageNumber)) {
            throw new SegFaultException("Illegal address");
        }

        // 2. 分配一个物理帧
        int frameNumber;
        if (hasFreeFrame()) {
            frameNumber = allocateFreeFrame();
        } else {
            // 3. 没有空闲帧,需要置换
            frameNumber = selectVictimFrame();  // LRU/Clock算法
            int victimPage = getPageOfFrame(frameNumber);

            // 4. 如果被选中帧脏了,需要写回
            if (isDirty(victimPage)) {
                swapOut(victimPage, frameNumber);
            }

            // 5. 标记旧页无效
            invalidatePage(victimPage);
        }

        // 6. 从磁盘加载新页
        swapIn(pageNumber, frameNumber);

        // 7. 更新页表
        updatePageTable(pageNumber, frameNumber, present=true);

        // 8. 恢复执行
        resumeInstruction();
    }
}

虚拟内存的三大优势

优势说明例子
地址空间抽象程序看到连续统一的地址空间不同进程的相同虚拟地址映射到不同物理地址
内存保护防止非法访问、越界进程A无法访问进程B的内存
内存高效利用只加载需要的页,支持超量使用4GB内存跑8GB的程序

虚拟地址 vs 物理地址

┌──────────────────────────────────────────────────────────┐
│                    地址转换                               │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  虚拟地址(VA):                                          │
│  - 程序使用的地址                                          │
│  - 32位系统:0x00000000 ~ 0xFFFFFFFF                      │
│  - 64位系统:巨大的地址空间                                │
│                                                          │
│  物理地址(PA):                                          │
│  - 实际硬件的地址                                         │
│  - 取决于实际内存大小                                      │
│  - 4GB物理内存:0x00000000 ~ 0xFFFFFFFF                   │
│                                                          │
│  MMU(内存管理单元):                                     │
│  - 硬件自动完成翻译                                        │
│  - 集成在CPU中                                            │
│  - 内置TLB缓存                                            │
│                                                          │
└──────────────────────────────────────────────────────────┘

实际案例:Java虚拟机的虚拟内存

JVM运行在操作系统之上,又有自己的一层虚拟内存:

┌──────────────────────────────────────────────────────────┐
│                  JVM的虚拟内存层次                         │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  Java程序看到的地址(Java对象引用)                         │
│         ↓                                                 │
│  JVM堆(对象分配在这里)                                    │
│         ↓                                                 │
│  JVM进程的虚拟地址空间(malloc/mmap)                       │
│         ↓                                                 │
│  操作系统的虚拟地址空间                                     │
│         ↓                                                 │
│  操作系统的物理内存管理                                     │
│         ↓                                                 │
│  硬件MMU + TLB                                            │
│         ↓                                                 │
│  实际物理内存(DDR)                                       │
│                                                          │
└──────────────────────────────────────────────────────────┘
java
public class JVMMemoryLayout {
    public static void main(String[] args) {
        // JVM堆的虚拟地址空间(由-Xmx等参数控制)
        // 例如:-Xmx4g -Xms2g -XX:MaxMetaspaceSize=512m

        // JVM堆外的区域:
        // - 直接内存(-XX:MaxDirectMemorySize)
        // - 代码缓存(CodeCache)
        // - 线程栈(每个线程一份)
        // - 元空间(Metaspace)

        // JVM的内存映射文件
        // - hprof堆转储
        // - GC日志
        // - JIT编译代码缓存

        // NIO的零拷贝利用了虚拟内存
        // FileChannel.map() → MappedByteBuffer
        // 映射文件到虚拟地址空间,操作系统负责按需加载
    }
}

虚拟内存的限制

问题说明解决
换页开销缺页导致磁盘I/O减少缺页、工作集管理
抖动过度换页导致性能下降增加内存、优化工作集
地址空间碎片虚拟地址空间碎片化虚拟内存整理
64位问题巨大地址空间,页表怎么做?多级页表、倒排页表

面试追问方向

  • 虚拟内存和物理内存的区别是什么?MMU的作用是什么? 提示:虚拟内存是程序视角,物理内存是硬件视角。
  • 缺页异常的处理流程是什么?哪些情况会导致缺页? 提示:页不在内存、页权限错误、地址不合法。
  • 虚拟内存如何实现进程隔离? 提示:每个进程有独立的页表。
  • 为什么32位系统最大只能使用4GB内存,即使物理内存更大? 提示:虚拟地址空间只有4GB,或者PAE的额外开销。

基于 VitePress 构建