重生之我是操作系统(七)----内存管理(上)

简介

一个操作系统,要实现对内存的管理,需要实现如下几个核心目标:

  1. 分配与回收
    高效分配,减少内存碎片和内存利用率
  2. 空间扩充
    内存虚拟化,让进程享受近乎无限的内存地址。
  3. 存储隔离
    保证各个进程之间不会越界访问。
  4. 高效通信
    支持进程间内存共享,提高交换效率。

分配与回收

先来分享一个前置知识:

1.内部碎片

分配给进程的内存区域中,有些部分是空闲没有用上。

比如SQL Server , .NET CLR 。 他们自己内部一套实现了内存管理,因此要先预申请大量内存。

2.外部碎片

内存中某些空闲分区由于太小难以利用上

连续内存分配

为进程分配的必须是一个连续的内存空间

单一连续分配

long long years ago ,单任务系统时代(MS-DOS),由于是单任务操作系统。所以内存中只会有一道用户程序。该程序独享整个用户态空间。

  1. 优点
    实现简单:直接采用覆盖技术扩充内存,且不需要进程隔离
    无外部碎片:就一个程序能有啥碎片?
  2. 缺点:
    有内部碎片

固定分配

随着多任务操作系统的兴起,单一连续分配已经不能满足需求。因此发展成,讲整个用户态空间划分为若干个固定大小的分区,每一个分区中只运行一个进程。形成了最早,最原始的多进程内存管理。

  1. 优点
    实现简单,不会产生外部碎片。
  2. 缺点
    -. 当程序太大,分区表中所有分区都无法满足,此时就需要采用覆盖技术来解决。但会降低性能。
    -. 会产生内部碎片,一个10M大小的分区,只放了一个9M的程序,还有1M空间被浪费。
    -. 分区固定,缺乏灵活性

虽然也有将要分区表划分为不同大小的分区,以此提交灵活性。但依旧是固定分配的范畴。

动态分配

为了解决固定分配的痛点,操作系统又发展出动态分区分配策略。

这种分配方式不会预先划分内存区域,而是在进程装入内存时,根据进程大小动态建立分区

  1. 优点
    不会产生内部碎片,外部碎片可以通过压缩来缓解。
  2. 缺点
    实现复杂,内存回收时要考虑前后相邻,合并的情况。

动态分配算法

首次适应(First Fit)

  1. 原理
    低地址/起始地址开始搜索,选择满足大小的第一个空闲空间。分割后分配(剩余部分仍为空闲块)。
  2. 优点
    快速,无需遍历所有空闲块。
  3. 缺点
    在低地址区域留下大量碎片

【100kb,200kb,50kb】,申请150,选择第二个,变成【100kb,50kb,50kb】

邻近适应(Next Fit)

由首次适应演变而来

  1. 原理
    上一次分配结束的位置开始搜索,找到第一个足够大的空闲块(类似首次适应)
  2. 优点
    平衡内存碎片分配位置,较少低地址碎片的堆积,且搜索效率高于首次适应
  3. 缺点
    只是平衡内存碎片分配的位置,但碎片问题依旧存在。且会提前消耗大空闲分区。

最佳适应(Best Fit)

  1. 原理
    搜索所有空闲块,选择满足大小的最小空闲块。分割后分配(剩余部分仍为空闲块)。
  2. 优点
    内存利用率最高,会有更多大分区被保留下来,能够满足大进程需求
  3. 缺点
    产生大量极小内存碎片,且难以利用。

【100kb,200kb,50kb】,申请49,选择第三个,变成【100kb,200kb,1kb】

最坏适应(Worst Fit)

为了解决最佳适应的缺点,又衍生出最坏适应

  1. 原理
    搜索所有空闲块,选择满足大小的最大空闲块。分割后分配(剩余部分仍为空闲块)。
  2. 优点
    避免产生微小碎片
  3. 缺点
    提前消耗大内存块,等大进程来时无法满足。

【100kb,200kb,50kb】,申请15个10,选择第二个,变成【100kb,50kb,50kb】,此时再申请200,无空间可用。

总结

算法 核心策略 优点 缺点 典型场景
首次适应 第一个足够大的块 分配速度快,实现简单 低地址碎片多 实时性要求高的系统
最佳适应 最小足够大的块 内存利用率高 微小碎片多,效率低 内存紧张、小内存请求多
最坏适应 最大空闲块分割 减少微小碎片 大内存块可能提前耗尽 中等大小内存请求为主
邻近适应 从上一次位置开始搜索 分配均衡,减少碎片堆积 性能中等 通用多任务系统

tips: 一个反直觉的知识

由于最佳适应/最坏使用需要检索整个空闲块,所以为了提高搜索效率。往往要对空闲块进行排序操作。而邻近适应在实际应用中,并没什么卵用,反而影响大进程的调度。

