第四章讲的都是时间的协调——程序/线程先运行谁?冲突怎么办?

这一节讲空间的协调——程序放在哪里,放不下怎么办?

程序如何布局,动态,隔离,虚拟内存,交换算法

内存的分配

程序内的分配

虚拟地址空间布局

  • 逻辑布局(设计)

    • 代码段(Code):存放指令。
    • 数据段(Data):存放全局变量等。
    • 堆与栈(Heap/Stack):这是动态变化的区域。
  • 运行时布局(实现)
    当程序真正跑起来,操作系统会给它分配实际的虚拟地址。

    • 只读段(.text/.rodata):放在高地址,比如 0x08000000 开始。
    • 读写段(.rwdata/.bss):放在中间。
    • 堆(.heap):这是最大的段,也是我们要讲的主角。程序运行中所有的动态内存申请(比如 malloc),都是从这里切出来的。
    • 栈(.stack):通常比较小,MB量级,用来存函数调用的局部变量。

为什么要“动态”分配?——对抗不确定性

  • 功能的不确定性:用户可能只用软件的10%功能,如果一开始就把100%的内存分给它,剩下90%就浪费了。
  • 阶段的不确定性:同一个功能,在不同阶段需要的内存也不一样。
  • 对象的不确定性:处理100条数据和100万条数据,占用的空间天差地别。

结论:必须按需分配,这就是动态内存分配存在的意义。

如何评价“在线算法”?——竞争比

理想情况(离线分配):假设可以预知未来,得到的最优方案

现实情况(在线分配):无法预知未来,当下能实现的最优方案

我们希望分配的堆正好满足需求。

竞争比 = S_ALG / S_OPT=在线空间/离线空间

如何分配内存

流程:分配-使用-释放

固定分区法——拿最大需求分配

内部碎片:要1MB,给100MB,剩下的99MB就白白浪费了,这就是内部碎片

多固定块法——桶分配

做不同大小的块池子。比如一个池子装1MB的块,一个池子装100MB的块。

不知道程序到底需要多少个1MB,多少个100MB。

外部碎片:如果100MB的池子空了,哪怕1MB的池子堆成山,你也无法满足100MB的请求。这叫外部碎片——内存总量够,但因为不连续、不匹配,就是没法用。

动态分区法

按需求的最大公约数切割内存,这样不会有内部碎片,但是会产生外部碎片

首次、最好、最坏适配

首次适配法(First-Fit)

逻辑:从头开始找,找到第一个能装下的空闲区就分配。

优点:简单、快。

缺点:容易在内存开头留下很多小碎片,而且不保留大块内存给未来的大请求。

最好适配法(Best-Fit)

逻辑:遍历所有空闲区,找那个“最紧凑”、最刚好能装下的。

优点:看起来最节省,试图保留大块内存。

缺点:会产生大量无法利用的极小碎片(“劫小济大”),而且遍历所有空闲区非常慢(O(n))。

最坏适配法(Worst-Fit)

逻辑:反其道而行之,找最大的空闲区来分配。

优点:试图避免产生极小碎片,保留中等大小的块。

缺点:很快就把大块内存消耗光了,未来遇到大请求时就傻眼了(“劫大济小”)。

简单分配策略的共性问题

  • 遍历开销大:每次分配都要遍历空闲列表,程序跑久了,列表越来越长,速度越来越慢。
  • 策略单一:把所有请求混在一起分配,大小请求互相干扰。
  • 粒度问题:太粗有内部碎片,太细有管理开销。

程序间的分配

  1. 虚张声势(Over-allocation):很多程序为了保险,会申请比实际需求大得多的内存。
  2. 心中无数(Unknown Size):有些程序(比如处理大数据的AI模型)根本不知道自己最后需要多少内存。给少了,程序会崩溃;给多了,又是浪费。

☆ 考点 内外部碎片

内部碎片(分多了用不完) 外部碎片(太碎了拼不起来)

程序内内部碎片

定义:已经分配给程序的内存块,但程序实际上用不到,被“浪费”在块内部的空间。

程序内外部碎片

定义:系统中空闲的内存满足需求,但由于内存不连续,无法满足大块连续内存的请求。

程序间的内部碎片
  • 定义:指操作系统已经分配给某个程序,但该程序逻辑上无法利用的内存。
程序间的外部碎片
  • 定义:操作系统手里还有空闲内存,但因为这些内存不连续,无法满足某个程序的大块连续内存请求。

进程

权能:由管理者下发的许可证

保护域 :许可证限制的空间,赋予进程合法操作资源,限制非法操作资源,实现隔离。

类比线程,是空间的分配对象。

进程是—种特殊的保护域,其特殊在它是常见保护域中最小的—种。

进程控制块(PCB)

