虚拟内存:让程序以为你有无限内存
你的电脑只有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的额外开销。
