侧边栏壁纸
  • 累计撰写 138 篇文章
  • 累计创建 19 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

一个进程是如何使用内存的?从分页、虚拟内存到缺页异常

YaFuX
2026-07-31 / 0 评论 / 0 点赞 / 21 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

Java 程序员每天都在和内存打交道。

我们会创建对象:

User user = new User();

会调整 JVM 堆大小:

java -Xms2g -Xmx2g Application

也会遇到各种内存问题:

OutOfMemoryError
频繁 Full GC
进程被 OOM Killer 终止
服务器开始大量使用 Swap

但很多人并没有真正想过:

一个程序启动后,是不是会被完整地装入物理内存?

Java 对象中的某个字段,最终存放在哪一个物理地址上?

为什么两个进程都可以使用相同的虚拟地址,却不会访问到同一块数据?

物理内存不足时,操作系统会把哪些内容移出去?

页、页框、页表、MMU 和 TLB 分别是什么?

缺页异常为什么既可能是正常现象,也可能导致程序崩溃?

LRU 真的是 Linux 内核使用的页面淘汰算法吗?

这篇文章,我们从早期操作系统遇到的问题出发,一步步理解现代内存管理系统到底是如何工作的。

本文主要以:

Linux
x86-64
Java 应用程序

为背景。

不同操作系统和 CPU 架构在实现细节上会有所不同,但核心思想基本一致。


一、为什么不能直接把程序装进物理内存?

1. 早期系统如何使用内存?

在早期计算机系统中,内存容量很小,系统同一时间通常只运行一个主要用户程序。

可以简单理解为:

物理内存
┌──────────────────────┐
│ 操作系统              │
├──────────────────────┤
│ 当前正在运行的程序      │
├──────────────────────┤
│ 空闲空间							│
└──────────────────────┘

程序使用的地址,往往可以直接对应物理内存地址。

假设程序访问地址:

0x1000

它访问的可能就是物理内存中的:

0x1000

这种模型虽然简单,但只能适应非常有限的运行环境。

随着内存容量增大,人们希望同时运行多个程序:

文本编辑器
浏览器
数据库
Java 服务
后台任务

这时,问题就出现了。

2. 多进程带来的第一个问题:内存装不下

假设一台机器拥有 8 GB 物理内存。

现在需要运行三个程序:

程序 A:需要 4 GB
程序 B:需要 3 GB
程序 C:需要 5 GB

三个程序合计需要:

4 GB + 3 GB + 5 GB = 12 GB

显然无法同时完整地装入 8 GB 物理内存。

即使总需求暂时没有超过物理内存,也没有必要把程序的全部内容一次性装入。

例如,一个大型程序可能包含:

当前正在执行的代码
暂时不会执行的功能
很少使用的异常处理逻辑
尚未访问的数据
动态链接库
调试信息

程序启动时,真正立即需要的内容往往只是其中一小部分。

因此,现代操作系统需要解决的第一个问题是:

如何让程序不必完整装入物理内存,也能够正常运行?

3. 多进程带来的第二个问题:进程互相干扰

如果程序直接使用物理地址,那么进程 A 可能访问:

物理地址 0x1000

进程 B 也可能访问:

物理地址 0x1000

如果不进行限制,一个程序就可能:

读取其他进程的数据
修改其他进程的数据
覆盖操作系统内核
破坏系统关键结构

可以想象成:

物理内存
┌──────────────────────┐
│ 操作系统内核           │ ← 可能被错误修改
├──────────────────────┤
│ 进程 A               │ ← 可能被进程 B 修改
├──────────────────────┤
│ 进程 B               │ ← 可能被进程 A 修改
└──────────────────────┘

一个普通程序中的野指针,就可能导致整个系统崩溃。

因此,现代操作系统需要解决的第二个问题是:

如何保证每个进程只能访问属于自己的内存?

4. 现代内存管理的答案

现代操作系统主要通过三套机制解决这些问题:

虚拟地址
+
分页管理
+
操作系统与硬件协同寻址

它们分别承担不同职责:

分页和按需加载
    ↓
解决程序不必一次性完整装入的问题

虚拟地址空间和访问权限
    ↓
解决进程之间互相干扰的问题

页表、MMU 和 TLB
    ↓
完成虚拟地址到物理地址的转换

可以先记住一句话:

程序使用虚拟地址,操作系统维护映射关系,CPU 硬件完成地址转换。