☆ 考点 静态与动态——可执行文件与进程

一句话记住:exe 是硬盘上的 “程序文件”,进程是内存里 “正在跑的程序实例”。

核心区别
  1. 存在位置不同

    • exe:硬盘 / 磁盘上的静态文件(.exe)
    • 进程:内存中运行的动态实体
  2. 状态不同

    • exe:静止的代码和数据
    • 进程:活动的,占用 CPU、内存、句柄等资源
  3. 数量关系

    • 一个 exe 可以启动多个进程(比如开多个微信、多个浏览器)
    • 一个进程 只对应一个正在运行的程序
  4. 生命周期

    • ​​​​​exe:长期存在,删了才没

    • 进程:启动才有,关闭就消失

进程与线程与指令流

线程通过在进程里运行, 将CPU时间转化为对地址空间的—个访问操作次序 ,从而实现应用程序的功能。

进程是一个资源空间,不具备执行能力,运行的是进程中线程中的指令流。

进程可以不包含任何线程,而是等线程迁移进来

线程在进程中运行,且可以在多个进程中穿行,共享—份时间预算

关系
  • 一对一关系(最常见)

    • 模式:一个进程对应一个线程。
    • 本质:一个内存空间对应一个CPU执行流。
  • 一对多关系(现代主流)

    • 模式:一个进程对应多个线程(多线程进程)
    • 本质:一堆程序指令流在同一块内存上用,这样非常方便快速访存。

如何实现一个进程——内存隔离

【对应第三章 内存管理的进化史——减少内存浪费】

隔离的作用:操作系统给每个程序提供一个独立的虚拟地址空间,也就是进程,防止交叉访问

权能:由管理者下发的许可证

保护域 :许可证限制的权限空间,赋予进程合法操作资源,限制非法操作资源,实现隔离。

进程是—种特殊的保护域,其特殊在它是常见保护域中最小的—种。

如何实现内存隔离

分段

把程序的逻辑地址转换成物理地址,由段式内存管理单元(S-MMU)维护一个段表。

核心逻辑:基址 + 偏移

  • 段号:告诉MMU这是哪个段(代码段?数据段?)。
  • 段基址:段在物理内存里的起始位置。
  • 虚拟地址(偏移量):在这个段里跑了多远。
  • 公式:物理地址 = 段基址 + 偏移量。

流程:

每次访问内存,它都要做三次检查:

  1. 段存在吗?(查段号有没有)
  2. 越界了吗?(偏移量有没有超过段长度?)
  3. 权限对吗?

段表放哪里?

放CPU寄存器:

提供一组寄存器存放段表,进程切换时需要取出新进程的段表,拷贝到寄存器中。

优点:无需访问外部

缺点:无法支持比寄存器组数量更多的段

放内存:

CPU里只放一个指针指向它

优点:这样能支持无限的段

缺点:要访问两次内存,首先访问段表,再访问内存本身。

用快表缓存:

工作集:程序在一段时间内,频繁访问的内存

☆ 考点 局部性:
  • 时间局部性:如果你访问了一个数据,大概率过一会儿还会访问它。
  • 空间局部性:如果你访问了一个数据,大概率接下来会访问它旁边的数据

工作流程

  1. CPU给出一个虚拟地址。
  2. 查快表:快表里有没有这个段号?
    • 命中:直接拿到物理基址
    • 没命中:去内存里查段表,查到了之后,顺便把这个条目“复制”一份到快表里,以备下次使用。

怎么把数据填进快表?

软件填充

产生内存管理异常,进入处理程序后,操作系统读取段表,将段描述符填充到CPU段寄存器组

  • 优点:灵活。段表长什么样,替换策略自定义。
  • 缺点太慢! 每次触发“异常”,从用户态切到内核态,开销大。
硬件填充

CPU内置专门电路表查找器。硬件发现快表没数据,自己去内存里查,填进去

  • 优点极快。纯硬件电路操作,没有软件切换的开销。
  • 缺点:不灵活。段表的格式被硬件写死了。

快表的维护与副作用

清空TLB Flush

切换线程 重新填充

删除TLB Shootdown

不清空 仅删一条

锁定TLB Lockdown

硬件填充下由软件填充并锁定的表项。这些表项会无条件保留

替换

分页

再把内存切为4MB的小块,减少碎片。

把程序的逻辑地址转换成物理地址,由页式内存管理单元(P-MMU)维护一个页表。

核心逻辑:基址 + 偏移

  • 段号:告诉MMU这是哪个页(代码段?数据段?)。
  • 段基址:段在物理内存里的起始位置。
  • 虚拟地址(偏移量):在这个段里跑了多远。
  • 公式:物理地址 = 段基址 + 偏移量。

流程:

每次访问内存,它都要做三次检查:

  1. 段存在吗?(查段号有没有)
  2. 越界了吗?(偏移量有没有超过段长度?)
  3. 权限对吗?

但是这样页表就太大了。

页表很大怎么办?如何组织

页表是稀疏

我们需要一种特殊的数据结构来存储,使得复杂度O(1)

基数树/桶排序

原理

  1. 把虚拟地址切分成几段(比如一级页号、二级页号、页内偏移)。
  2. 一级页表(母桶):只负责索引“区域”。如果某个区域完全没用,一级页表里对应的项就是空的,根本不需要创建二级页表。
  3. 二级页表(子桶):只有在真正用到某个区域时,才分配二级页表。

外部碎片消失,但是内部碎片变多

Q:什么是超级页,为什么要引入超级页?
为了兼顾两者,现代CPU支持“超级页”(比如2MB或1GB的大页),专门给大块连续内存使用,既减少了页表项,又提高了TLB效率。

单层存储模型

计算机上所有的存储器都被抽象成一种逻辑模型。
数据存储的逻辑模型向最上层的存储器靠近;数据存储的物理特性向最下层的存储器靠近
存哪由OS决定

分页解决了内存“怎么放”(碎片)的问题,但没有解决内存“不够放”(容量)的问题。

☆ 考点 请求分页的流程

物理内存有限。当所有物理页占满,而新页面又需要进来时,该怎么办?

页也具有时刻局部性。

为什么要用请求分页:1. 突破物理内存的“硬限制”(容量问题)

在普通的分页管理(也叫纯分页)中,虽然内存被切成了固定大小的页,但依然要求程序在运行前,必须把所有页面一次性全部装

入物理内存

  • 痛点:假设你的物理内存只有 8GB,但你突然想玩一个 20GB 的 3A 大作,或者同时打开几十个网页和大型软件。在纯分页机制下,因为 20GB > 8GB,程序根本启动不了,系统会直接报错“内存不足”。
  • 请求分页的解法:它引入了虚拟内存的概念。系统不再强求一次性把 20GB 全塞进 8GB 的内存里,而是“按需加载”(懒加载)。程序启动时只把当前要用的几页代码(比如主界面)加载进来,剩下的 99% 先留在硬盘上。等你真正玩到某个关卡时,再把那个关卡的数据页从硬盘“请求”调入内存。

缺页异常的处理:

0.懒加载:操作系统在启动程序时,根本不把代码全读进内存。页表里先留空,标记为“不在内存”。

1.触发缺页异常,暂停执行

2.处理缺页异常,从用户态进入内核态

3.写回换出页,如果满了要腾出空间,如果没被修改过就扔掉,否则要存入

4.读入换入页:从外存读入刚刚请求的页

5.修改页表,将存在页设为1

6.重新执行,PC不变

怎么做?

☆ 考点 页面置换算法

理想算法:最长前向距离(LFD/OPT) 最久要用

替换“最晚才会被再次用到”的页面

现实算法
  • 先进先出(FIFO)

    • 逻辑:谁先进来的,就先踢谁。
    • 缺陷:太傻了。最早进来的可能是主函数代码,一直被用到,结果被踢出去了。
  • 最近最少使用(LRU):最久未用

    • 逻辑:利用“时间局部性”,认为“过去很久没用的,未来大概率也不用”。踢掉那个“最久没被访问”的页面。
    • 优点:比FIFO聪明,符合程序运行规律。
    • 缺点:硬件实现成本高(需要记录每个页面的访问时间戳)。
  • 最不频繁使用(LFU)

    • 逻辑:踢掉那个“访问次数最少”的页面。
    • 缺陷:有些页面刚开始访问频繁,后来不用了,但计数器很高,导致它赖在内存里不走。

内存管理的“终极形态”——内存映射

讲完了怎么管内存,这几页提出了一个更宏大的愿景:消除内存和硬盘的界限

  • 直接把硬盘上的文件映射到进程的虚拟地址空间
  • 怎么做
    • 进程直接对着一段虚拟内存地址进行读写。
    • 硬件发现这页不在内存(缺页),触发中断。
    • 操作系统一看,哎,这页对应的是硬盘上的文件啊,于是直接从硬盘把文件内容读入内存。
    • 结果:程序员感觉像是在操作内存,实际上是在操作文件!

好处

  • 高效:不用多次拷贝。
  • 共享:多个进程可以映射同一个文件,共享内存。

考点☆ 具体怎么做?

硬件机制:

缺页异常
当页表中没有某个页时,产生操作系统可截获并处理的异常。
访问位A
当某个页被访问,其页表的访问位会被硬件置位。
脏位D
当某个页被实际写入(内容遭修改),其页表的脏位会被硬件置位。