因此综合下来,反而是首次适应的综合效率更好。

动态回收算法

边界标记法

每个内存块头部记录块大小和分配状态,尾部存储前一个块的头部地址(或状态),释放时通过前后块地址判断是否相邻空闲:

前向合并:后一个块是空闲块,合并为一个大空闲块。

后向合并:前一个块是空闲块,合并为一个大空闲块。

双向合并:前后块均为空闲块,合并为一个更大的块。

非连续内存分配

为进程分配的是一些分散的空间

从上面可以看出,连续内存分配的有如下几个痛点:

  1. 外部碎片严重,内存利用率低
    动态分配在申请和释放过程中,会产生大量不连续的空闲块。需要定期"压缩"内存,而压缩会消耗大量CPU时间。
  2. 开销大
    动态分配算法中(首次适应,最佳适应,最坏适应)等算法需要遍历整个空闲块,时间复杂度为O(n).
    释放算法中,需要合并相邻的空闲块,逻辑复杂且耗时。
  3. 不支持虚拟化
    动态分配要求是对物理内存的的规划,需要对物理内存进行动态分配。因此无法运行大于物理内存的程序。
  4. 管理困难
    因为进程地址是单一连续区域,所以难以对程序进行单独的隔离与管理(如代码段、数据段)。

为解决上述问题,又进化出了非连续的分配管理方式。

页(Paging)

原理 :将物理内存划分为固定大小的页框(Page Frame,4kb),而程序的逻辑地址空间页划分为相同大小的页(Page,4KB)。通过页表(Page Table)完成页到页框的映射,并允许页在物理内存中的不连续。

  1. 优点
    完全消除外部碎片,支持虚拟内存地址
  2. 缺点
    存在页内碎片(最后一页可能未填满),页表本身也占用内存

页表转换过程没有图示如此简单,有兴趣可以自行查找资料。

硬件对页的优化

为了加速页表的转换,硬件也使出了多种手段。

  1. TLB
    缓存近期访问的页表项,将地址转换从两次降为一次,(查页表+访问物理内存)=>直接访问物理内存。
  2. 多级列表
    将页表分页存储,减少内存占用。例如,x86-64采用五级页表,支持64位虚拟地址空间。

页管理方式很好,但依旧难以对程序进行单独的隔离与管理(如代码段、数据段)。

段(Segmentation)

原理 :将程序的逻辑模块(如代码段,数据段,栈)分组,每个段在逻辑上连续,但在物理上不连续,通过段表(Segment Table)来实现转换。

  1. 优点
    符合程序逻辑的结构,很方便的按照分组来实现共享和保护。
    比如:多个进程共享同一段代码段。代码段设置只读,数据段设置可写。
  2. 缺点
    可能产生外部碎片,因为段与动态分配很像,不确定大小,所以在分配过程中难免产生碎片。

段也可以引用TLB机制与多级段表来优化效率。

段页(Segmentd Paging)

原理 :结合分页与分段的优势,先将程序划分为段,再将每个段划分为页。通过段表和页表的多级映射实现地址转换。

  1. 优点
    兼顾逻辑分段和物理分页的效率,支持虚拟内存和内存保护
  2. 缺点
    空间占用大,地址转换效率低,需要三次(查段表+查页表+访问物理内存)。

现代操作系统的设计应用

  1. Linux
    采用分页方案,使用四级页表(64 位系统),支持大页(Huge Pages,如2MB/1GB)减少页表开销。
  2. Windows
    使用段页方案,使用简化版的段与页,同样使用四级页表与大页。

空间扩充(虚拟化)

为什么我们需要虚拟化内存?

在上述的连续分配/非连续内存分配方案中,进程一次性全部载入内存后才能开始运行。很多暂时用不到的数据也会长期占用内存,导致内存利用率不高。

举个例子,有一个经典游戏叫<三男一狗>,他的容量大小将近100g,如果按照目前的方式,内存容量完全不够。

虚拟化的理论支持:局部性原理

  1. 时间局部性
    如果执行了进程中的某条指令,那么不久后这条指令很有可能再次执行,如果某个数据被访问过,也很有可能再次被访问。

  2. 空间局部性
    一旦程序访问了某个存储单元,那么其附近的存储单元页很有可能被访问。

    int i;
    int a[100];//空间局限性,变量/存储单元基本上都是集中定义在一起,连续存放

    for(i=0;i<100;i++){ //时间局部性,程序中充斥着大量的循环
    a[i]=i;
    }

基于局部性原理,在程序载入内存时。

  1. 可以将程序中先用到的部分先载入内存,暂时用不到的暂不加载
  2. 当所访问的信息不存在时,由操作系统继续从外存(硬盘)加载到内存(缺页故障)。
  3. 当内存不够时,由操作系统负责将用不到的内存swap到外存(硬盘)

