程序是如何访问内存的?------虚拟内存与内存管理
最近在看JVM的内存模型,发现一个很有意思的问题:我们写的程序访问的内存地址,和实际的物理内存地址是不一样的。这是怎么回事?今天我们就来聊聊虚拟内存这个"障眼法"。
一、为什么需要虚拟内存
1.1 早期的内存管理
在早期的计算机系统中,程序直接访问物理内存:
程序A → 物理地址 0x1000
程序B → 物理地址 0x2000
这种方式有两个严重的问题:
问题1:内存不够用
如果物理内存只有4GB,而程序需要8GB,怎么办?
问题2:程序之间互相干扰
程序A可能不小心修改了程序B的数据
程序B可能读取了程序A的私有数据
1.2 虚拟内存的解决方案
虚拟内存的思路是:给每个程序一个"假"的内存地址,然后由操作系统负责把"假"地址映射到"真"地址。
程序A的虚拟地址空间 程序B的虚拟地址空间
0x1000 → 数据A 0x1000 → 数据B
0x2000 → 代码A 0x2000 → 代码B
↓ ↓
物理内存
0x1000 → 数据A
0x2000 → 数据B
0x3000 → 代码A
0x4000 → 代码B
每个程序都以为自己独占了整个内存空间,实际上它们的数据可能分散在物理内存的不同位置。
二、虚拟地址空间的布局
2.1 进程的虚拟地址空间
每个进程都有自己的虚拟地址空间,在64位系统上,通常是这样的:
高地址
+------------------+
| 内核空间 | ← 操作系统内核的代码和数据
+------------------+
| 栈 | ← 函数调用、局部变量(向下增长)
| ↓ |
| |
| ↑ |
| 堆 | ← 动态分配的内存(向上增长)
+------------------+
| BSS段 | ← 未初始化的全局变量
+------------------+
| 数据段 | ← 已初始化的全局变量
+------------------+
| 代码段 | ← 程序的指令
+------------------+
低地址
2.2 各段的作用
代码段(Text Segment):
存放程序的机器指令,通常是只读的。
c
// 这段代码会被编译到代码段
int add(int a, int b) {
return a + b;
}
数据段(Data Segment):
存放已初始化的全局变量和静态变量。
c
// 这些变量在数据段
int global_var = 100;
static int static_var = 200;
BSS段:
存放未初始化的全局变量和静态变量,会被自动初始化为0。
c
// 这些变量在BSS段
int uninitialized_var; // 自动初始化为0
static int static_uninit;
堆(Heap):
动态分配的内存,如malloc()和new分配的内存。
c
// 堆上的内存
int *arr = malloc(100 * sizeof(int)); // 在堆上分配
栈(Stack):
函数调用时的局部变量、参数、返回地址等。
c
void func() {
int local_var = 10; // 在栈上
int arr[100]; // 在栈上
}
三、分页机制
3.1 什么是分页
虚拟内存把内存分成固定大小的"页"(Page),通常每页4KB:
虚拟地址空间:
页0: [0x0000 - 0x0FFF]
页1: [0x1000 - 0x1FFF]
页2: [0x2000 - 0x2FFF]
...
物理内存:
页框0: [0x000000 - 0x000FFF]
页框1: [0x001000 - 0x001FFF]
页框2: [0x002000 - 0x002FFF]
...
3.2 页表
操作系统用"页表"(Page Table)来记录虚拟页和物理页框的映射关系:
虚拟页号 → 物理页框号
0 → 5
1 → 2
2 → 8
...
3.3 地址转换
当程序访问虚拟地址时,CPU会进行地址转换:
虚拟地址: 0x1234
1. 提取页号: 0x1234 / 0x1000 = 1
2. 提取页内偏移: 0x1234 % 0x1000 = 0x234
3. 查页表: 页1 → 物理页框2
4. 计算物理地址: 2 * 0x1000 + 0x234 = 0x2234
3.4 页表的层次结构
如果只有一个页表,在64位系统上会非常大:
虚拟地址空间大小: 2^64 字节
每页大小: 2^12 字节 (4KB)
页表项数量: 2^64 / 2^12 = 2^52 个
每个页表项8字节,页表大小: 2^52 * 8 = 2^55 字节 ≈ 32PB
这显然不现实。所以现代系统使用多级页表:
虚拟地址: [一级页号][二级页号][三级页号][四级页号][页内偏移]
以四级页表为例:
虚拟地址
↓
一级页表 → 二级页表 → 三级页表 → 四级页表 → 物理页框
这样,只有被使用的页才需要分配页表项,大大节省了内存。
四、TLB:页表的缓存
4.1 问题的来源
每次访问内存,都需要查页表。如果页表有四级,就需要访问四次内存才能完成一次地址转换。这太慢了!
4.2 TLB的作用
TLB(Translation Lookaside Buffer)是CPU内部的高速缓存,用来缓存最近使用的页表项:
虚拟地址 → TLB → 物理地址(如果TLB命中)
→ 页表 → 物理地址(如果TLB未命中)
TLB的速度非常快(几个时钟周期),比访问内存快几十倍。
4.3 TLB的工作流程
1. CPU发出虚拟地址
2. 查TLB
3. 如果命中(TLB Hit):
- 直接得到物理地址
- 访问物理内存
4. 如果未命中(TLB Miss):
- 查页表
- 把结果存入TLB
- 访问物理内存
4.4 局部性原理
TLB之所以有效,是因为程序的"局部性原理":
- 时间局部性:最近访问的地址,很可能马上又要访问
- 空间局部性:访问了一个地址,它附近的地址很可能也要访问
java
// 空间局部性的例子
int[] arr = new int[1000];
for (int i = 0; i < arr.length; i++) {
arr[i] = i; // 访问arr[0]时,arr[1]到arr[15]的页表项也被加载到TLB
}
五、缺页异常
5.1 什么是缺页
当程序访问一个虚拟地址,但对应的物理页不在内存中时,就会发生"缺页异常"(Page Fault)。
虚拟地址 → 查页表 → 物理页框号 = 0(不在内存中)
→ 触发缺页异常
5.2 缺页处理
操作系统处理缺页异常的步骤:
- 确定要访问的虚拟页
- 在磁盘上找到这个页
- 把这个页加载到物理内存
- 更新页表
- 重新执行触发缺页的指令
5.3 页面置换算法
当物理内存满了,需要把一个页"换出"到磁盘,为新页腾出空间。常见的页面置换算法:
FIFO(先进先出):
淘汰最早进入内存的页。
LRU(最近最少使用):
淘汰最长时间没有被访问的页。
java
// LRU的简单实现
public class LRUCache {
private LinkedHashMap<Integer, Integer> cache;
private int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
this.cache = new LinkedHashMap<>(capacity, 0.75f, true);
}
public int get(int key) {
return cache.getOrDefault(key, -1);
}
public void put(int key, int value) {
if (cache.size() >= capacity) {
// 淘汰最久没有使用的
cache.remove(cache.keySet().iterator().next());
}
cache.put(key, value);
}
}
Clock(时钟算法):
FIFO的改进版,给每个页一个"访问位",淘汰时优先淘汰访问位为0的页。
六、内存映射
6.1 什么是内存映射
内存映射(Memory Mapping)是把文件的内容映射到进程的虚拟地址空间:
文件: data.txt
"Hello, World!"
进程的虚拟地址空间:
0x7f0000000000: "Hello, World!" ← 映射到data.txt
6.2 内存映射的好处
- 简化文件操作:可以像访问内存一样访问文件
- 共享内存:多个进程可以映射同一个文件
- 延迟加载:只有访问时才加载文件内容
6.3 Java中的内存映射
java
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
public class MmapExample {
public static void main(String[] args) throws Exception {
// 打开文件
RandomAccessFile file = new RandomAccessFile("data.txt", "rw");
FileChannel channel = file.getChannel();
// 创建内存映射
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE, 0, 1024);
// 像访问内存一样访问文件
buffer.put(0, (byte) 'H');
buffer.put(1, (byte) 'e');
// 关闭文件
channel.close();
file.close();
}
}
七、交换空间
7.1 什么是交换空间
交换空间(Swap Space)是磁盘上的一块区域,用来存放从内存中换出的页:
物理内存满了
↓
选择一个页换出到Swap
↓
把需要的页换入到内存
↓
更新页表
7.2 交换空间的作用
- 扩展内存:让系统可以运行更多的程序
- 处理内存峰值:应对突发的内存需求
7.3 交换空间的代价
交换空间在磁盘上,访问速度比内存慢几万倍。如果系统频繁使用交换空间,会导致严重的性能问题(称为"颠簸",Thrashing)。
7.4 查看交换空间
bash
# 查看交换空间
$ swapon --show
NAME TYPE SIZE USED PRIO
/dev/sda2 partition 8G 1.2G -2
# 查看内存使用情况
$ free -h
total used free shared buff/cache available
Mem: 15Gi 8.2Gi 2.1Gi 512Mi 5.1Gi 6.5Gi
Swap: 8.0Gi 1.2Gi 6.8Gi
八、虚拟内存的安全性
8.1 地址空间隔离
虚拟内存的一个重要特性是"地址空间隔离":
进程A的虚拟地址空间 进程B的虚拟地址空间
0x1000 → 数据A 0x1000 → 数据B
↓ ↓
物理内存(不同的位置)
进程A无法访问进程B的虚拟地址空间,因为它们的页表是独立的。
8.2 内核空间保护
虚拟地址空间的高地址部分是内核空间,用户程序无法访问:
用户程序访问内核地址 → 触发异常(Segmentation Fault)
这防止了用户程序破坏操作系统内核。
8.3 写时复制
当fork()创建子进程时,子进程和父进程共享物理内存页,但标记为"只读":
父进程和子进程共享同一个物理页
↓
其中一个进程尝试写入
↓
触发缺页异常
↓
操作系统复制这个页
↓
两个进程各自有独立的页
这就是"写时复制"(Copy On Write),它让fork()非常高效。
九、总结
这篇文章我们深入探讨了:
- 虚拟内存:给每个程序一个"假"的内存地址
- 虚拟地址空间布局:代码段、数据段、堆、栈
- 分页机制:把内存分成固定大小的页
- TLB:页表的高速缓存
- 缺页异常:访问不在内存中的页
- 内存映射:把文件映射到内存
- 交换空间:磁盘上的"虚拟内存"
- 虚拟内存的安全性:地址空间隔离、写时复制
理解虚拟内存,能帮你更好地理解JVM的内存管理、操作系统的内存分配,以及各种性能优化的手段。
参考资料:
- 《深入理解计算机系统》
- 《Linux内核设计与实现》