Update 计算机操作系统 - 内存管理.md

补充虚拟内存、多级页表与写时复制的说明及实测示例,调整段页式与内存碎片对比内容,并新增及重新绘制相关配图。
This commit is contained in:
CyC2018
2026-10-30 20:19:27 +08:00
parent 2726df2670
commit 783862b30d
7 changed files with 56 additions and 24 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 221 KiB

+56 -24
View File
@@ -32,15 +32,17 @@
- 简化内存管理:每个程序都按相同的地址布局链接和加载,不用关心物理内存中哪些位置空闲,分到的物理内存也不必连续;
- 共享:不同进程的虚拟页可以映射到同一块物理内存,例如共享库的代码、fork 之后写时复制的页。
为了更好的管理内存,操作系统将内存抽象成地址空间。每个程序拥有自己的地址空间,这个地址空间被分割成多个块,每一块称为一页。这些页被映射到物理内存,但不需要映射到连续的物理内存,也不需要所有页都必须在物理内存中。当程序引用到不在物理内存中的页时,硬件触发缺页中断,由操作系统将缺失的页装入物理内存,再重新执行失败的指令。
实现上,操作系统把每个进程的地址空间划分成固定大小的页,物理内存按同样的大小划分成页框,再由页表记录每一页放在哪个页框中。页不必放在连续的页框中,也不必都在物理内存中:访问到不在内存中的页时,硬件触发[缺页异常](#分页系统地址映射),操作系统把这一页装入内存,再重新执行那条指令。
从上面的描述中可以看出,虚拟内存允许程序不用将地址空间中的每一页都映射到物理内存,也就是说一个程序不需要全部调入内存就可以运行,这使得有限的内存运行大程序成为可能。例如有一台计算机可以产生 16 位地址,那么一个程序的地址空间范围是 0\~64K。该计算机只有 32KB 的物理内存,虚拟内存技术允许该计算机运行一个 64K 大小的程序。
因此程序不需要全部调入内存就能运行。例如一台计算机产生 16 位地址,程序的地址空间是 0\~64 KB,物理内存却只有 32 KB,借助虚拟内存仍然可以运行一个 64 KB 的程序,内存中只放当前用到的页。
地址空间的大小由虚拟地址的位数决定:32 位系统是 2^32 = 4 GB,Linux 默认用户态 3 GB、内核 1 GB;x86-64 目前使用 48 位虚拟地址,共 256 TB,用户态和内核各约 128 TB,开启 5 级页表后用户态扩大到 64 PB。
<div align="center"> <img src="pics/vm-mapping.png" width="640px"/> </div><br>
## 分页系统地址映射
内存管理单元(MMU)管理着地址空间和物理内存的转换,其中的页表(Page table)存储着页(程序地址空间)和页框(物理内存空间)的映射表。
内存管理单元(Memory Management Unit, MMU)是 CPU 中负责把虚拟地址翻译成物理地址的硬件,翻译的依据是操作系统为每个进程维护的页表(Page Table),页表记录了每一页(虚拟地址空间)对应哪个页框(物理内存)。
一个虚拟地址分成两个部分,一部分存储页面号,一部分存储偏移量。
@@ -50,37 +52,45 @@
页表存放在内存中,每次访问内存之前都要先查一次页表。CPU 中的快表(Translation Lookaside Buffer, TLB)缓存最近用过的页表项,命中时不用再访问页表。
64 位系统的地址空间很大,如果用一张页表覆盖全部地址,页表本身就会非常大,因此使用多级页表,只为用到的地址分配下一级页表。例如 x86-64 通常使用 4 级页表,48 位虚拟地址分为 4 个 9 位的页表索引和 12 位的页内偏移。
64 位系统的地址空间很大,如果用一张页表覆盖全部地址,页表本身就会非常大:48 位地址、4 KB 的页共有 2^36 个页,每个页表项 8 字节,一张单级页表要 512 GB,而且每个进程一张。因此使用多级页表,只为用到的地址分配下一级页表。例如 x86-64 通常使用 4 级页表,48 位虚拟地址分为 4 个 9 位的页表索引和 12 位的页内偏移。
48 位地址、4 KB 的页共有 2^36 个页,每个页表项 8 字节,一张覆盖全部地址的单级页表要 512 GB,而且每个进程一张。4 级页表中每张表正好占一页(512 项 × 8 字节),某一级的表项为空,它下面的整棵子树都不用分配。例如一个刚启动的小程序,页表只占 44 KB;mmap 256 MB 并逐页写入后,新增的 65536 个页表项占 512 KB,页表共 556 KB(Ubuntu 24.04 实测,见[附录 A4](#a4-页表占用的内存))。代价是 TLB 未命中时要依次访问 4 级页表,所以 TLB 的命中率对性能很重要,使用 2 MB 的大页时,同样多的 TLB 表项可以覆盖更多内存。
4 级页表中每张表正好占一页(512 项 × 8 字节),某一级的表项为空,它下面的整棵子树都不用分配。例如一个刚启动的小程序,页表只占 44 KB;mmap 256 MB 并逐页写入后,新增的 65536 个页表项占 512 KB,页表共 556 KB(Ubuntu 24.04 实测,见[附录 A4](#a4-页表占用的内存))。
MMU 查页表时发现页不在内存中,或者这次访问违反了页的权限(例如写只读页),就会触发缺页异常(Page Fault),由操作系统处理:
<div align="center"> <img src="pics/mem-page-walk.png" width="760px"/> </div><br>
多级页表的代价是 TLB 未命中时要依次访问 4 级页表,所以 TLB 的命中率对性能很重要,使用 2 MB 的大页时,同样多的 TLB 表项可以覆盖更多内存。
MMU 查页表时发现页不在内存中,或者这次访问违反了页的权限(例如写只读页),就会触发缺页异常(Page Fault,也常称为缺页中断),由操作系统处理:
1. 保存现场,取得引发缺页的虚拟地址;
2. 检查地址是否合法,地址不属于进程的任何区域或者权限不符(例如写代码段)时,向进程发送 SIGSEGV 信号,也就是常见的段错误;
2. 检查地址是否合法,地址不属于进程的任何区域,或者违反了区域本身的权限(例如写代码段)时,向进程发送 SIGSEGV 信号,也就是常见的段错误;区域允许写、只是页表项暂时标为只读的(写时复制),属于正常的缺页,继续处理;
3. 按页的来源准备页框:第一次访问的堆、栈等匿名页分配一个页框并清零;代码和 mmap 映射的文件页先在页缓存中找,找到就直接映射,找不到才分配页框并读磁盘;换出到交换区的页从交换区读回;fork 之后写共享的页时复制一份(写时复制);
4. 需要页框而内存不足时,先用页面置换算法换出一页,被修改过的页要先写回磁盘;
5. 更新页表项,回到用户态重新执行引发缺页的那条指令。
需要读磁盘的缺页称为主要缺页(Major Fault),进程要阻塞到 I/O 完成,期间 CPU 去运行其它进程;不需要 I/O 的称为次要缺页(Minor Fault),只需分配、清零或复制页框并修改页表。缺页异常属于异常,在执行某条指令的过程中同步产生,处理完后重新执行这条指令;外部中断由设备异步产生,处理完后继续执行下一条指令,见[中断分类](计算机操作系统%20-%20概述.md#中断分类)。
例如用 mmap 申请 256 MB 的匿名内存后,进程的常驻内存(RSS)几乎不变;逐页写入一遍,触发 65536 次次要缺页(256 MB / 4 KB),RSS 增加到 257 MB,再写一遍就不再缺页。fork 出的子进程把这些页再写一遍,又触发 65536 次缺页,每次复制一页,父进程中的值不受影响(Ubuntu 24.04 实测,见[附录 A2](#a2-缺页次数))。这也说明申请内存只是分配了虚拟地址,真正用到时才占用物理内存。
<div align="center"> <img src="pics/mem-page-fault-paths.png" width="760px"/> </div><br>
因此申请比物理内存还大的内存也可能成功,结果取决于 vm.overcommit_memory:默认值 0 用启发式规则拒绝明显过大的申请,即一次申请超过物理内存与交换区的总和;1 总是允许;2 不允许所有进程申请的总量超过交换区加上一定比例(默认 50%)的物理内存。申请成功不代表能用完,例如在内存上限为 512 MB 的容器中(overcommit_memory 为 1),malloc 1024 GB 也会成功,但逐 MB 写入 2 GB 时,写到 384 MB 之后不久进程就被内核的 OOM Killer 杀死,退出码 137(实测,见[附录 A5](#a5-申请超过物理内存))。
例如用 mmap 申请 256 MB 的匿名内存后,进程的常驻内存(RSS)几乎不变;逐页写入一遍,触发 65536 次次要缺页(256 MB / 4 KB),RSS 增加到 257 MB,再写一遍就不再缺页(Ubuntu 24.04 实测,见[附录 A2](#a2-缺页次数))。这说明申请内存只是分配了虚拟地址,真正用到时才占用物理内存。
因此 top 中进程的 VIRT(VmSize)远大于 RES(VmRSS)通常是正常的:申请了还没写的内存、线程栈中还没用到的部分、映射了但没读到的文件都只占虚拟地址;需要关注的是 RES 是否持续增长。
所以申请比物理内存还大的内存也可能成功,结果取决于 vm.overcommit_memory:默认值 0 用启发式规则拒绝明显过大的申请,即一次申请超过物理内存与交换区的总和;1 总是允许;2 不允许所有进程申请的总量超过交换区加上一定比例(默认 50%)的物理内存。申请成功不代表能用完,例如在内存上限为 512 MB 的容器中(overcommit_memory 为 1),malloc 1024 GB 也会成功,但逐 MB 写入 2 GB 时,写到 384 MB 之后不久进程就被内核的 OOM Killer 杀死,退出码 137(实测,见[附录 A5](#a5-申请超过物理内存))。
fork 也靠缺页异常推迟复制,这称为写时复制(Copy-on-Write, COW):fork 时内核只复制页表,并把私有可写的页在父子两边都标为只读,哪一方先写,哪一方就触发缺页,内核把这一页复制到新的页框并改为可写,没被写过的页一直共享。接着上面的例子,fork 出的子进程把 256 MB 再写一遍,又触发 65536 次缺页,父进程中的值不受影响;换成父进程先写,同样触发约 65536 次缺页(见[附录 A2](#a2-缺页次数))。
<div align="center"> <img src="pics/mem-cow-fork.png" width="740px"/> </div><br>
## 页面置换算法
在程序运行过程中,如果要访问的页面不在内存中,就发生缺页中断从而将该页调入内存中。此时如果内存已无空闲空间,系统必须从内存中调出一个页面到磁盘对换区中来腾出空间。
缺页时如果内存已经没有空闲的页框,操作系统就要选一页换出:被修改过的页先写回磁盘(交换区或映射的文件),没修改过的页在磁盘上已有相同的内容,直接丢弃,以后用到时再读回。
页面置换算法和缓存淘汰策略类似,可以将内存看成磁盘的缓存。在缓存系统中,缓存的大小有限,当有新的缓存到达时,需要淘汰一部分已经存在的缓存,这样才有空间存放新的缓存数据。
页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。
页面置换算法决定换出哪一页,目标是让缺页率最低。这与缓存的淘汰策略是同一个问题,内存相当于磁盘的缓存。
### 1. 最佳
最佳置换算法(Optimal replacement algorithm, OPT)所选择的被换出的页面将是最长时间内不再被访问,可以保证获得最低的缺页率。
是一种理论上的算法,因为无法知道一个页面多长时间不再被访问。
最佳置换算法(Optimal replacement algorithm, OPT)所选择的被换出的页面将是最长时间内不再被访问,可以保证获得最低的缺页率。它需要知道将来的访问顺序,所以无法实现,只用来衡量其它算法。
举例:一个系统为某进程分配了三个物理块,并有如下页面引用序列:
@@ -88,7 +98,7 @@ MMU 查页表时发现页不在内存中,或者这次访问违反了页的权
7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1
```
开始运行时,先将 7、0、1 三个页面装入内存。当进程要访问页面 2 时,产生缺页中断,会将页面 7 换出,因为页面 7 再次被访问的时间最长。按这个规则走完整个序列共缺页 9 次;同样的序列和 3 个物理块,LRU 缺页 12 次,FIFO 缺页 15 次(逐步模拟,见[附录 A1](#a1-页面置换模拟))。
开始运行时,先将 7、0、1 三个页面装入内存。当进程要访问页面 2 时发生缺页,会将页面 7 换出,因为页面 7 再次被访问的时间最长。按这个规则走完整个序列共缺页 9 次;同样的序列和 3 个物理块,LRU 缺页 12 次,FIFO 缺页 15 次(逐步模拟,见[附录 A1](#a1-页面置换模拟))。
### 2. 最近最久未使用
@@ -96,7 +106,9 @@ MMU 查页表时发现页不在内存中,或者这次访问违反了页的权
为了实现 LRU,需要在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面是最近最久未访问的。
因为每次访问都需要更新链表,因此这种方式实现的 LRU 代价很高。
因为每次访问内存都要更新链表,这种实现的代价很高,通用的硬件也不提供支持,实际的系统用页表项中的访问位(R 位)来近似 LRU,例如下面的 NRU 和时钟算法。
例如有 5 个页框,访问序列如下:
```text
4,7,0,7,1,0,1,2,1,2,6
@@ -113,15 +125,15 @@ MMU 查页表时发现页不在内存中,或者这次访问违反了页的权
- 第 2 类:R=1,M=0;
- 第 3 类:R=1,M=1。
当发生缺页中断时,NRU 算法随机地从类编号最小的非空类中挑选一个页面将它换出。
缺页时,NRU 算法随机地从类编号最小的非空类中挑选一个页面将它换出。
NRU 优先换出已经被修改的脏页面(R=0,M=1),而不是被频繁使用的干净页面(R=1,M=0)。
因此最近没被访问的脏页(第 1 类)比最近访问过的干净页(第 2 类)先被换出。这是因为最近是否被访问更能说明之后还会不会用到,换出脏页只是多写一次磁盘。
### 4. 先进先出
先进先出(First In First Out, FIFO)算法选择换出的页面是最先进入的页面。
该算法会将那些经常被访问的页面换出,导致缺页率升高。
该算法会将那些经常被访问的页面换出,导致缺页率升高。例如 OPT 一节的序列,FIFO 缺页 15 次,比 LRU 多 3 次。
### 5. 第二次机会算法
@@ -139,7 +151,7 @@ FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问
## 分段
虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
前面介绍的虚拟内存采用的是分页技术,也就是将地址空间划分成固定大小的页,每一页再与内存进行映射。
例如一个程序有代码、全局数据、堆和栈几部分,其中堆和栈会动态增长。如果放在一维的地址空间中,就要预先给每一部分留出位置,某一部分增长到相邻部分的位置时就会发生覆盖。
@@ -149,13 +161,16 @@ FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问
## 段页式
程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既拥有分段系统的共享和保护,又拥有分页系统的虚拟内存功能。
程序的地址空间划分成多个拥有独立地址空间的段,每个段上的地址空间划分成大小相同的页。这样既能按逻辑上的段来组织地址空间、按段设置共享和保护,每个段又不必占用连续的物理内存。
地址由段号、段内页号和页内偏移三部分组成:先用段号查段表,找到这个段的页表,再用段内页号查页表得到页框号,拼上页内偏移就是物理地址。
## 分页与分段的比较
- 对程序员的透明性:分页透明,但是分段需要程序员显式划分每个段;
- 地址空间的维度:分页是一维地址空间,分段是二维的;
- 大小是否可以改变:页的大小不可变,段的大小可以动态改变;
- 碎片:分页不会产生外部碎片,但每个段或区域的最后一页通常用不满,形成内部碎片;分段按需分配大小不一的连续内存,段释放后会留下大小不一的空闲块,形成外部碎片;
- 出现的原因:分页主要用于实现虚拟内存,从而获得更大的地址空间;分段主要是为了使程序和数据可以被划分为逻辑上独立的地址空间并且有助于共享和保护。
需要注意的是,x86-64 的 64 位模式下分段基本不再使用,代码段、数据段的基址都按 0 处理(FS、GS 除外,用于线程局部存储),Linux 等系统只用分页来管理内存。
@@ -166,7 +181,7 @@ FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问
- 代码段、数据段和 BSS 在低地址,大小在程序加载时就确定了,BSS 存放未初始化的全局变量,加载时清零;
- 堆从低地址向高地址增长,malloc 申请小块内存时通过 brk 扩大堆,大块内存(glibc 的阈值初始为 128 KB)改用 mmap 单独映射;
- 栈从高地址向低地址增长,Ubuntu 默认最大 8 MB(ulimit -s);
- 栈从高地址向低地址增长,Ubuntu 默认最大 8 MB(ulimit -s);这是主线程的栈,其它线程的栈在内存映射区中单独分配,大小在创建线程时指定,例如 JVM 用 -Xss 设置,见 [Java 虚拟机栈](Java%20虚拟机.md#java-虚拟机栈);
- 共享库、mmap 映射的文件和大块内存放在堆和栈之间的内存映射区。
## 附录:实测
@@ -207,6 +222,23 @@ mmap 之后: 次要缺页 +0,RSS 1 MB
父进程中的值仍为 2
```
换成子进程只读、父进程在 fork 之后先写:
```c
pid_t c = fork();
if (c == 0) { sleep(2); printf("子进程读到 p[0]=%d\n", p[0]); _exit(0); }
for (size_t i = 0; i < size; i += page) p[i] = 3; // 父进程先写
for (size_t i = 0; i < size; i += page) p[i] = 4; // 再写一遍
```
```text
子进程读到 p[0]=2
fork 后父进程写一遍:次要缺页 +65537
父进程再写一遍:次要缺页 +0
```
父进程的两行在退出时才从缓冲区输出,所以排在子进程之后。
容器所在虚拟机的透明大页设置为 always,不调用 madvise 时内核用 2 MB 的大页映射,逐页写入只触发 128 次缺页(256 MB / 2 MB);Ubuntu 默认的设置是 madvise,不主动申请时使用 4 KB 的页,与上面的结果一致。
### A3 进程的地址空间布局