给 FIFO 加点“智慧”——利用访问位 二次机会法CLOCK

淘汰页面时找到一个访问位A不为0的页面,其余A=1都置为0.进入下一轮逃杀

利用脏位

修改过的页面需要保存再换入,时间长

改进的clock算法 三次机会法

先清除D脏位再清除A访问位

承接上文:
上一页我们讲到了 CLOCK 算法以及通过访问位 A 和脏位 D 来优化置换。
本部分核心:
但在真实的操作系统(如 Linux)中,内存管理远比单纯的 CLOCK 算法复杂。因为内存里的页面不仅仅是“程序的数据”,还有“共享的库”、“刚退出的缓存”、“文件映射”等。
这几页 PPT 将告诉你:操作系统是如何利用“共享”和“缓存”来减少缺页,又是如何利用“多队列”来给不同类型的页面定优先级的。

这组 PPT 的逻辑非常连贯,它将我们之前讨论的“页面置换算法”(怎么踢人)进一步升维,结合了“页面共享”(怎么省人)和“页面缓存”(怎么留人),最终形成了一个现代操作系统复杂的内存管理策略。

以下是这几页 PPT 的串联讲解,逻辑上承接了之前的“页面置换算法”:


逻辑衔接:从“单一算法”到“复杂生态”

承接上文:
上一页我们讲到了 CLOCK 算法(二次机会法)以及通过访问位 A 和脏位 D 来优化置换。
本部分核心:
但在真实的操作系统(如 Linux)中,内存管理远比单纯的 CLOCK 算法复杂。因为内存里的页面不仅仅是“程序的数据”,还有“共享的库”、“刚退出的缓存”、“文件映射”等。
这几页 PPT 将告诉你:操作系统是如何利用“共享”和“缓存”来减少缺页,又是如何利用“多队列”来给不同类型的页面定优先级的。


第一页:省钱(共享)与 捡漏(缓存)

核心逻辑:
在讨论怎么“踢”页面之前,先讨论怎么少踢甚至不踢

页面共享
  • 原理:既然很多程序都用同样的代码,而且是只读的,无需在内存中存储多次
  • 做法:让这些进程的页表指向同一块物理内存
  • 好处:极大地节省了物理内存。
页面缓存
  • 原理:当一个进程退出了,它占用的内存(特别是那些加载好的动态库)通常不会立刻被清空。
  • 做法:操作系统把这些页面标记为“备用”或“缓存”。如果过一会儿又启动了一个新进程(或者重启了刚才那个进程),发现需要的库已经在缓存里了,直接拿来用,连硬盘都不用读了。
  • 好处:极大地提高了启动速度,减少了强制缺页异常

第二页:置换策略的进化——谁该先走?

核心逻辑:
有了共享和缓存,置换算法就不能“一视同仁”了。

  • 直觉
    • 共享页面(比如系统库):非常重要,一旦被踢出,所有用到它的进程都会缺页,代价极大。所以尽量不踢。
    • 缓存页面(比如刚退出的程序):本来就是闲置资源,没人引用了。所以优先踢它。

第三页:双队列 PLRU——给页面分等级

核心逻辑:
为了实现上面的策略,Linux 使用了双队列(甚至多队列)机制。

  • 机制
    • 活跃队列:存放正在频繁使用的页面。
    • 不活跃队列:存放偶尔使用或暂时不用的页面。
  • 流程
    1. 新进来的页面先放入活跃队列。
    2. 如果页面在活跃队列里很久没被访问(A位=0),它会被降级到不活跃队列。
    3. 置换发生时,只在不活跃队列里找受害者
    4. 如果不活跃队列里的页面突然又被访问了,它会被立刻提拔回活跃队列(这就避免了频繁置换抖动)。

第四页:多级反馈队列——Linux 的真实面貌

核心逻辑:
Linux 的内核代码(vmscan.c)比双队列更细致,它维护了一个分层级的页面链表

  • 分层逻辑
    1. 最活跃页面(比如正在运行的程序代码)。
    2. 比较活跃页面
    3. 不甚活跃页面(比如文件映射)。
    4. 最不活跃页面(比如无用的缓存)。
  • 动态调整:页面会根据访问频率在这些队列之间“升级”或“降级”。
  • 目的:确保共享页面关键代码永远留在最上面的队列,而缓存页面永远在最下面等着被回收。

第五页:Swap 文件 vs 分区 & 内存压缩

核心逻辑:
当页面真的被踢出内存后,它们去哪了?

存放位置
  • Windows:使用分页文件,即在文件系统里建一个大文件。优点是灵活(想多大建多大),缺点是文件系统本身有开销,稍慢。
  • Linux:传统上使用交换分区,即硬盘上专门划出一块连续区域。优点是直接读写磁盘,速度快;缺点是不灵活(分区大小固定)。