二、分页如何解决“内存装不下”?

1. 为什么要把内存切成固定大小?

如果让操作系统直接以整个程序为单位分配内存,会产生很多问题。

例如,三个程序分别需要:

程序 A:13 MB
程序 B:27 MB
程序 C:41 MB

它们不断启动和退出后,物理内存中可能留下很多大小不一的空洞:

┌──────────┐
│ 已使用   │
├──────────┤
│ 空闲 2MB │
├──────────┤
│ 已使用   │
├──────────┤
│ 空闲 5MB │
├──────────┤
│ 已使用   │
├──────────┤
│ 空闲 3MB │
└──────────┘

虽然总空闲空间可能不少,但很难找到一块足够大的连续空间。

为了简化管理,操作系统把虚拟地址空间和物理内存都切分成固定大小的单位。

程序虚拟地址空间中的固定块称为:

页,Page

物理内存中的固定块称为:

页框,Page Frame

它们大小相同。

在常见的 x86 系统中,基础页大小通常为:

4 KiB

同时还可能支持:

2 MiB 大页
1 GiB 大页

4 KiB 是常见值,但不是所有 CPU 架构和操作系统的唯一页大小。

2. 页与页框是什么关系?

假设一个程序的虚拟地址空间被分成以下页面:

虚拟页 0
虚拟页 1
虚拟页 2
虚拟页 3
虚拟页 4

物理内存则被划分为:

物理页框 0
物理页框 1
物理页框 2
物理页框 3
物理页框 4
物理页框 5

虚拟页不需要按照原来的顺序装入物理内存。

例如:

虚拟页 0 → 物理页框 4
虚拟页 1 → 物理页框 1
虚拟页 2 → 尚未装入
虚拟页 3 → 物理页框 5
虚拟页 4 → 尚未装入

画出来就是:

进程虚拟空间                  物理内存
┌────────────┐              ┌────────────┐
│ 虚拟页 0   │─────────────→│ 页框 4     │
├────────────┤              ├────────────┤
│ 虚拟页 1   │──────┐       │ 页框 1     │
├────────────┤      └──────→├────────────┤
│ 虚拟页 2   │ 未装入        │ 页框 2     │
├────────────┤              ├────────────┤
│ 虚拟页 3   │─────────────→│ 页框 5     │
├────────────┤              ├────────────┤
│ 虚拟页 4   │ 未装入        │ 页框 4     │
└────────────┘              └────────────┘

分页带来了一个重要能力:

虚拟地址连续,并不要求对应的物理内存也连续。

程序看到的可能是一段连续空间:

0x1000
0x2000
0x3000
0x4000

它们在物理内存中却可能分散在完全不同的位置。

3. 什么是按需加载?

程序启动时,操作系统通常不会立刻把程序涉及的所有页面都读入物理内存。

它会先建立必要的虚拟内存区域和映射信息。

当程序真正访问某个页面时,再准备对应的物理页框。

这就是:

按需分页
Demand Paging

可以简单理解为:

程序启动
    ↓
建立虚拟地址空间
    ↓
记录代码、数据和文件的映射关系
    ↓
先不加载全部页面
    ↓
CPU 第一次访问某页
    ↓
发现页面尚不在物理内存
    ↓
触发缺页异常
    ↓
操作系统装入或创建页面
    ↓
程序继续执行

因此:

程序拥有一段虚拟地址,不代表这个地址背后已经存在物理页框。

这也是为什么申请一块很大的虚拟内存后,进程的实际物理内存占用不一定立刻增加同样多。

4. 什么是页表?

操作系统必须记录:

哪个虚拟页
映射到
哪个物理页框

保存这种映射关系的数据结构称为:

页表,Page Table

页表项中通常不仅包含物理页框编号,还会包含各种状态和权限信息,例如:

页面是否存在于物理内存
是否允许读取
是否允许写入
是否允许执行
是用户页面还是内核页面
页面是否被访问过
页面是否被修改过

可以简单表示为:

虚拟页号     物理页框     存在位     可写     可执行
--------------------------------------------------
0            4            1          0        1
1            1            1          1        0
2            -            0          -        -
3            5            1          1        0

现代地址空间非常大,不可能总是用一张巨大的平面数组保存所有页表项。

因此,主流系统通常使用:

多级页表

虚拟地址中的不同部分,用来逐级查找页表。

