前言
写 Java 时,我们每天都在和内存打交道:
User user = new User();
byte[] buffer = new byte[1024 * 1024];
但如果继续往下追,会碰到一串看起来彼此独立的名词:
Page
Page Frame
Swap
LRU
Virtual Memory
Page Table
MMU
TLB
Page Fault
这些概念如果分开背,很容易记住定义,却不知道它们为什么会一起出现。
理解现代内存管理,更适合从历史问题往回推:
如果程序直接使用物理内存,为什么不行?
再继续增加条件:
最开始主要一次运行一个程序
↓
后来希望多个程序共同运行
↓
物理内存不够了
↓
程序之间还会互相破坏
↓
分页、页面置换、Swap
↓
虚拟地址空间
↓
Page Table + MMU
↓
Page Fault
这条历史线,正好也是现代操作系统内存管理逐步解决问题的过程。
一、从 DOS 到现代内存管理
1. DOS 单任务,不等于 RAM 只能放一个程序
为了理解内存管理的发展,可以先从经典 DOS 的使用方式说起。
经典 DOS 主要采用单任务模型,同一时间通常只有一个前台应用拥有主要的 CPU 执行控制权。早期 PC 的 RAM 也非常小,几十 KB、几百 KB 都是很真实的容量级别。
但这里有一个非常容易产生的误解:
DOS 时代一次主要运行一个应用,是不是因为 RAM 里物理上只能放一个程序?
不是。
先把“程序存在哪里”和“程序由谁执行”分开。
一个程序真正执行之前,大致经历:
也就是:
程序文件在磁盘 / 软盘
↓
被加载到 RAM
↓
CPU 从 RAM 中取指令执行
CPU 不是“程序先经过的中转站”,而是执行者;RAM 才是程序运行时主要保存代码和数据的位置。
如果 RAM 稍微大一些,从纯容量角度当然可以同时存在多份程序代码。例如:
Physical Memory
┌──────────────────────────┐
│ DOS / 系统代码 │
├──────────────────────────┤
│ 驱动 / 常驻程序 │
├──────────────────────────┤
│ Program A 的部分内容 │
├──────────────────────────┤
│ Program B 的部分内容 │
└──────────────────────────┘
历史上的 DOS 也存在 TSR(Terminate and Stay Resident)等机制,可以让一部分程序代码在退出前台后继续常驻 RAM。
所以:
“主要一次执行一个前台应用”不等于“RAM 里只能存在一个程序”。
真正缺少的是现代操作系统那套完整的多任务机制:
RAM 里已经有多个程序
↓
CPU 现在应该执行谁?
↓
A 跑一会儿怎么暂停?
↓
PC、Registers、Stack 状态怎么保存?
↓
什么时候恢复 B?
↓
A 怎么保证不能破坏 B 的内存?
这需要:
Scheduler
Context Switch
Process State
Memory Protection
等机制共同支持。
因此更准确的关系是:
内存容量增加,只是让多个程序同时驻留变得可行;真正让多个程序安全地共同运行,还需要调度和内存保护。
2. 多任务普及后,两个问题立刻暴露
随着硬件资源增加,多任务逐渐成为主流使用方式。
课堂资料用 DOS → Windows 9x 作为历史推导线:DOS 时代以单任务为主,后来内存增大,多个程序能够同时装入内存,再通过调度轮流执行。
这里需要补一个准确性说明:
Windows 9x 本身已经具备保护模式和虚拟内存机制,不能简单理解成“完全没有虚拟内存、所有程序裸奔访问物理内存”。
本文保留这条历史线,是为了理解多任务普及后为什么必须同时解决“容量”和“隔离”两个问题,而不是把 Windows 9x 的真实内核实现简化成纯 DOS 模型。
问题一:物理内存会被撑满
假设机器只有:
16 MB Physical Memory
现在同时需要:
Program A → 6 MB
Program B → 7 MB
Program C → 8 MB
如果仍然坚持:
每个程序启动时,把所有代码和数据一次性完整装进 RAM。
那么:
6 + 7 + 8 = 21 MB
显然装不下。
于是第一个问题出现:
程序能不能不要一启动,就把全部内容都占进 RAM?
问题二:程序之间会互相破坏
容量还不是最危险的问题。
如果普通程序直接使用真实 Physical Address,那么一个 Bug 就可能影响别的程序。
假设:
0x0000 ──────────────────
OS 数据
0x3000 ──────────────────
Program A
0x5000 ──────────────────
Program B
0x7000 ──────────────────
Program A 正常应该操作:
0x3000 ~ 0x4FFF
如果因为越界或错误地址写成:
write 0x5500
而 0x5500 恰好属于 Program B:
更糟的是,如果程序直接碰到了 OS 自己的数据:
所以多任务环境必须同时解决:
内存不够
+
进程互相干扰
课堂资料也是从这两个问题引出现代内存管理:虚拟地址、分页装入和软硬件结合寻址。
3. 两个问题,先分成两条线理解
为了建立第一层直觉,可以先这样拆:
也就是:
Paging
→ 先解决“程序不必一次全部驻留 RAM”
Virtual Address
→ 先解决“进程不能直接裸用 Physical Address”
这是一种教学拆分。
真实现代操作系统里,Paging 本身就是 Virtual Memory System 的重要组成部分,两者最终是一套统一机制。
二、分页:为什么程序不用全部装进 RAM?
一个 500MB 的程序,并不意味着它启动时马上需要其中全部 500MB。
当前真正会执行的可能只是:
启动代码
初始化逻辑
当前页面的数据
至于很久以后才会调用的功能,现在完全没有必要占 Physical Memory。
于是很自然会产生一个设计:
把地址空间切成固定大小的 Page,需要哪一页,就让哪一页进入 Physical Memory。
1. Page 和 Page Frame
先区分两个概念。
Page
Page 属于 Virtual Address Space。
可以想成:
Virtual Page 0
Virtual Page 1
Virtual Page 2
Virtual Page 3
...
Page Frame
Page Frame 属于 Physical Memory。
RAM 也被按同样粒度切成:
Frame 0
Frame 1
Frame 2
Frame 3
...
于是一个 Virtual Page 可以被放到任意合适的 Physical Frame:
这里最关键的是:
Virtual Page 连续
并不要求:
Physical Frame 连续
应用看到的是连续地址空间,真正的物理页框完全可以分散在 RAM 各处。
课堂资料专门区分了 Page 和 Page Frame,并把按需装入作为解决“内存撑爆”的核心方法。
2. 为什么经常看到 4KB?
在常见的 x86-64 Linux 环境里,基础 Page Size 通常是:
4 KiB
可以执行:
getconf PAGESIZE
很多 x86-64 Linux 环境会得到:
4096
也就是 4096 Byte。
但这里不能把课堂里的“所有系统都支持 4K”直接背成跨架构绝对结论。
Page Size 与 Architecture、Kernel Configuration 有关;Linux 还支持 HugeTLB、Transparent Huge Pages 等更大页面。
所以这篇文章后面仍用 4KB 建立直觉,但它代表的是常见 x86-64 基础分页模型,不是所有机器的唯一固定值。
3. 程序文件不是被切成一万个 4KB 小文件
课堂里为了理解 Paging,会画:
Program
↓
4KB
4KB
4KB
4KB
这个模型非常适合理解“按页管理”,但不要把它理解成:
硬盘中的 ELF / EXE 被操作系统真的切成了一堆独立 4KB 文件。
更准确的理解是:
Executable / File
↓
Loader 建立 Virtual Mapping
↓
Virtual Address Range 与 File Offset 关联
↓
真正访问某个 Page 时
↓
按页面粒度建立实际映射
也就是说,Page 是内存管理粒度,不是文件系统里一堆独立小文件。
4. “双击程序就整个加载进 RAM”为什么不准确?
课堂资料专门纠正了这个误区:程序启动时并不是整个程序文件全部立即进入 RAM,而是先建立必要的地址空间与映射关系,再按需把需要的页装入。
对于现代 Linux,更准确的说法应该再精确一层:
Kernel / Loader 先建立进程的 Virtual Memory Areas 和文件映射关系,具体的 Physical Page 和 PTE 往往可以在真正访问时通过 Page Fault 再建立。
所以概念链是:
5. 为什么按页加载能有效?局部性
分页之所以能工作得很好,很大程度上依赖 Locality。
课堂资料讲了两种局部性:时间局部性和空间局部性。
时间局部性
刚访问过的代码或数据,短时间内很可能再次访问。
例如循环里的几条机器指令会不断重复执行。
空间局部性
访问一个地址以后,附近的数据也很可能很快被访问。
例如:
array[100]
array[101]
array[102]
array[103]
因此程序在某一段时间内,往往只集中使用整个地址空间的一小部分 Working Set。
这正是 Demand Paging 可行的基础之一:
只要当前真正需要的页面在 RAM 里,没必要把整个程序全部塞进 Physical Memory。
三、LRU、Page Replacement 与 Swap
前面已经知道,Demand Paging 允许程序只把当前真正需要的 Page 放进 Physical Memory。
现在继续往下推。
假设 Physical Memory 的所有 Frame 都已经被占满:
Frame 0 → Page A
Frame 1 → Page B
Frame 2 → Page C
Frame 3 → Page D
程序此时又需要:
Page E
没有空闲 Frame 了。
这时候操作系统必须先回答一个问题:
已经在 RAM 里的 Page,到底应该淘汰谁?
这就是这堂课引出 LRU 的地方。
1. LRU 到底是什么?
LRU 的全称是:
Least Recently Used
也就是:
最近最少使用。
LRU 首先是一种淘汰策略,不是某一种固定的数据结构。
它解决的问题很简单:
当空间已经满了,必须淘汰一个数据时,优先淘汰最长时间没有被访问的数据。
假设一个缓存当前只能保存 4 个数据,并且访问顺序是:
A → B → C → D
规定:
左边 = 最久没有使用
右边 = 最近刚使用
那么:
A = LRU
D = MRU
其中 MRU 是:
Most Recently Used
最近使用
现在缓存已经满了,又要加入 E。
按照 LRU 规则:
A → B → C → D
先淘汰 A,再加入 E:
B → C → D → E
如果接下来又访问了 B,那么 B 就变成最近使用的数据:
C → D → E → B
所以 LRU 真正规定的只有两件事:
1. 每次访问数据,都更新“最近使用顺序”
2. 空间满时,淘汰最长时间没有被访问的数据
所以一定要先分清:
LRU
= 淘汰规则 / 淘汰思想
至于怎么记录“谁最近使用、谁最久没使用”,可以有很多实现方式:
数组 + 时间戳
链表
HashMap + 链表
其他能够记录访问顺序的数据结构
它们都可以实现 LRU 思想,只是时间复杂度不同。
2. LRU 在页面置换里做什么?
回到这堂课的操作系统场景。
Page Frame 已经全部占满,现在又必须加载一个新 Page:
Page Frame 全满
↓
需要加载新 Page
↓
必须先选一个旧 Page 淘汰
↓
LRU 判断哪个 Page 最久没有被使用
↓
优先淘汰这个 Cold Page
所以:
在这一课的内存管理主线里,LRU 的作用就是给 Page Replacement 提供“选谁出去”的策略。
例如当前顺序:
如果必须释放一个 Physical Frame,就优先选择 Page A。
这里还不需要考虑 HashMap + 双向链表。
因为操作系统这一部分真正关心的是:
谁最冷?
谁应该被换出去?
至于用什么数据结构高效实现,是另外一个问题。
3. Swap 在这里负责什么?
LRU 和 Swap 不是同一个东西。
可以一句话区分:
LRU
→ 负责“选谁出去”
Swap
→ 负责“需要保存的页面出去以后放哪里”
课堂里的第一层模型是:
以后这个 Page 又被访问:
于是这几个概念就连起来了:
Page Frame 满
↓
Page Replacement
↓
LRU 选择 Cold Page
↓
必要时 Swap Out
↓
释放 Frame
↓
加载新 Page
4. 真实 Linux 为什么不是所有冷页都进 Swap?
上面的流程非常适合建立直觉,但真实 Linux 还要区分 Page 类型。
例如一个 Clean File-backed Page:
文件原始内容本来就在磁盘
+
RAM 中的这份 Page 没有被修改
Kernel 可以直接丢弃这份内存页,以后需要时重新从原文件读取。
而 Anonymous Memory:
Java Heap
Native Heap
Anonymous mmap
没有一个原始文件可以重新读取。
这种 Page 如果需要被换出,并且系统启用了 Swap,才是典型的 Swap Out 对象。
所以真实 Page Reclaim 更接近:
这不是否定课堂里的:
LRU → Swap
而是把第一层教学模型进一步精确到真实 Linux。
5. Linux Page Reclaim 不等于标准 LRU
还有一层需要区分。
标准 LRU 很容易理解:
精确记录所有数据的访问先后顺序
↓
永远淘汰最久没有访问的数据
但如果 Linux Kernel 真给全系统几百万、几千万个 Page 精确维护一个严格访问顺序,本身就会付出很高成本。
因此真实 Linux Page Reclaim 使用的是更复杂的近似和代际机制,现代 Kernel 还提供 Multi-Gen LRU(MGLRU)等机制来估计 Page 的冷热程度。
所以应该把三个东西分开:
LRU
→ 一种淘汰思想
LeetCode 146 - LRU Cache
→ 用代码实现 LRU 思想的一道具体题
Linux Page Reclaim
→ 操作系统真正的工程级页面回收机制
四、LeetCode 146:为什么最终是 HashMap + 双向链表?
知道 LRU 本身是什么以后,再来看课堂为什么突然讲到了:
HashMap + 双向链表
原因不是:
LRU 天生必须这么实现。
而是课堂接下来拿 LeetCode 146 - LRU Cache 作为例题。
这道题除了要求实现 LRU 淘汰规则,还额外要求:
get(key) = O(1)
put(key, value) = O(1)
于是问题从:
“怎样实现 LRU?”
进一步变成:
怎样 O(1) 找到一个数据?
怎样 O(1) 更新它的最近使用顺序?
怎样 O(1) 淘汰最久没使用的数据?
这才一步步推导出:
HashMap + 双向链表
所以正确的因果关系是:先有 LRU 淘汰思想,再有 LeetCode 146 这个具体问题;因为题目附加了 O(1) 约束,才继续推导出 HashMap + 双向链表。
1. 数组为什么不满足 O(1)?
可以给数组里的每个元素记录访问时间:
A → time=10
B → time=3
C → time=8
D → time=1
但缓存满时,要找到最久没访问的一项,通常需要遍历:
O(n)
不满足题目要求。
课堂资料也先从数组 O(n)、树 O(log n) 分析,再进入链表。
2. 单链表为什么还不够?
链表可以很好地维护顺序:
最久未使用 → ... → 最近使用
如果缓存满,直接删除一端即可。
但 get(key) 怎么快速找到目标节点?
只用单链表仍然需要遍历:
O(n)
即使加 HashMap 后能够 O(1) 找到 Node,还有另一个问题:
命中一个中间节点后,怎么 O(1) 把它从原位置摘下来,再移动到“最近使用”一端?
单链表只知道:
next
不知道:
prev
要找前驱节点仍然可能需要从头遍历。
这正是课堂资料从单向链表继续推到双向链表的原因。
3. HashMap + 双向链表分别负责什么?
最终结构:
HashMap 负责:
O(1) 根据 key 找到 Node
双向链表负责:
O(1) 删除中间 Node
O(1) 把 Node 移到 MRU 端
O(1) 删除 LRU 端节点
所以经典 LRU Cache 才能满足:
get → O(1)
put → O(1)
课堂资料也明确把 HashMap 用于 O(1) 查找、双向链表用于 O(1) 插入删除。
4. Head / Tail 放哪边并不是本质
有些实现规定:
Head = LRU
Tail = MRU
也有些实现反过来:
Head = MRU
Tail = LRU
两种都可以。
真正必须保证的是:
全程使用同一套约定。
本文采用:
Head → LRU
Tail → MRU
5. Java 实现
下面不使用 LinkedHashMap,手写 HashMap + Doubly Linked List:
import java.util.HashMap;
import java.util.Map;
public class LRUCache {
private static class Node {
int key;
int value;
Node prev;
Node next;
Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private final int capacity;
private final Map<Integer, Node> map = new HashMap<>();
private final Node head = new Node(-1, -1);
private final Node tail = new Node(-1, -1);
public LRUCache(int capacity) {
this.capacity = capacity;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
Node node = map.get(key);
if (node == null) {
return -1;
}
moveToTail(node);
return node.value;
}
public void put(int key, int value) {
Node node = map.get(key);
if (node != null) {
node.value = value;
moveToTail(node);
return;
}
node = new Node(key, value);
map.put(key, node);
addBeforeTail(node);
if (map.size() > capacity) {
Node lru = head.next;
remove(lru);
map.remove(lru.key);
}
}
private void moveToTail(Node node) {
remove(node);
addBeforeTail(node);
}
private void remove(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private void addBeforeTail(Node node) {
node.prev = tail.prev;
node.next = tail;
tail.prev.next = node;
tail.prev = node;
}
}
测试:
public static void main(String[] args) {
LRUCache cache = new LRUCache(2);
cache.put(1, 10);
cache.put(2, 20);
System.out.println(cache.get(1));
cache.put(3, 30);
System.out.println(cache.get(2));
System.out.println(cache.get(3));
}
本文在 OpenJDK 21.0.11 中实际编译运行,输出:
10
-1
30
过程是:
put(1)
put(2)
get(1)
→ 1 变成 MRU
→ 2 变成 LRU
put(3)
→ 容量满
→ 淘汰 2
JDK 自带的 LinkedHashMap 本身就把 Hash Table 和 Linked List 结合在一起,因此也很适合实现 LRU Cache;但 LeetCode / 面试“手写 LRU”通常真正考察的是你能不能把这套结构自己推出来。
五、虚拟内存:为什么进程不能直接碰物理内存?
前面的 Paging 解决了:
程序不必全部驻留 RAM
现在回到第二个问题:
A 怎么保证不能随便写坏 B?
这就进入 Virtual Memory。
课堂资料对 Virtual Memory 的核心定位是:每个进程工作在独立的虚拟地址空间,而不是直接操作真实 Physical Memory,从而实现隔离。
1. 每个进程都有自己的地址世界
假设 Process A 里存在:
0x1000
Process B 里也存在:
0x1000
完全没有问题。
因为:
A 的 0x1000
属于 Address Space A;
B 的 0x1000
属于 Address Space B。
于是:
Virtual Address 数值相同
完全不意味着:
Physical Address 相同
这就是虚拟内存最关键的“假象”之一:
每个进程都觉得自己拥有一整套独立地址空间。
2. 实验:两个进程真的可以使用同一个虚拟地址
下面用 Linux mmap(),让两个进程都尝试在:
0x700000000000
映射一页 Anonymous Memory。
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <unistd.h>
int main(int argc, char **argv) {
int value = argc > 1 ? atoi(argv[1]) : 1;
void *wanted = (void *)0x700000000000UL;
void *p = mmap(
wanted,
4096,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE,
-1,
0
);
if (p == MAP_FAILED) {
perror("mmap");
return 1;
}
*(volatile int *)p = value;
printf(
"pid=%d virtual=%p value=%d\n",
getpid(), p, *(int *)p
);
sleep(2);
}
编译:
gcc -O2 -Wall same_va.c -o same_va
分别启动:
./same_va 111 &
./same_va 222 &
本文 Linux 环境实际得到:
pid=1408 virtual=0x700000000000 value=111
pid=1409 virtual=0x700000000000 value=222
两个进程拥有完全一样的 Virtual Address:
0x700000000000
却分别保存:
111
222
这就是进程地址空间隔离最直观的证明。
3. 为什么 A 不能随便访问 B?
普通 Load / Store 使用的是当前 Process 的地址翻译上下文。
如果 Kernel 没有为 A 建立:
A 的 Virtual Address
↓
B 的 Physical Page
那么 A 就不能靠普通内存指令擅自访问 B。
真正强制执行这件事的是:
Operating System
+
CPU MMU
+
Page Table Permission
4. 不同进程也可以故意共享物理内存
隔离并不代表:
不同 Process 永远不能映射同一 Physical Page。
Kernel 可以主动建立:
例如:
Shared Memory
File-backed mmap
Shared Library
都可能利用共享映射。
课堂资料也提到了 Shared Library 可以由多个进程共享同一份物理代码。
所以更准确的说法是:
进程不能擅自访问别人的内存,但 Kernel 可以显式建立共享。
5. Virtual Memory 不等于 Swap
很多人第一次听“虚拟内存”,会直接理解成:
RAM 不够
↓
拿硬盘冒充内存
然后得到:
Virtual Memory = Swap
这是错误的。
Virtual Memory 是一整套:
Virtual Address Space
Paging
Page Table
Permission
MMU
Demand Paging
Shared Mapping
Page Fault
Swap
...
共同构成的系统。
Swap 只是其中一个可能使用的 Backing Store。
即使系统完全没有配置 Swap,Virtual Address、Page Table、进程隔离依然存在。
六、一个进程的虚拟地址空间里有什么?
课堂资料在讲完独立 Virtual Address Space 后,又继续展开了进程内部结构:Code、Data、Heap、Shared Library、Stack、Kernel Area 等。
可以先画成:
具体布局会受到:
Architecture
ASLR
PIE
Loader
mmap()
线程数量
等因素影响,所以不要把上图理解成所有 Linux Process 的固定地址模板。
1. Text / Code
通常保存机器代码,并通过权限设置限制为可执行、不可随意写入。
2. Data / BSS
保存全局、静态数据。
3. Heap
用于动态内存分配。
Java Process 里的 Java Heap 最终也建立在 OS Virtual Memory 机制上。
4. Shared Libraries / mmap Area
动态库、文件映射、匿名映射等通常出现在这里。
5. Stack
保存函数调用、返回地址、局部状态等。
每个 Native Thread 都有自己的 Stack。
6. “每个进程都有一份 Kernel Space”要怎么理解?
课堂图为了建立直觉,会画每个 Process 的 Virtual Address Space 上方都有一块 Kernel Area,同时强调 Physical Memory 中 Kernel 只有一份。
这个模型在理解传统地址空间布局时很有用,但现代 Linux 不能绝对说:
用户态运行时每个进程始终完整映射全部 Kernel Space。
例如现代 x86 Linux 可能启用 Page Table Isolation(PTI),用户态 Page Table 会尽量减少 Kernel Mapping,在进入 Kernel 时再使用完整映射。
所以这里真正应该记住的是:
Kernel 不是给每个 Process 复制一份物理内核,而是通过地址映射和权限机制让 Process 在需要时进入共享的 Kernel。
七、32 位和 64 位到底代表多大的地址空间?
课堂资料这一部分出现了前后冲突:前面一度把 2^32 / 2^64 写成 bit,后面的总结又明确改成 byte。
对常见 Byte-addressable 体系结构,地址空间通常按 Byte 计算。
理论上:
32-bit address
→ 2^32 Byte
→ 4 GiB
而:
64-bit address
→ 理论可表达 2^64 Byte
→ 16 EiB
但这不等于现实 x86-64 Process 今天真的能使用完整 16 EiB Virtual Address Space。
真实 CPU 只实现部分 Virtual Address Bits。
例如 Linux 官方文档说明,x86-64 4-level Paging 和 5-level Paging 支持的 Virtual Address Range 不同;5-level Paging 可以进一步扩大地址空间,但 Linux 为兼容旧软件也不会默认把普通 Mapping 放到所有高地址区域。
所以正确理解是:
“32 位 / 64 位”描述地址表达能力的一部分;实际可用 Virtual Address Width 还取决于 CPU Paging Architecture 和 OS。
八、逻辑地址、线性地址和物理地址是什么关系?
课堂资料按经典 x86 模型讲了:
Logical Address
↓
Segment Base + Offset
↓
Linear Address
↓
MMU / Paging
↓
Physical Address
并举了“段基址 1000 + 段内偏移 20 = 线性地址 1020”的例子。
这套模型对理解 x86 历史非常有价值。
1. Logical Address
在经典 Segmentation 模型里,可以理解成:
Segment + Offset
或者更直观地说:
某个 Segment 内部的偏移位置
2. Linear Address
Segment Base 加 Offset 后得到 Linear Address。
它仍然不是最终 DRAM 位置。
3. Physical Address
Linear / Virtual Address 经过 Paging Translation 后,最终得到 Physical Address。
4. 现代 x86-64 为什么不能一直按“分段”理解?
在现代 x86-64 Long Mode 中,传统 Segmentation 已经被大幅弱化。
普通 Java / Linux User Process 的主线更应该抓住:
Virtual / Linear Address
↓
Paging
↓
Physical Address
FS / GS 等 Segment Register 仍然有实际用途,例如 Thread Local Storage,但传统“代码段、数据段都靠不同 Segment Base 做普通寻址”已经不是现代 x86-64 的主流运行模型。
所以这里保留 Segmentation,是为了理解历史和术语,不应该把它当成现代 Linux 每次普通内存访问的核心步骤。
九、Page Table:虚拟地址和物理地址怎么对应?
现在来到 Virtual Memory 最核心的桥梁:
Page Table
最简单可以把它想成:
| Virtual Page | Physical Frame | Present | Writable |
|---|---|---|---|
| Page 0 | Frame 5 | Yes | No |
| Page 1 | Frame 9 | Yes | Yes |
| Page 2 | — | No | — |
它不只记录:
Virtual Page → Physical Frame
还参与维护:
Present
Read / Write
User / Supervisor
Execute 等 Permission
所以 Page Table 同时参与两件事:
Address Translation
+
Memory Protection
1. 为什么真实 Page Table 不是一张超级大数组?
64-bit Address Space 太大,如果为所有可能 Virtual Page 预先放一张平铺表,浪费会非常严重。
所以现代系统使用 Multi-level Page Table。
Linux 的架构无关层可以抽象成最多五级:
PGD
↓
P4D
↓
PUD
↓
PMD
↓
PTE
没有使用的层可以 Folding 掉。
十、MMU 和 TLB:CPU 怎么真正完成地址翻译?
课堂资料把 MMU 定义为 CPU 内部负责地址映射的硬件单元,并强调 Virtual / Linear Address 到 Physical Address 需要 OS 与硬件共同完成。
1. Kernel 和 MMU 分别做什么?
Kernel 负责:
创建和维护 Page Table
建立 Mapping
设置 Permission
处理 Page Fault
MMU 负责:
按照当前 Page Table
完成硬件 Address Translation
以及 Permission Check
所以 Virtual Memory 是典型的:
OS Software + CPU Hardware 协同机制。
2. TLB 为什么必须存在?
如果每次内存访问都重新走完整多级 Page Table,成本会很高。
于是 CPU 使用:
TLB
Translation Lookaside Buffer
缓存地址翻译结果。
这里要区分:
TLB
→ 缓存 Virtual Page → Physical Frame Translation
L1 / L2 / L3 Cache
→ 缓存真正的数据 / 指令
两者都是 Cache,但缓存的东西完全不同。
十一、Page Fault:页面暂时不能访问时会发生什么?
课堂资料把“缺页中断”作为最后一个核心知识点:Process 访问的 Page 不在 Physical Memory 时,CPU 产生缺页异常,Kernel 再负责处理。
中文教材常叫:
缺页中断
更精确的 CPU 分类是:
Page Fault Exception
它是当前指令同步触发的 Exception,不是网卡 IRQ 那种异步 Hardware Interrupt。
1. 最典型的处理流程
2. 如果 Page 在 Swap 里
其中一种典型情况:
Virtual Page
当前不在 RAM
但数据在 Swap
↓
Page Fault
↓
Kernel Swap In
↓
放入 Physical Frame
↓
更新 Page Table
↓
继续执行
这就把前面的:
Paging
LRU
Swap
Page Fault
全部连接起来了。
3. Page Fault 不一定读硬盘
课堂为了建立直觉,主要讲了:
Page 不在 RAM
→ 从硬盘读进来
这是重要场景,但不能继续背成:
Page Fault = 一定发生 Disk I/O
Linux 官方文档明确说明,Page Fault 也可能来自 Lazy Allocation、Copy-on-Write、Permission 等原因;真正从 Storage 读取页面只是其中一种情况。
例如:
Anonymous Memory 第一次触碰
Copy-on-Write
已经在 Page Cache 中的 File-backed Page
都可能发生 Page Fault,却不一定产生实际磁盘读取。
所以最核心的定义应该是:
当前这次 Virtual Memory Access 无法按照现有 Translation 直接完成,需要 Kernel 介入。
十二、为什么内存不足时整台机器会越来越卡?
课堂提到了一个很有时代感、但非常有帮助的现象:
内存不足以后系统频繁做页面置换,机械硬盘持续“嘎啦嘎啦”读写,整台机器越来越卡。
这背后就是 Working Set 和 Page Reclaim 的问题。
如果 Working Set 明显大于可用 RAM:
系统大量时间都花在:
Page Fault
Reclaim
Swap I/O
而不是执行真正业务。
这种严重的页面来回换入换出叫:
Thrashing
所以 Swap 可以作为内存压力下的后备机制,但它绝不等价于 RAM。
十三、把整套内存管理重新串起来
现在从历史到执行路径再走一次。
1. 历史演化
2. 一个 Process 启动
Executable
↓
Loader / Kernel 建立 Virtual Address Space
↓
创建 Code / Data / Library 等 Mapping
↓
CPU 开始执行入口
↓
真正访问某 Page 时按需建立 Physical Mapping
3. 一次 Java Object Field Access
假设:
int age = user.age;
JIT 最终生成 Machine Load 后:
如果此时 Physical Memory 又处于压力状态:
Kernel
↓
Page Reclaim
↓
选择 Cold Page
↓
Drop / Writeback / Swap Out
↓
释放 Frame
到这里,前面所有概念终于连成一条线:
Process
Virtual Address
Page
Page Table
TLB
MMU
Physical Frame
Page Fault
LRU / Reclaim
Swap
十四、ZGC:为什么这节课会顺带提到它?
课堂在 Virtual Address Mapping 后又顺带讲到了 ZGC Colored Pointer。资料的意图很明显:不是在这里完整展开 GC,而是用它说明——JVM 的高级算法也会利用“地址是一层抽象”这个思想。
这里先只建立一个连接:
Virtual Address
不等于
Physical Address
甚至:
不同 Virtual Mapping
可以映射到相同 Physical Memory
理解这一层后,后面学习 ZGC 的:
Colored Pointer
Load Barrier
Store Barrier
Concurrent Relocation
会容易很多。
需要特别注意:课堂资料中的:
42-bit Address
4-bit Color
18-bit Unused
以及 Multi-Mapped Heap,是特定历史版本 ZGC 的实现模型,不应该当成今天所有 Generational ZGC 版本都固定不变的结构。
这一篇只把 ZGC 当作 Virtual Address 思想的延伸,真正的 GC 原理留到 JVM 专题再展开。
十五、几个最容易混淆的说法
1. 关于 DOS 与多任务
误区 1:DOS 主要一次运行一个程序,所以 RAM 里绝对只能存在一个程序。
错误。
RAM 从容量上可以存在系统代码、驱动、TSR 以及其他程序内容。单任务描述的是执行模型,不是“RAM 物理上只能放一个文件”。
误区 2:RAM 变大以后,多进程就自动出现。
错误。
RAM 变大只是提供条件;多任务还需要 Scheduler、Context Switch、Process State 和 Memory Protection。
2. 关于 Paging
误区 3:程序启动时,整个 Executable 一次性完整加载到 RAM。
错误。
现代系统会先建立 Virtual Mapping,再通过 Demand Paging 按需建立实际 Physical Page。
误区 4:所有系统的 Page 都固定 4KB。
错误。
4 KiB 是常见 x86-64 Linux 的基础页大小之一,不是跨架构绝对常量。
误区 5:硬盘上的程序真的被切成很多独立 4KB 小文件。
错误。
Page 是 Memory Management 的粒度,不是文件系统中的独立文件。
3. 关于 LRU 与 Swap
误区 6:LRU 就是 Swap。
错误。
LRU 负责“选谁淘汰”;Swap 是某些被换出页面的 Backing Store。
误区 7:LRU 必须用 HashMap + 双向链表。
错误。
LRU 是淘汰策略。HashMap + Doubly Linked List 是为了满足 LeetCode 146 get / put O(1) 约束得到的经典实现。
误区 8:Linux 所有被回收的 Page 都会进入 Swap。
错误。
File-backed / Anonymous、Clean / Dirty Page 的处理方式不同。
误区 9:Linux 内核就是 LeetCode 146 那套精确 LRU。
错误。
真实 Linux Page Reclaim 是复杂的近似和代际实现,现代 Kernel 还提供 MGLRU。
4. 关于 Virtual Memory
误区 10:Virtual Memory 就是硬盘虚拟出来的 RAM。
错误。
Swap 只是 Virtual Memory System 的一部分。Virtual Address、Page Table、Permission、MMU、Demand Paging 都属于这套系统。
误区 11:两个 Process 不可能出现相同地址。
错误。
不同 Address Space 完全可以出现相同 Virtual Address,最终映射到不同 Physical Frame。
误区 12:一个 64-bit Process 一定能使用完整 16 EiB Virtual Address Space。
错误。
2^64 是理论地址编码能力,现实可用范围由 CPU Paging Architecture 和 OS 决定。
5. 关于地址翻译和 Page Fault
误区 13:现代 x86-64 每次内存访问都必须先做传统 Segment Base + Offset。
错误。
这套模型主要用于理解 x86 Segmentation 历史;Long Mode 下普通寻址的主线是 Paging。
误区 14:Page Fault 一定意味着从硬盘读取数据。
错误。
Lazy Allocation、Copy-on-Write 等也会产生 Page Fault。
误区 15:Page Fault 是普通 Hardware Interrupt。
错误。
它属于由当前指令同步触发的 Exception。
十六、总结
现代内存管理如果从 Page Table 开始背,会显得非常复杂。
但从历史问题重新走一遍,所有组件都有明确理由。
最开始:
DOS 等早期环境
↓
单任务为主
↓
程序与 Physical Memory 的关系很直接
后来多任务普及,马上遇到两个问题。
第一个:
这么多程序,RAM 根本装不下。
于是出现:
Paging
↓
Demand Paging
↓
需要哪个 Page 就装哪个 Page
↓
Frame 满时 Page Replacement
↓
LRU 思想选择 Cold Page
↓
必要时 Swap
第二个:
如果普通程序直接使用 Physical Address,一个 Bug 就可能破坏其他程序甚至整个系统。
于是出现:
Virtual Address Space
↓
每个 Process 有自己的地址世界
↓
Page Table 决定 Mapping 和 Permission
↓
MMU 强制执行 Translation 和 Protection
CPU 真正访问内存时又需要:
Virtual Address
↓
TLB
↓
Page Table Walk
↓
Physical Address
↓
CPU Cache / DRAM
如果当前 Translation 无法直接完成:
Page Fault
↓
Linux Kernel
↓
Allocation / COW / File / Swap In
↓
更新 Page Table
↓
重新执行原来的指令
所以,Virtual Memory 最重要的价值不是“假的内存”,而是做了一层非常关键的解耦:
程序看到的地址,不再等于真实 Physical Memory 的位置。
有了这层抽象,操作系统才能同时实现:
Process Isolation
Demand Paging
Non-contiguous Physical Memory
Shared Mapping
Permission Protection
Page Reclaim
Swap
现代内存管理不是凭空设计出 Page、Swap、MMU 这些概念,而是从“多个程序怎样安全地共同使用有限物理内存”这个问题一步步演化出来的。分页提高有限 RAM 的利用率,虚拟地址建立进程隔离,Page Table 与 MMU 把虚拟世界连接到真实物理内存,而 Page Fault、Reclaim 与 Swap 则让这套映射能够动态变化。
评论区