前言
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
每次 get 和 put 最多只进行:
一次 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 停顿
进程隔离
就不再是一组彼此孤立的概念,而是一套完整的操作系统内存管理体系。
评论区