Linux 内核文档也将页表描述为虚拟地址到物理地址转换的核心结构,并说明页面不存在时,内核会在缺页处理过程中建立或更新相应映射。

5. 物理内存满了怎么办?

当物理内存不足时,操作系统会尝试回收页面。

但并不是所有页面都使用同一种方式处理。

文件映射的干净页面

如果一个页面来自:

可执行文件
动态链接库
普通磁盘文件

并且页面内容没有被修改,那么操作系统通常可以直接丢弃这个物理页框。

因为需要时,可以重新从原文件读取。

文件中的原始内容仍然存在
    ↓
物理页面可以直接回收
    ↓
下次访问时重新读取

被修改的文件页面

如果文件映射页面已经被修改,可能需要先将内容写回文件,再回收页框。

匿名页面

Java 堆、线程栈等内容通常属于匿名内存。

它们没有一个可以直接重新读取的原始文件。

在启用 Swap 的情况下,这些页面可能被写入交换空间:

匿名页面
    ↓
写入 Swap
    ↓
释放物理页框
    ↓
以后访问时再读回

因此,Swap 并不是简单地把硬盘永久变成速度较慢的内存。

更准确地说:

Swap 为部分暂时不活跃的匿名页面提供了后备存储空间。

如果系统无法回收足够内存,也无法通过其他方式满足分配请求,Linux 可能进入 OOM 处理流程,选择终止部分进程以释放内存。


三、为什么内存管理会讲到 LRU?

1. 操作系统应该淘汰哪个页面?

物理内存已满时,如果要装入一个新页面,就需要先选择一个旧页面进行回收。

现在有三个页面:

页面 A:刚刚被访问
页面 B:一段时间没有访问
页面 C:很久没有访问

直觉上,我们更愿意淘汰:

页面 C

因为它最近最少被使用,将来短时间内再次使用的可能性相对较低。

这种思想就是:

LRU
Least Recently Used
最近最少使用

假设页面的访问顺序为:

A → B → C → A → D

此时从最久未使用到最近使用,可以排列为:

B → C → A → D

如果缓存容量已满,下一次加入新页面时,优先淘汰:

B

2. 为什么普通数组不能实现 O(1)?

LRU 缓存通常要求:

get:O(1)
put:O(1)
淘汰:O(1)

如果使用数组保存数据,查找一个指定 key 通常需要遍历:

O(n)

如果使用普通链表,虽然删除头节点很快,但查找指定节点仍然需要:

O(n)

因此,我们需要两个数据结构配合:

HashMap
+
双向链表

HashMap 负责:

O(1) 查找节点

双向链表负责:

O(1) 删除节点
O(1) 移动节点
O(1) 淘汰最旧节点

链表中的顺序可以约定为:

头部                           尾部
最久未使用  ←──────────────→  最近使用

当某个节点被访问时:

从原位置删除
    ↓
移动到链表尾部

当容量不足时:

删除链表头部节点

3. Java 实现 LRU 缓存

下面是不依赖 LinkedHashMap 的实现:

import java.util.HashMap;
import java.util.Map;

public class LRUCache {

    private static final class Node {
        private final int key;
        private int value;
        private Node prev;
        private Node next;

        private Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    private final int capacity;
    private final Map<Integer, Node> cache;

    // 使用两个哨兵节点,减少头尾节点的特殊判断。
    private final Node head;
    private final Node tail;