内存压缩
  • 新思路:有些页面数据重复性很高(比如全是 0,或者全是某种结构)。
  • 做法:与其把页面写到慢速的硬盘,不如在内存里把它压缩一下存着。
  • 优势:CPU 解压速度 >>> 硬盘读取速度。用 CPU 换 I/O,整体性能反而提升了。

内存映射设备

硬件设备也可以被映射到内存地址。访问这些地址就像访问内存一样。

页面置换的其他内容

Belady异常:
在某些资源分配策略下,增加资源总量反而导致性能下降和效率降低的现象。
在这里是指, 对于某些替换算法, 允许的物理页数量越大,缺页率反而升高。

栈式性质:LFD、 LRU都含有“最”字,  满足;FIFO则没有这个性质。

抖动:分配给进程的物理页数少于其工作集大小时,进程会频繁地发生缺页中断

分配策略的动态性
  • 静态分配 一个进程的页数量可以是静态决定的也可以是动态调整的。如果工作集的大小已知,只要将页数量设置为那个固定值就好了;如果出现阶段性的需求,就需要动态决定进程需要多少页框。
  • 动态分配
置换策略的全局性
  • 局部置换:只在自己进程的页面里踢人。
  • 全局置换:可以从任何进程的页面里踢人。

进程是什么——用于隔离空间的东西

如何实现一个进程——内存隔离

如何内存隔离——分段分页

有什么后果——页表太大——用基数树和桶排序

解决了如何存,要解决如何加载——请求分页

如何实现请求分页——懒加载-触发中断暂停执行-执行中断服务程序进入内核态-根据算法换出页,如果有修改需要修改外存对应的副本,否则直接丢弃-换入所需页-将页表对应项改为1-重新执行PC不变

具体算法——LFD 最久要用 LFU最低频次 LRU最久没用 FIFO

balady异常 资源阅读 性能越拉
抖动 分配的页不够
栈式性质 LFU LRU
分配动态性 进程的页数量可以是动态的,如果不同阶段有不同需求就调整
置换局部性 在哪里换
内存映射文件   用请求分页技术,将要访问的外存块通过缺页中断装入 访问外存和访问内存一样

  1. 分段解决了隔离,但带来了外部碎片。
  2. 分页通过“固定大小块”彻底消灭了外部碎片。
  3. 页表膨胀是分页的副作用。
  4. 基数树(多级页表)利用地址空间的稀疏性,完美解决了页表存储问题。
  5. TLB依然是加速地址转换的核心硬件。

进程的具体实现

系统中的进程总览:按来源划分

按照操作系统中的进程的来源,大致可以将它们分为如下三类

按照操作系统中的进程的特权,大致可以将它们分为如下两类。

这几页PPT把我们带入了内存管理的“硬件落地”环节。

刚才我们讲完了纯理论的“分段”、“分页”和“请求分页”,但操作系统最终是要跑在CPU上的。x86架构(也就是我们电脑里最常用的CPU)是如何把这些理论变成现实的?这中间经历了从32位到64位的巨大跨越。

x86-32的“缝合怪”时代——段页式管理

  • 为了兼容:不能把老的分段机制删了,否则以前的DOS程序就跑不了了。
  • 结果段页式管理
    • 先分段:逻辑地址通过段表转换成“线性地址”。
    • 后分页:线性地址再通过页表转换成“物理地址”。

x86-64的“拨乱反正”——长模式

  • 长模式:这是64位CPU的工作模式。
  • 废除分段:在长模式下,分段机制被基本禁用。段基址强制为0,段长强制无限大。
    • 结果有效地址 = 线性地址。分段这一步被直接绕过了!
  • 扩展分页:地址变长了,页表结构也变复杂了。
    • 多级页表:32位只需要2级页表,64位因为地址空间太大,需要4级页表(PML4 -> PDPT -> PD -> PT)甚至5级。

x86-64的“潜规则”——规范地址

虽然64位理论上有 2^64的地址空间,用不到。x86-64架构制定了一个“规范地址”的标准。

  • 地址空间被劈成两半
    • 低地址(用户空间):从 0x0000... 开始,最高位是0。这是给应用程序用的。
    • 高地址(内核空间):从 0xFFFF... 开始,最高位是1。这是给操作系统内核用的。

这样做的好处

  1. 硬件检查简单:CPU只需要检查最高几位就能判断地址是否合法,不需要查页表,速度极快。
  2. 安全隔离:用户程序只能用低地址,内核用高地址,天然隔离。

第一幕:总开关与导航仪(CR0, CR3, CR4)

在操作系统启动时,CPU需要知道该用什么模式工作。这些设置都写在控制寄存器里。