在操作系统的管理下,对于程序而言,它看到的是一个近乎无限大的内存空间,这就是虚拟内存技术。

如何实现虚拟内存?

虚拟内存有以下三个主要特征:

  1. 多次性
    无需在程序运行时一次性全部装入,允许分成多次调用。
  2. 对换性
    无需一直常驻内存,在合适的情况下,允许swap到外存(硬盘)。
  3. 虚拟性
    从逻辑上扩充了内存的容量。"欺骗"进程不再关注实际内存大小。

因为虚拟内存多次性的存在,如果采用连续分配的方式,就实现不了了。因此,虚拟内存的实现需要建立在'离散分配'的内存管理方式。

没错,正是在下!非连续内存分配!
少林功夫+足球=大有搞头!非连续内存分配+虚拟内存=目前主流操作系统的选择

因此,在非连续内存分配的基础上,再由操作系统添加缺页故障纠错(多次性)与swap(对换性)功能,即可实现。

如何实现添加缺页故障纠错(请求分页)

  1. 请求页表
    与之前提到过的页管理相比,为了实现添加缺页故障纠错,操作系统需要知道每个页是否已经载入内存,如果没有,需要存储外存的位置。
    那么,我们对之前页的模型图稍加扩充,就变成了下面的这个样子,该页表也被称为请求页表
  1. 缺页中断机构
    在请求分页中,当要访问的页不存在时,操作系统便会产生一个缺页中断,然后系统故障Fault,搜索中断描述表(Interrupt Descriptor Table),找到对应的中断处理程序。
    此时缺页的进程会陷入阻塞,放入阻塞队列,调页完成后再将其唤醒,放回就绪队列。

调页:如果内存中有空闲块,则为进程分配一个空闲块,并更新请求页表。

如果内存空间不足,则由页面置换算法选择一个淘汰的页,若页被修改过,则将其写回外存。如果修改过,直接丢弃。完成后更新请求页表。

页面置换算法

页面置换算法是虚拟内存管理的核心技术,用于在物理内存不足时,选择将哪些页面从内存中置换到外存,以腾出空间加载新页面

  1. OPT, Optimal Algorithm

    原理:选择未来最长时间不被访问的页面,理论上缺页率最低

    优点:性能最佳

    缺点:无法实现,因为操作系统无法预知程序的行为

  2. FIFO, First-In-First-Out

    原理:置换最早进入的页面

    优点:实现简单,用一个链表记录进入队列即可实现

    缺点:产生belady异常,且与程序运行规律不相符,因为最早进入的页面,往往最经常被使用。

比如你代码中的第一个变量,其生命周期大概率跟随整个方法。

  1. LRU, Least Recently Used

    原理:淘汰最近最久未使用的页面,当缺页时,逆向扫描最后出现调页的页号就是被淘汰的页

    优点:贴合局部性原理,性能接近OPT

    缺点:实现复杂,需要硬件支持

  2. Clock Algorithm,CLOCK

    在上面介绍的几种算法中,OPT性能最好,但无法实现。FIFO实现简单,但性能查。LRU算法接近OPT,但开销大,需要硬件支持。

    而CLOCK算法则比较均衡。

    原理:为每个页设置一个访问位(R),并将整个页表链接起来形成一个环形队列。首次扫描:若页面 R 位为 1,清 0 并跳过;若为 0,置换该页面。若未找到可置换页面,重复扫描

    优点:实现复杂度低,比 FIFO 更高效

    缺点:仅用访问位,可能置换频繁访问但近期未被访问的页面

  3. Modified Clock Algorithm

    该算法是对CLOCK的改进,增加修改位(M),优先置换未被修改且未被访问的页面(减少写回外存的开销),第一轮扫描:找 R=0 且 M=0 的页面(最优候选),若未找到,第二轮扫描:找 R=0 且 M=1 的页面(需写回外存)。

    过程中清除 R 位,避免重复扫描

    优点:结合访问频率和修改状态,减少 I/O 操作(优先置换干净页面,无需写回)

    缺点:精度低于LRU算法

算法 核心思想 优点 缺点 典型应用
OPT 未来最远访问 理论最优 无法实现 性能基准
FIFO 最早进入内存 实现简单 可能置换活跃页面,Belady现象 早期系统(如简单嵌入式)
LRU 最近最少使用 贴近局部性,性能优秀 实现复杂(需硬件支持) 通用操作系统(Linux、Windows)
Clock/改进版 访问位+修改位近似LRU 平衡性能与实现复杂度 精度低于LRU 实际系统(如Android)

现代操作系统(如 Linux)通常采用 LRU 改进算法(如反向映射、大页支持),并结合硬件加速(TLB缓存地址转换),在缺页率和性能之间实现高效平衡。

熟悉Redis的朋友肯定对此不陌生