    public LRUCache(int capacity) {
        if (capacity <= 0) {
            throw new IllegalArgumentException("capacity must be greater than 0");
        }

        this.capacity = capacity;
        this.cache = new HashMap<>(capacity);
        this.head = new Node(0, 0);
        this.tail = new Node(0, 0);

        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        Node node = cache.get(key);

        if (node == null) {
            return -1;
        }

        moveToTail(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node existing = cache.get(key);

        if (existing != null) {
            existing.value = value;
            moveToTail(existing);
            return;
        }

        Node newNode = new Node(key, value);
        cache.put(key, newNode);
        addToTail(newNode);

        if (cache.size() > capacity) {
            Node oldest = removeFirst();
            cache.remove(oldest.key);
        }
    }

    /**
     * 将节点加入链表尾部,表示它刚刚被访问。
     */
    private void addToTail(Node node) {
        Node previousTail = tail.prev;

        previousTail.next = node;
        node.prev = previousTail;

        node.next = tail;
        tail.prev = node;
    }

    /**
     * 将节点从双向链表中移除。
     */
    private void remove(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;

        node.prev = null;
        node.next = null;
    }

    /**
     * 将节点移动到尾部。
     */
    private void moveToTail(Node node) {
        remove(node);
        addToTail(node);
    }

    /**
     * 删除并返回最久未使用的节点。
     */
    private Node removeFirst() {
        Node oldest = head.next;

        if (oldest == tail) {
            throw new IllegalStateException("cache is empty");
        }

        remove(oldest);
        return oldest;
    }
}

操作过程如下:

put(1, 10)
链表:1

put(2, 20)
链表:1 → 2

get(1)
链表:2 → 1

put(3, 30)
容量不足,淘汰 2
链表:1 → 3

每次 getput 最多只进行:

一次 HashMap 查找
+
有限次指针修改

因此平均时间复杂度为:

O(1)

JDK 的 LinkedHashMap 本身支持按访问顺序维护元素,官方文档也明确指出这种模式适合构建 LRU 缓存。

4. Linux 使用的就是精确 LRU 吗?

这里需要特别注意。

为了讲解缓存淘汰,通常会使用“最近最少使用”描述操作系统页面回收。

但现代操作系统一般不会维护一个全局、完全精确的访问时间顺序。

原因是:

每次内存访问都更新链表
    ↓
需要频繁修改内核数据
    ↓
多核之间产生同步竞争
    ↓
管理成本可能高于收益

因此,Linux 的页面回收机制更适合描述为:

使用基于冷热程度、访问状态和多种回收队列的近似 LRU 策略。

传统 Linux 页面回收使用过 active、inactive 等列表;较新的内核还提供 Multi-Gen LRU 等实现,用代际信息估计页面冷热程度,而不是照搬面试题中的“HashMap 加双向链表”。

所以:

手写 LRU
    ↓
是理解缓存淘汰思想和数据结构的经典题目

操作系统页面回收
    ↓
会借鉴 LRU 思想,但实现远比面试题复杂

四、虚拟内存如何解决“进程互相干扰”?

1. 每个进程都有自己的虚拟地址空间

现代程序通常不会直接操作物理地址。

程序执行下面的代码:

int value = user.getAge();

CPU 最终执行机器指令时,使用的是这个进程上下文中的虚拟地址。

假设进程 A 访问:

虚拟地址 0x1000

进程 B 也访问:

虚拟地址 0x1000

它们最终可以映射到不同物理位置:

进程 A:虚拟地址 0x1000 → 物理页框 8
进程 B:虚拟地址 0x1000 → 物理页框 21

画出来就是:

进程 A 虚拟空间
0x1000 ─────────────→ 物理页框 8

进程 B 虚拟空间
0x1000 ─────────────→ 物理页框 21

虽然虚拟地址相同,但由于两个进程使用不同的地址映射关系,最终访问的数据完全不同。

这就是进程隔离的基础。

2. 进程看到的虚拟空间是什么样的?

一个典型进程的虚拟地址空间,可以粗略理解为:

高地址
┌─────────────────────────┐
│ 内核相关映射区域         │
├─────────────────────────┤
│ 用户栈                   │
│ 向较低地址方向增长       │
├─────────────────────────┤
│ 内存映射区域             │
│ 动态链接库、mmap 文件等  │
├─────────────────────────┤
│ 堆                       │
│ 通常向较高地址方向增长   │
├─────────────────────────┤
│ BSS 段                   │
│ 未初始化或零初始化数据   │
├─────────────────────────┤
│ 数据段                   │
│ 已初始化全局数据         │
├─────────────────────────┤
│ 只读数据                 │
├─────────────────────────┤
│ 代码段                   │
└─────────────────────────┘
低地址

实际布局会受到以下因素影响:

操作系统
CPU 架构
可执行文件格式
动态链接器
地址空间布局随机化 ASLR
JVM 实现

因此,这张图表达的是概念结构,而不是所有进程都固定使用相同地址。

3. 为什么代码段通常不能随便修改?

页表不仅记录地址映射,还记录访问权限。

例如:

代码页面:可读、可执行、不可写
普通数据:可读、可写、不可执行
只读数据:可读、不可写、不可执行
内核页面:用户态不可访问

可以表示为:

代码段
R-X

只读数据
R--

堆和栈
RW-

内核空间
用户态无权限

这种权限控制可以阻止很多错误操作。

例如,程序尝试写入只读页面:

写入代码段
    ↓
CPU 检查页表权限
    ↓
发现不允许写入
    ↓
触发页面故障
    ↓
内核判断访问非法
    ↓
向进程发送 SIGSEGV

因此,虚拟内存不仅负责“地址转换”,也承担:

进程隔离
访问权限控制
内核保护
内存共享
按需加载
写时复制

等重要职责。

4. 虚拟内存等于 Swap 吗?

不等于。

这是最常见的误解之一。

虚拟内存是一整套地址空间抽象与映射机制:

虚拟地址空间
页表
内存权限
按需分页
文件映射
共享内存
写时复制
页面回收
Swap

Swap 只是其中一种后备存储机制。

即使系统完全没有配置 Swap,进程仍然会使用虚拟地址、页表和分页机制。

因此:

虚拟内存 ≠ Swap
Swap ⊂ 虚拟内存管理机制

五、虚拟地址是如何转换成物理地址的?

1. 逻辑地址、线性地址和虚拟地址

在传统 x86 分段模型中,一次地址访问可以被描述为:

段基地址
+
段内偏移量
=
线性地址

例如:

数据段基地址:0x1000
段内偏移量:  0x0020

线性地址:    0x1020

然后再通过分页机制,将线性地址转换为物理地址。

完整过程是:

逻辑地址
    ↓ 分段转换
线性地址
    ↓ 分页转换
物理地址

不过,在现代 x86-64 操作系统中,大部分用户空间通常采用接近平坦的分段模型。

多数情况下可以简化理解为:

程序使用的虚拟地址
≈
CPU 分页机制处理的线性地址

分段仍然存在一些特殊用途,例如 FS、GS 寄存器可能用于线程局部数据等场景,但它已经不是普通应用内存隔离的主要机制。

2. 什么是 MMU?

MMU 的全称是:

Memory Management Unit
内存管理单元

它是处理器中的地址转换硬件。

MMU 负责根据当前进程的页表,将虚拟地址转换为物理地址。

可以简单表示为:

CPU 执行内存访问指令
          ↓
得到虚拟地址
          ↓
MMU 查找地址映射
          ↓
得到物理地址
          ↓
访问缓存或物理内存

这里需要注意:

不是操作系统在每次内存访问时,都运行一段软件代码进行地址计算。

如果每次读取变量都要陷入内核,程序将无法高效运行。

正常情况下,地址转换由 CPU 硬件自动完成。

操作系统负责:

创建页表
修改页表
设置页面权限
处理缺页异常
在进程切换时切换地址空间上下文

MMU 负责:

在执行内存指令时查询映射
检查权限
生成物理地址
在映射无效时触发异常

这就是所谓的:

软硬件结合

3. 一个虚拟地址如何被拆分?

假设基础页大小为:

4 KiB = 4096 字节 = 2¹² 字节

那么一个虚拟地址的低 12 位可以表示页内偏移量。

地址可以抽象为:

┌──────────────────────┬──────────────┐
│ 虚拟页号             │ 页内偏移量   │
└──────────────────────┴──────────────┘
                         低 12 位

假设:

虚拟地址 = 0x12345ABC

那么可以拆分为:

虚拟页号:0x12345
页内偏移:0xABC

页表负责找到:

虚拟页号 0x12345
    ↓
物理页框号 0x789

最终物理地址为:

物理页框起始地址
+
页内偏移量

也就是:

0x789000 + 0xABC
=
0x789ABC

需要转换的是页号部分。

页内偏移量在转换前后保持不变。

4. 什么是 TLB?

多级页表本身存放在内存中。

如果 CPU 每次读取数据前,都要先访问多次内存查询页表,开销仍然很大。

因此,CPU 内部通常还有一个专门缓存地址映射的结构:

TLB
Translation Lookaside Buffer
地址转换后备缓冲器

TLB 可以理解为:

虚拟页号到物理页框号的高速缓存。

地址转换过程变成:

CPU 产生虚拟地址
        ↓
查询 TLB
   ┌────┴────┐
   │         │
命中        未命中
   │         │
   │       查询多级页表
   │         │
   │       填充 TLB
   │         │
   └────┬────┘
        ↓
得到物理地址

TLB 命中时,不需要完整遍历多级页表。

因此,TLB 命中率会直接影响内存访问性能。

5. 64 位系统真的拥有 2⁶⁴ 字节空间吗?

地址通常按字节编号。

因此,理论上:

32 位地址空间
=
2³² 字节
=
4 GiB

而完整的 64 位地址理论上可以表示:

2⁶⁴ 字节
=
16 EiB

但“64 位 CPU”并不意味着当前硬件和操作系统一定实际实现了全部 64 位虚拟地址。

以 x86-64 为例,不同分页模式通常只转换其中一部分地址位:

四级分页:常见为 48 位线性地址
五级分页:可扩展到 57 位线性地址

操作系统还会进一步划分用户空间和内核空间。

因此:

2⁶⁴ 字节是理论编号能力,不等于每个 64 位进程都能实际使用完整的 16 EiB 空间。

Intel 的相关架构资料说明,四级分页与五级分页分别转换较低的 48 位或 57 位线性地址。


六、什么是缺页异常?

1. 为什么访问一个合法地址也会发生异常?

假设进程需要访问虚拟页 5。

页表当前记录为:

虚拟页 5
存在位:0

这表示当前没有可直接使用的物理页框映射。

CPU 无法继续完成这条内存访问指令,于是产生:

Page Fault
缺页异常或页面故障

需要特别注意:

缺页异常不一定表示程序出现了错误。

很多正常机制都依赖缺页异常,例如:

按需加载程序代码
第一次访问新分配的匿名内存
读取 mmap 映射文件
写时复制
从 Swap 换入页面
延迟分配物理页

2. 缺页异常的处理过程

一次正常的缺页处理,可以抽象为:

进程访问虚拟地址
        ↓
MMU 查询页表
        ↓
发现页面不存在
        ↓
CPU 触发缺页异常
        ↓
进入内核缺页处理程序
        ↓
检查地址是否合法
        ↓
找到对应虚拟内存区域
        ↓
申请一个物理页框
        ↓
从文件或 Swap 读取数据
或者创建一个零页
        ↓
更新页表
        ↓
刷新或更新相关 TLB 状态
        ↓
重新执行发生缺页的指令

从应用程序的角度看,它只是执行了一次普通的变量读取。

真正发生的过程却可能是:

暂停当前指令
进入内核
等待磁盘
分配页面
修改页表
恢复执行

这也是为什么严重缺页会导致明显卡顿。

3. 缺页异常有哪些类型?

可以粗略分成三类。

轻微缺页

所需数据不需要从慢速磁盘重新读取。

例如:

第一次为匿名内存分配物理页
共享页面已经存在于内存
写时复制时创建新页面

这种情况通常称为:

Minor Page Fault
轻微缺页

重大缺页

需要从磁盘或其他较慢的后备存储中读取数据。

例如:

从 Swap 换入页面
从文件重新读取页面

这种情况通常称为:

Major Page Fault
重大缺页

重大缺页的成本远高于普通内存访问。

非法访问

如果进程访问的地址:

根本没有映射
没有相应权限
超出合法区域
写入只读页面
执行不可执行页面

内核无法通过装入页面解决问题。

此时可能向进程发送:

SIGSEGV

最终表现为常见的:

Segmentation Fault

Linux 内核的页表文档也说明,页故障既可能来自页面尚未装入,也可能来自访问不属于当前地址空间的内存或违反页面权限;非法用户空间访问通常会导致 SIGSEGV

4. 缺页异常是中断吗?

日常教学中经常把 Page Fault 统称为“缺页中断”。

但从 CPU 体系结构的严格分类看,它更接近:

同步异常

因为它由当前正在执行的指令直接触发。

例如:

当前指令访问地址 0x1234
        ↓
该地址对应页面不存在
        ↓
立即产生 Page Fault

它与键盘、网卡等设备产生的外部硬件中断不同。

可以这样区分:

外部中断
    由外部设备异步产生
    与当前指令不一定有直接关系

缺页异常
    由当前指令的内存访问同步产生

系统调用
    由用户程序主动请求进入内核

因此,在表达上使用:

缺页异常
页面故障

通常比“缺页中断”更加准确。


七、用户态、内核态与内存管理有什么关系?

1. 为什么用户程序不能自己修改页表?

如果普通应用能够随意修改页表,它就可以建立这样的映射:

自己的虚拟地址
    ↓
其他进程的物理页框

甚至可以映射到:

操作系统内核
设备寄存器
安全敏感数据

整个隔离机制将失去意义。

因此,修改关键地址转换状态通常属于特权操作。

普通 Java 程序运行在用户态,只能使用操作系统允许它访问的虚拟地址。

操作系统内核运行在更高权限级别,可以:

创建和修改页表
配置页面权限
处理中断和异常
管理物理内存
调度进程与线程
访问硬件设备

2. 用户程序如何请求内核服务?

Java 程序读取文件时,最终需要请求操作系统完成磁盘访问。

可以抽象为:

Java 代码
    ↓
JDK 类库
    ↓
JVM 或本地代码
    ↓
系统调用
    ↓
进入内核态
    ↓
文件系统和驱动程序处理
    ↓
返回用户态

系统调用是用户程序请求内核服务的受控入口。

常见系统调用包括:

read
write
openat
mmap
clone
futex

课程中常见的:

int 0x80

主要是经典 32 位 x86 Linux 系统调用入口。

在原生 x86-64 ABI 中,通常使用的是:

syscall

不同 CPU 架构使用的系统调用指令和参数寄存器并不相同。Linux syscall(2) 文档列出的 ABI 表中,i386 对应 int $0x80,x86-64 对应 syscall

3. 系统调用一定会发生线程切换吗?

不一定。

系统调用意味着:

用户态
    ↓
内核态

但不一定意味着:

线程 A
    ↓
线程 B

如果内核可以立即完成请求,当前线程可能进入内核执行一段代码后,直接返回用户态。

只有发生以下情况时,才可能进一步触发调度:

读取的数据尚未准备好
线程主动睡眠
时间片耗尽
等待锁
被更高优先级任务抢占

因此:

用户态与内核态切换
≠
线程上下文切换

4. 阻塞和非阻塞是什么?

假设线程执行:

inputStream.read();

如果当前没有数据,并且调用采用阻塞模式,那么线程可能进入等待状态。

调用 read
    ↓
数据尚未到达
    ↓
当前线程无法继续执行后续代码
    ↓
内核将线程置为等待状态

这就是阻塞。

非阻塞调用则会在当前无法完成操作时立即返回,让程序决定:

稍后重试
处理其他任务
注册事件通知

阻塞描述的是:

当前操作无法立即完成时,调用线程是否需要等待。

它与“是否发生系统调用”不是同一个概念。


八、一次 Java 内存访问到底经历了什么?

现在把前面的内容串起来。

假设 Java 代码执行:

int age = user.age;

经过解释执行或 JIT 编译后,CPU 最终会执行一条读取内存的机器指令。

整个过程可以抽象为:

Java 源代码
    ↓
字节码
    ↓
解释器或 JIT 编译
    ↓
机器指令读取 user.age
    ↓
CPU 计算出虚拟地址
    ↓
MMU 查询 TLB

如果 TLB 命中:

TLB 命中
    ↓
直接得到物理页框号
    ↓
拼接页内偏移量
    ↓
得到物理地址
    ↓
访问 CPU Cache 或物理内存
    ↓
返回字段值

如果 TLB 未命中,但页表中存在有效映射:

TLB 未命中
    ↓
硬件遍历多级页表
    ↓
找到有效页表项
    ↓
将映射填入 TLB
    ↓
得到物理地址
    ↓
完成读取

如果页面尚未准备好:

页表项不存在或存在位为 0
    ↓
触发缺页异常
    ↓
进入内核
    ↓
检查虚拟地址是否合法
    ↓
创建、换入或读取页面
    ↓
更新页表
    ↓
重新执行原来的机器指令

如果地址非法:

地址没有合法映射
或访问权限不允许
    ↓
内核无法修复
    ↓
发送 SIGSEGV 等信号
    ↓
进程可能终止

完整链路可以画成:

Java 对象字段
      ↓
JVM 生成机器指令
      ↓
虚拟地址
      ↓
┌───────────────┐
│ 查询 TLB      │
└───────┬───────┘
        │
   ┌────┴────┐
   │         │
 命中       未命中
   │         │
   │      遍历页表
   │         │
   │   ┌─────┴─────┐
   │   │           │
   │ 映射存在    映射不可用
   │   │           │
   │ 填充 TLB    Page Fault
   │   │           │
   │   │       进入操作系统
   │   │           │
   │   │       准备物理页面
   │   │           │
   └───┴─────┬─────┘
             ↓
          物理地址
             ↓
          CPU Cache
             ↓
          物理内存

程序员看到的只是一行:

int age = user.age;

但在底层,它依赖了:

JVM
CPU 指令
虚拟地址
TLB
多级页表
MMU
物理页框
CPU Cache
操作系统缺页处理

等一整套机制。


九、几个容易混淆的概念

1. 分页和虚拟内存是一回事吗?

不是完全相同的概念。

虚拟内存
    是面向进程的地址空间抽象和管理体系

分页
    是实现虚拟地址映射与物理内存管理的重要方法

现代系统通常通过分页实现虚拟内存,但虚拟内存包含的内容更广。

2. 虚拟内存是不是因为物理内存不够才出现?

内存容量扩展只是其中一个目标。

虚拟内存还用于:

进程隔离
权限控制
共享内存
文件映射
写时复制
地址空间随机化
简化程序链接和装载

即使物理内存非常充足,现代操作系统仍然需要虚拟内存。

3. 页表是否保存实际数据?

不是。

页表主要保存:

地址映射
状态
访问权限

真正的程序代码和数据位于物理页框或相应的后备存储中。

4. 页表不存在映射就一定要读磁盘吗?

不一定。

可能的处理包括:

分配一个清零的新页面
建立共享页面映射
执行写时复制
从文件读取页面
从 Swap 读回页面
判定为非法访问

因此,缺页并不等于一定发生磁盘 I/O。

5. 物理内存不足就一定使用 Swap 吗?

不一定。

系统可能先:

回收文件缓存
丢弃干净文件页面
写回脏页面
压缩内存
回收内核缓存

只有合适的页面才可能被写入 Swap。

如果仍然无法获得足够内存,还可能触发 OOM 处理。

6. LRU 是所有缓存系统的唯一选择吗?

不是。

常见淘汰策略还包括:

FIFO
LFU
CLOCK
Second Chance
Random
TinyLFU
ARC
多代 LRU

不同策略需要在以下目标之间权衡:

命中率
实现成本
并发性能
内存开销
访问模式

LRU 重要,是因为它直观、经典,并且适合帮助理解局部性和缓存淘汰。

7. 进程完全无法共享内存吗?

默认的普通私有页面相互隔离,但操作系统可以显式建立共享映射。

例如:

共享内存
mmap 的共享文件
动态链接库中的只读代码页
进程间通信缓冲区

两个进程的不同虚拟地址,也可以映射到同一个物理页框:

进程 A:虚拟地址 0x1000 ──┐
                           ├──→ 同一个物理页框
进程 B:虚拟地址 0x8000 ──┘

虚拟内存并不意味着所有物理页面都必须独占。

它意味着:

每个进程只能通过操作系统为它建立并授权的映射访问内存。


十、内存管理总结

现代内存管理系统,主要解决两个问题:

物理内存有限
+
不同进程不能互相破坏

分页将虚拟空间和物理内存划分为固定大小的单位:

虚拟空间中的块:页
物理内存中的块:页框

程序不需要一次性完整装入物理内存,而是可以:

按需加载
按页分配
按页回收

每个进程拥有自己的虚拟地址空间。

即使不同进程使用相同虚拟地址,也可以映射到不同物理页框:

相同虚拟地址
+
不同页表
=
不同物理地址

页表负责保存:

虚拟页到物理页框的映射
页面是否存在
读写执行权限
用户态和内核态权限

MMU 是 CPU 内部的地址转换硬件。

TLB 是虚拟页到物理页框映射的高速缓存。

正常地址转换过程为:

虚拟地址
    ↓
TLB
    ↓
多级页表
    ↓
物理地址

页面尚未准备好时,会触发缺页异常:

CPU 发现映射不可用
    ↓
进入操作系统
    ↓
创建、读取或换入页面
    ↓
更新页表
    ↓
重新执行原指令

内存不足时,操作系统会尝试回收不活跃页面。

LRU 是理解页面冷热和缓存淘汰的重要模型,但现代 Linux 页面回收并不是简单实现一个全局、精确的双向链表 LRU。

最后,可以用一句话概括整个内存管理体系:

进程只负责使用虚拟地址,操作系统负责建立和保护映射,MMU 负责把虚拟地址快速转换成物理地址。

对于 Java 程序员来说,理解这条链路后,再看:

JVM 堆
直接内存
内存映射文件
Page Cache
Swap
缺页
OOM
GC 停顿
进程隔离

就不再是一组彼此孤立的概念,而是一套完整的操作系统内存管理体系。

0

评论区