CR0:最基础的总开关
  • PE位(第0位):开启保护模式。这是现代操作系统的门槛,只有开了它,才有用户态和内核态之分。
  • PG位(第31位):开启分页机制。如果不开这个,CPU就只能分段,不能分页。
  • 逻辑:先开保护模式,再开分页。
CR3:进程切换的核心
  • 作用:它存着页目录基址。简单说,CR3就是“地图的地图”的起点。
  • 进程切换的本质:当操作系统要从进程A切换到进程B时,最关键的一步就是修改CR3寄存器。把CR3指向进程B的页表基址,CPU瞬间就换了一套地址翻译规则,A和B的内存就彻底隔离了。
CR4:功能的扩展包
  • PAE(第5位):物理地址扩展。早期32位CPU只能寻址4GB内存,开了这个位,配合分页机制,就能访问超过4GB的物理内存。
  • PGE(第7位):开启全局页。这是为了解决TLB刷新问题的(后面会细讲)。
  • LA57(第12位):开启57位地址支持。这决定了是用4级页表还是5级页表。

第二幕:五级地图结构(PML5 -> PML4 -> PDPT -> PGD -> PT)

既然开启了64位分页,CPU是如何一步步把“虚拟地址”翻译成“物理地址”的?这就是多级页表的结构。

PML5(第5级):顶层目录
  • 只有在开启了LA57(57位地址)时才存在。
  • 它负责管理巨大的地址空间。
PML4(第4级):次顶层
  • 标准4级页表的起点。
PDPT(第3级):关键转折点
  • 这里有一个非常重要的位:PS位(Page Size)
  • 如果PS=0:继续往下查下一级(PGD)。
  • 如果PS=1:停止! 这里直接映射一个1GiB的超级页。不用再查下级表了,省事!
PGD(第2级):
  • 同样有PS位。
  • 如果PS=1:这里直接映射一个2MiB的超级页
PT(第1级):最底层
  • 这里没有PS位了,因为已经到了尽头。
  • 它负责映射最小的4KiB标准页

逻辑总结:地址翻译就像查字典,一级一级往下查。但是,如果在高层(PDPT或PGD)就发现“这有一大块连续内存”,就可以直接通过超级页机制停下来,不用查到底。

允许映射 1GiB 大超级页的好处

简单来说,好处就是“省”“快”

  • 省 TLB 条目(最重要的原因)
    • 条目少 缺页触发少 命中率高 更快
  • 省页表内存
    • 用小页页表多,占存储空间且多级查找时间长
  • 减少缺页中断
    • 访问新内存时,硬件不需要层层查表,直接命中超级页,减少了查表的开销。
  • PML4/PML5 不支持:是因为它们对应的地址空间太大(512GiB/256TiB),物理内存给不起,应用程序用不上

第三幕:性能优化机制(超级页与全局页)

硬件设计者为了提升性能,在页表项里加入了很多“小心机”。

超级页:空间换时间
  • 问题:如果所有内存都用4KB的小页,那页表会非常大,查起来很慢,TLB(快表)也容易满。
  • 解决:引入超级页(1GB或2MB)。
  • 好处
    1. 省空间:一个表项就能顶成千上万个小页,页表层级变少,甚至不需要下一级表。
    2. TLB命中率高:TLB里存一个超级页的条目,就能覆盖巨大的内存范围。
  • 代价:超级页必须占用物理上连续的内存。如果内存碎成渣,就分配不出超级页了(外部碎片问题)。
全局页:避免无效刷新
  • 背景:每次进程切换(修改CR3),CPU通常会把TLB(快表)里的内容全清空,因为新旧进程的地址映射不一样。
  • 问题:但是,内核代码是所有进程共用的!每次切换进程都清空内核的TLB条目,下一秒内核又要重新去查页表,这很浪费。
  • 解决全局页
    • 在页表项里设置G位
    • 告诉CPU:“这个页面(通常是内核代码)是全局的,切换进程时别删它的TLB条目!”
    • 这大大提高了系统调用的效率。

操作系统内核层:负责“分段、分页、请求分页”

这一层关注的是“宏观的虚拟地址到物理地址的映射”以及“内存保护和隔离”

  • 分段:把内存按逻辑功能(代码段、数据段)划分。
  • 分页:把虚拟地址空间和物理内存都切成固定大小的块(页),建立映射关系。
  • 请求分页:这是分页的一种优化。只有程序真正用到某一页时,才把它从硬盘调入内存。

它们解决的问题是

  • 如何让程序拥有连续的虚拟地址空间,而不管物理内存是否连续?
  • 如何保护进程A不访问进程B的内存?
  • 如何运行比物理内存还大的程序?

📦 用户层/库层:负责“内存分配”

