操作系统之动态存储器分配

1.用户级存储器映射
之前我们介绍过关于程序加载的详细内容,我们知道在其加载执行之前要对程序进行存储器映射,Unix进程可以使用mmap函数来创建新的虚拟存储器区域,并将对象映射到这些区域。

mmap函数要求内核创建一个新的虚拟存储器区域,最好是从start开始的地址,并将文件描述fd标识对象的一个连续的片映射到这个新的区域。连续的对象片大小为length,从距文件开始处偏移量为offset的地方开始。Prot指定了新创建的虚拟页面的访问位权限(之前提到过的虚拟页面的读写、执行权限)。最后,falgs字段描述的是被映射对象的类型,可以用来标记匿名对象,私有、写时拷贝对象和共享对象。调用mmap函数成功后就会返回对应新区域的地址。

和mmap相对应,munmap函数用来删除虚拟存储器的区域:

Munmap函数删除从虚拟地址start开始的,由接下来length字节组成的区域,对已删除区域的引用会引起段错误。

2.动态存储器分配
我们可以通过mmap和munmap来创建和删除虚拟存储器区域,但对开发人员来说使用起来并不方便,况且没有很好的移植性,所以提出了使用动态存储分配器来管理进程空间中的堆区域。

动态存储分配器维护着一个进程的虚拟存储器区域,称为堆。堆是从低位地址向高位向上增长的,对于每个进程,内核维护着一个brk,它指向堆的顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟存储器片,要么是已分配的,要么是空闲的。已分配的显示地保留为供应应用程序使用。空闲块可用来分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是存储分配器隐式执行的,它们的都是显式的来分配存储块的,不同之处在于由哪个实体来负责释放已分配的块。

  a)  显式分配器,要求显式的释放已分配的块。如C标准库中的malloc和free,C++中的new和delete操作符。

  b)  隐式分配器,要求分配器检测一个已分配的块何时不再被程序使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(Grabage collector)。如JAVA语言就依赖于类似分配器。

下面我们看下malloc和free的实现是如何管理一个C程序的16字的小堆的。每个方框代表一个4字节的字。粗线标出的矩形对应于已分配块(有阴影)和空闲块(无阴影),初始时,堆是由一个大小为16个字的、双字对齐的、空闲块组成的。
在这里插入图片描述

a) 程序请求一个4字的块,malloc的响应是:从空闲块的前部切出一个4字的块,并返回一个指向这个块的第一个字的指针p1

b) 程序请求一个5字的块,malloc的响应是:从空闲块的前部分配一个6字的块,返回指针p2,填充的一个额外字是为了保持空闲块是双字边界对齐的。

c) 程序请求一个6字的块,而malloc就从空闲块的前部切出一个6字的块。返回指针p3

d) 程序释放在b中分配的那个6字的块。需要注意的是,在调用free返回之后,指针p2仍然指向被释放的块,在它被一个新的malloc调用重新初始化之前不能在程序中再使用p2.

e) 程序请求一个2字的块。在这种情况下,malloc分配在前一步中释放了的块的一部分,并返回指向新块的指针p4.

3.分配器的要求和目标
显式分配器必须在一些相当严格的约束条件下工作:

(1) 处理任意请求序列。一个应用可以有任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配的块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。

(2) 立即响应请求。分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求。

(3) 只使用堆。分配器使用的任何非标量数据结构必须保存在堆里。

(4) 对齐块。分配器必须对齐块,使得它们可以保存任何类型的数据对象。在大多数系统中,分配器返回的块是8字节(双字)边界对齐的。

(5) 不修改已分配的块。分配器只能操作或者改变空闲块。特别地,一旦块被分配了,就不能修改或者移动它了。

4.碎片
造成堆利用率很低的主要原因是一种称为碎片的现象,当虽然有未使用的存储器但不能用来满足分配请求时,就会发生这种现象。有两种形式的碎片:内部碎片和外部碎片。

内部碎片在一个已分配块比有效载荷大时发生,即分配了一块内存空间,实际没用用到那么多。

外部碎片空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。

隐式空闲链表

任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。大多数分配器将这些信息嵌入在块本身。如下:
在这里插入图片描述

上图的结构中,一个块是由一个字的头部、有效载荷,以及一些可能的填充组成。

头部编码了这个块的大小,以及这个块是已分配还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。因此,我们只需要存储块大小的29个高位,释放剩余的3位来编码其他信息。例如,假设我们有一个已分配额块,大小为24(0x18)字节。那么它的头部将是

0x00000018 |0x1=0x00000019

那么,根据上面的块格式,我们可以将堆组织为一个连续的已分配块的空闲块序列。

