程序是如何访问内存的?——虚拟内存与内存管理

程序是如何访问内存的?------虚拟内存与内存管理

最近在看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 缺页处理

操作系统处理缺页异常的步骤:

  1. 确定要访问的虚拟页
  2. 在磁盘上找到这个页
  3. 把这个页加载到物理内存
  4. 更新页表
  5. 重新执行触发缺页的指令

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 内存映射的好处

  1. 简化文件操作:可以像访问内存一样访问文件
  2. 共享内存:多个进程可以映射同一个文件
  3. 延迟加载:只有访问时才加载文件内容

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 交换空间的作用

  1. 扩展内存:让系统可以运行更多的程序
  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()非常高效。

九、总结

这篇文章我们深入探讨了:

  1. 虚拟内存:给每个程序一个"假"的内存地址
  2. 虚拟地址空间布局:代码段、数据段、堆、栈
  3. 分页机制:把内存分成固定大小的页
  4. TLB:页表的高速缓存
  5. 缺页异常:访问不在内存中的页
  6. 内存映射:把文件映射到内存
  7. 交换空间:磁盘上的"虚拟内存"
  8. 虚拟内存的安全性:地址空间隔离、写时复制

理解虚拟内存,能帮你更好地理解JVM的内存管理、操作系统的内存分配,以及各种性能优化的手段。


参考资料

  • 《深入理解计算机系统》
  • 《Linux内核设计与实现》