这一层关注的是“如何高效地把大块的内存切分给变量使用”

  • 动态分区法伙伴系统dlmallocslab分配器等,这些才是真正的“内存分配”算法。
  • 它们通常在一个进程内部工作。它们会先向操作系统申请一大块内存(比如几兆),然后自己在这个大池子里切分,把小块分给mallocnew

进程内内存分配

第一幕:解决进程切换的性能损耗——ASID

☆ 考点 在TLB中引入ASID的好处

在 TLB(Translation Lookaside Buffer,转换后备缓冲区)中引入 ASID(Address Space ID,地址空间标识符)主要是为了解决多任务操作系统中进程切换时的性能瓶颈。

简单来说,它的核心好处是极大地提高了进程切换的效率,避免了 TLB 的频繁清空。

以下是引入 ASID 的具体好处和原理:

1. 避免进程切换时的 TLB 全局刷新(核心优势)

  • 没有 ASID 时:每个进程都有自己独立的虚拟地址空间。切换进程时,为防止错误地址映射,必须清空TLB。导致进程刚开始运行时,TLB 是空的,每一次内存访问触发缺页,拖慢。
  • 引入 ASID 后:给每个进程分配ASID。当进程切换发生时,CPU 只需要更新当前的 ASID 寄存器,不需要清空 TLB
    • MMU 在查找 TLB 时,不仅比对虚拟地址,还会比对 ASID。
    • 如果 TLB 中的条目 ASID 与当前进程不匹配,MMU 会直接忽略该条目
    • 这意味着进程 B 可以利用 TLB 中原本属于它自己的旧条目,或者填入新条目

第二幕:进程内内存分配的原则与挑战

解决了硬件层面的地址翻译效率问题,接下来要看软件层面(用户程序)如何向系统申请内存。

指导原则

  • 高效:少浪费空间(减少碎片)。
  • 快速:少浪费CPU时间(分配算法要快)。
  • 局部性:尽量把相关的内存放在一起,减少缺页和TLB Miss。

挑战

  • 简单的动态分区法(如首次适应、最佳适应)很难同时满足“快”和“省”。因此,我们需要更高级的分配器——dlmalloc

第三幕:dlmalloc的核心机制——分块与分级

dlmalloc是Linux glibc中malloc的实现

空闲块的结构

  • 双向循环链表:为了快速插入和删除空闲块。
  • 首尾各有一个“大小”字段
    • 头部:用于查找和分割。
    • 尾部:为了快速合并。当释放一个块时,可以通过尾部的大小字段,直接找到前一个块的头部,判断是否能合并,而不需要遍历整个链表。这是O(1)合并的关键。

分级管理(Bin System)

  • 最小粒度(16字节):小于16字节的请求,直接分配16字节。这是为了避免管理过小块的开销(管理数据比数据本身还大)。
  • 多个队列(Bins)
    • 小队列(16-512字节):按8字节等间距划分。目的是精确匹配,减少内部碎片。
    • 大队列(576字节以上):按指数间距划分。因为大块内存请求少,不需要分得太细,指数划分可以减少队列数量,加快查找。

Q1: 为何使用双向循环链表?

  • 双向链表(Bidirectional)
    • 核心目的:高效空闲块合并
    • 原理:当释放一个内存块时,必须检查相邻的前后块是否空闲。双向链表允许我们在 O(1)时间内找到逻辑上的前驱和后继。
  • 循环链表(Circular)
    • 核心目的简化边界处理,提高代码执行效率。

Q2: 空闲块的大小为何在首尾各出现一次?尾部那次可以不要吗?

  • 核心目的:为了在释放内存时,快速定位前一个块的头部
  • 原理
    • 假设当前释放块的地址是 P。要检查前一个块(地址 < P)是否空闲,我们需要找到前一个块的头部。
    • 前尾紧邻当前块的头。通过读取当前块头部前4-8(前一块尾)算出前一个块的起始地址
  • 结论不能去掉。如果没有尾部的大小字段,寻找前一个块将需要遍历整个堆

Q3: 为何放任比最小粒度小的内存块成为内部碎片?

  • 空间开销权衡
    • 如果太小,元数据占用的空间可能比实际有效数据还要大
  • 管理成本
    • 管理极小的块会导致空闲节点激增

Q4: 为何要设计多个队列,且小队列和大队列的规则不同?

  • 核心目的加速查找 并 平衡碎片
  • 分队列:分配时直接去对应的队列查找,避免了遍历整个空闲链表。
  • 规则不同(小队列 vs 大队列)
    • 小队列(16~512字节):采用等间距(如每 8 字节一个队列)。
      • 原因:小块内存分配极其频繁,对性能要求极高。等间距可以保证查找速度极快。
    • 大队列(>512字节):采用指数间距(如 2 的幂次)。
      • 原因:大块内存分配较少,且大小差异巨大。指数间距可以用极少的队列覆盖巨大的地址空间。