我们称这种结构为隐式空闲链表是因为空闲块是通过头部中的大小字段隐含连接着的

分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块集合。这里,我们设置了已分配位而大小为0的终止头部来代表链表中的结束块

隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系。

放置已分配的块

三种分分配策略:
1:首次适配策略: 从头开始搜索空闲链表,选择第一个合适的空闲块。
2:下一次适配策略:不是从起始处开始搜索,而是从上一次查询结束的地方开始。
3:最佳适配策略:检查每一个空闲块,选择匹配所需请求大小的最小空闲块。

分割空闲块

一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。
一个选择是用整个空闲块。虽然这种方式较简单而快捷,但是缺点是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。

选用部分
然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。

获取额外的堆内存

如果分配器不能为请求块找到合适的空闲块会发生什么呢?
一个选择是通过合并那些在存储器中物理上相邻的空闲块来创建一些更大的空闲块。然而,如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会向内核请求额外的存储器。分配器将额外的存储器转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。

合并空闲块

当分配器释放一个已分配块时,可能有其他空闲块于这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片,就是有许多可用的空闲块被切割成小的、无法使用的空闲块。如下图,我们释放掉9-37中分配的块,结果是两个相邻的空闲块,每个有效负载都为3个字。因此,接下来一个对4个字的有效载荷的请求就会失败,即使两个空闲块的合计大小足够大,可以满足这个请求。

在这里插入图片描述

为了解决假碎片的问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并。分配器一般可以选择立即合并,也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选择推迟合并,也就是等到某个稍晚的时候再合并空闲块。需要特别注意的是,立即合并简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方式会产生一种的抖动,块会反复地合并,然后马上分割。

带边界标记的合并

分配器如何实现合并呢?假设我们称想要释放的块为当前块。那么合并下一个空闲块很简单而且高效。当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个块是否是空闲的。如果是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内被合并。

但我们该如何合并前面的块呢?给定一个带头的隐式空闲链表,唯一的选择将是搜索整个链表。记住前面块的位置,直到我们打到当前块。使用隐式空闲链表,这意味着每次调用free需要的时间都于堆的大小呈线性关系。即使使用更复杂精细的空闲链表组织,搜索时间也不会是常数。

Knuth提出一种聪明而通用的技术,叫做边界标记,允许在常数时间内进行对前面块的合并,这种思想,是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如下所示:
在这里插入图片描述

如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当期块开始位置一个字的距离。那么,分配器释放当前块时存在四种可能情况:

(1) 前面的块和后面的块都是已分配的

(2) 前面的块是已分配的,后面的块是空闲的

(3) 前面的块是空闲的,而后面的块是已分配的

(4) 前面的和后面的块都是空闲的。

下图,展示了这四种情况合并的过程:

在这里插入图片描述

边界标记帮我们解决了空闲块合并的问题,对于不同类型的分配器和空闲链表组织都是通用的,然而,他还存在一个潜在的缺陷。它要求每个块都保持一个头部和脚部,在应用程序中操作许多小块时,会产生显著的存储器开销。例如,在一个图形应用中反复的调用malloc和free来动态创建和销毁图形节点,并且每个图形节点都只要求两个存储器字,那么头部和脚部将占用每个已分配块的一半空间。

6.显式空闲链表
隐私空闲链表为我们提供了一种简单的介绍一些基本分配器概念的方法。然而,因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不合适的。而一种更好的方式是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。
在这里插入图片描述

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是常数,这取决于我们所选择的空闲链表中块的排序策略。

  1. 一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块位置放在链表的开始出。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

  2. 另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的存储器利用率,接近最佳适配的利用率。

一般而言,显示链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小。也潜在地提高了内部碎片的程度。

分离的空闲链表

一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。一种流行的减少分配时间的方法,通常称为分离存储,就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般思路是将所有可能的块大小分成一些等价类,也叫做大小类。

有很多方式来定义大小类,例如,我们可以可以根据2的幂来划分块大小:

{1},{2},{3,4},{58},……{10252048},{20494096},{4096∞}。

分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果它不能找到合适的块与之匹配,他就搜索下一个链表,以此类推。

简单分离存储

对于简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。例如,如果某个大小类定义为{17~32},那么这个类的空闲链表全由大小为32的块组成。

为了分配一个给定大小的块,我们需要检查相应的空闲链表。如果链表非空,我们简单地分配其中第一块的全部。空闲块是不会分割以满足分配请求的。如果链表为空,分配器就向操作系统请求一个固定大小的额外存储器片,然后将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表,要释放一个块,分配器只要简单地将这个块插入到相应的空闲链表的前部。

很重要

分离适配

使用分离适配,分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。