Q5: 小队列的间距选择和最小队列的大小选择需要权衡什么因素?

  •  权衡因素内部碎片 vs 队列数量 队列间距 最小队列长度

第四幕:伙伴分配器——一种特殊的分级策略

PPT中提到的“伙伴分配器”其实是Linux内核中用于管理物理内存的算法,但这里用它来解释dlmalloc的分级思想。

核心思想

  • 内存块大小都是2的幂次方。
  • 分配:如果请求610字节,就找1024字节的块,切一半给512队列,剩下512再切...直到分出1024。
  • 释放:检查“伙伴”(地址相邻、大小相同的块)是否空闲,如果空闲就合并,递归向上。

伙伴分配器的时间复杂度是多少(考虑分配和释放)?

  • 分配和释放都是O(log n),但在实践中,由于队列数量有限,可以优化到接近O(1)

为什么不用担心碎片?

  • 内部碎片:低于某个最小大小的空闲块将作为内部碎片
  • 外部碎片:通过分级队列和快速合并机制,dlmalloc能有效地将空闲块合并成大块,减少外部碎片。

问题三        小队列的间距选择和最小队列的大小选择需要权衡什么因素?
小队列:权衡因素:碎片率 vs. 链表数量
大队列:权衡因素:查找速度 vs. 可接受的浪费比例


问题四        考虑到内存分配器的这种特性,我们写程序需要注意什么?

对齐分配 减少分配频率 加快分配速度 注意释放顺序 尽量批量操作


第五幕:向系统要内存——sbrk与堆的增长

用户程序的内存(堆)不是一开始就全部分配好的,而是按需向操作系统申请。

程序断点(Program Break)

  • end_brk指针指向堆的顶部。
  • 当dlmalloc内部的空闲块不够用时,就调用sbrk()系统调用,将end_brk向上移动,向操作系统申请更多虚拟内存。

为什么end_brk一般只上移不下移?

  • 性能:下移end_brk需要操作系统回收页表,开销大。
  • 局部性:刚释放的内存很可能马上又被分配,保留在堆里可以复用。
  • 荒野保护:只有最高地址的块才能扩张,所以分配器会尽量先用完低地址的空闲块,推迟向系统申请新内存,避免end_brk不必要地增加。

第六幕:最后的优化——局部性与缓存

为了追求极致性能,dlmalloc还做了一些“投机取巧”的优化。

就近分配

  • 如果上次分配的块还有剩余空间,优先从这里分配。这保证了时间上连续的分配在空间上也连续,提高了缓存命中率。

操作缓存

  • 延迟合并:释放内存时,不立即合并相邻空闲块,而是等到空闲块积累到一定程度再合并。因为刚释放的块很可能马上又被分配,立即合并是浪费CPU。
  • 预分配:分配小块时,一次切出多个小块,放入缓存,下次直接从缓存拿。

总结

这几页PPT完整地展示了从硬件地址翻译优化(ASID)用户态内存分配策略(dlmalloc),再到系统调用接口(sbrk)的全链路内存管理逻辑。

核心思想就是:用空间换时间,用局部性换性能,用分级管理平衡碎片和速度

进程内:ASID 如何分配:分块与分级(六道题) 伙伴分配器的原理 如果不够怎么办:向上级要内存sbrk

进程间内存分配

为了防止某个用户或程序把系统资源耗尽,Linux 提供了 limits.conf 这样的配置文件,给每个用户或进程划定“红线”。

物理内存总量有限,当无法执行sbrk后就会执行OOM-killer

杀谁:内存大 优先级低 数量少

性能优化与超级页

 1.超级页
  • 原理:既然小页(4KB)管理起来太累,TLB 表项不够用,那不如直接用大页
  • 代价:快,但容易产生内部碎片
  • 实现:启动时就划出一块地专门做大页。
2. 内存紧缩
  • 问题:随着系统运行,大页会被零散的小数据填满
  • 解决:把正在使用的小页搬运到一起,腾出完整的大页空间。

物理内存的区域特性

32 DMA NORMAL是可以线性映射的 highmen区域必须选择映射

64未都可以映射到内核态

物理内存的不对称特性

非对称访问    Non-Uniform Memory Access,NUMA

  • UMA 时代:所有 CPU 访问内存的速度是一样的(通过北桥)。
  • NUMA 架构:现在每个 CPU 插槽都连着本地内存。
    • 本地访问:CPU 访问自己连着的内存,速度极快。
    • 远程访问:CPU 访问其他 CPU 连着的内存,需要跨总线,速度慢很多。
  • 分配策略:内存分配器会把内存分配在使用它的本地节点上,这就是所谓的“就近原则”

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