为了分配一个块,我们必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果我们找到了一个,那么我们可以分割它,并将剩余的部分插入到适当的空闲链表中。如果我们找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表没有合适的块,那么就向操作系统请求额外的堆存储器,从这个新的堆存储器中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。

伙伴系统

在实际应用中,经常需要分配一组连续的页框,而频繁地申请和释放不同大小的连续页框,必然导致在已分配页框的内存块中分散了许多小块的空闲页框。这样,即使这些页框是空闲的,其他需要分配连续页框的应用也很难得到满足。
为了避免出现这种情况,Linux内核中引入了伙伴系统算法(buddy system)。把所有的空闲页框分组为11个块链表,每个块链表分别包含大小为1,2,4,8,16,32,64,128,256,512和1024个连续页框的页框块。最大可以申请1024个连续页框,对应4MB大小的连续内存。每个页框块的第一个页框的物理地址是该块大小的整数倍。
假设要申请一个256个页框的块,先从256个页框的链表中查找空闲块,如果没有,就去512个页框的链表中找,找到了则将页框块分为2个256个页框的块,一个分配给应用,另外一个移到256个页框的链表中。如果512个页框的链表中仍没有空闲块,继续向1024个页框的链表查找,如果仍然没有,则返回错误。
页框块在释放时,会主动将两个连续的页框块合并为一个较大的页框块。
Buddy算法的优缺点
1)缺点:要求块是2的幂可能导致显著的内存碎片。算法中有一定的浪费现象,伙伴算法是按2的幂次方大小进行分配内存块,当然这样做是有原因的,即为了避免把大的内存块拆的太碎,更重要的是使分配和释放过程迅速。但是他也带来了不利的一面,如果所需内存大小不是2的幂次方,就会有部分页面浪费。有时还很严重。比如原来是1024个块,申请了16个块,再申请600个块就申请不到了,因为已经被分割了。
2)另外拆分和合并涉及到 较多的链表和位图操作,开销还是比较大的。

Buddy(伙伴的定义):

这里给出伙伴的概念,满足以下三个条件的称为伙伴:
1)两个块大小相同;
2)两个块地址连续;
3)两个块必须是同一个大块中分离出来的;

Buddy算法的释放原理:

内存的释放是分配的逆过程,也可以看作是伙伴的合并过程。当释放一个块时,先在其对应的链表中考查是否有伙伴存在,如果没有伙伴块,就直接把要释放的块挂入链表头;如果有,则从链表中摘下伙伴,合并成一个大块,然后继续考察合并后的块在更大一级链表中是否有伙伴存在,直到不能合并或者已经合并到了最大的块(222222222个页面)。

7.垃圾收集

垃圾收集器(gargagecollector)是一种动态存储器分配器,它自动释放程序不再需要的已分配块。这些块称为垃圾。自动回收堆存储的过程叫做垃圾收集。在java 虚拟机中就使用了类似的机制,应用显式分配堆块,但是从不显式地释放他们。垃圾收集器定期识别垃圾块,并相应地调用free,将这些块放回到空闲链表中。

垃圾收集器将存储器视为一张有向可达图,该图的节点被分成一组根节点和一组堆节点。每个堆节点对应于堆中的一个已分配块。有向边p->q意味着块p中的某个位置指向块q中的某个位置。根节点对应于这样一种不在堆中的位置,他们中包含指向堆中指针。这些位置可以是寄存器、栈里的变量,或者是虚拟存储器中读写数据区域内的全局变量。
在这里插入图片描述

当存在一条从任意根节点出发并到达p的有向路径时,我们说节点p是可达的。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色就是维护可达图的某种表示,并通过释放不可达节点将它们返回给空闲链表,来定期地回收它们。

像C/C++程序的收集器是保守的,其根本原因是C/C++不会用类型信息来标识存储器位置,因此,像int或者float这样的标量可以伪装成指针。类似这样语言的收集器通常不能维持可达图的精确表示。不过我们可以考虑将一个C程序的保守的收集器加入到已存在的malloc包中,如下图示:
在这里插入图片描述

无论何时需要堆空间,应用都会用通常的方式调用malloc,如果malloc找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器识别出垃圾块,并通过free函数将它们返回给堆,关键的思想是收集器替代应用去调用free。当对收集器的调用返回时,malloc重试,视图发现一个合适的空闲块。如果还是失败了,那么它就会向操作系统要求额外的存储器。最后,malloc返回一个执行请求块的指针或者返回一个空指针。

参考:
https://blog.csdn.net/zhouwei1221q/article/details/48242535
https://blog.csdn.net/u012960981/article/details/23672969

猜你喜欢

转载自blog.csdn.net/u014303647/article/details/88752856