您的位置:首页 > 编程语言 > C语言/C++

动态存储器分配:内存动态分区分配方式的理解以及模拟(一)

2017-12-07 23:50 507 查看

1.动态存储器分配器的概念

讲在前面:这个和c语言中的malloc和free有关。主要是了解内存的动态分配方式。

在进程运行的时候,动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆。分配器将对视为一组大小不同的块(block)的集合来分配。每个块就是一个连续的虚拟存储器片(chunk),要么是已经分配的,要么是还没有分配的。已经分配的,显示的保留给应用程序使用(malloc得到一段内存)。空闲的,则是可以继续分配。一个已经分配的块保持已经分配的转态,直到被free掉,得以被堆重用。当已经分配的块一直没有被free,那么一直保持分配转态,这个就是内存泄漏了。

分配器有两种基本风格:

显示分配器:要求应用显示的释放已经分配的块,像C,C++语言采用的机制。

隐式分配器:分配器检测一个已经分配的块何时不再被程序所使用时,自动free掉。这个就是垃圾收集。

为什么钥匙用动态存储器分配?原因是一些程序在实际运行时,才会知道某些数据结构的大小。

分配器的要求和目标

要求:

处理任意请求序列

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

只使用堆:为了使分配器可以拓展,分配器使用的任何非标量数据结构都要保存到堆里。

对齐块:使得其可以保存任何类型的数据对象。

不修改已经分配的块。

2.实现的问题

空闲块组织:如何记录空闲块?

放置: 如何选择一个合适的空闲块来放置一个新分配的块。

分割:在将一个新分配的块放置到某个空闲快之后,如何处理这个空闲块中的剩余部分?

合并:如何处理一个刚刚被释放的块?

接下来,一个个问题解决。

2.1.1如何记录空闲块?

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



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

头部编码了这个块的大小,以及这个块是已分配还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零。这里不对对齐作深入研究。

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



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

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

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

2.1.2放置已分配的块

当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所有请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是
首次适配
下一次适配
最佳适配


* 首次适配是从头开始搜索空闲链表,选择第一个合适的空闲块。

* 下一次适配和首次适配相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。

* 最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

2.1.3分割策略

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

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

2.1.4合并空闲块

当分配器释放一个已分配块时,可能有其他空闲块于这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片,就是有许多可用的空闲块被切割成小的、无法使用的空闲块。 为了解决假碎片的问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并。合并的时间点可以选择立即合并或者推迟合并。这里需要注意的一点是,立即合并简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方式会产生一种的抖动,块会反复地合并,然后马上分割。

2.2显示空闲链表

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



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

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

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

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

2.2.2分离空闲链表

简单分离存储

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

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

分离适配

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

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

伙伴系统

作为分离适配的一种特例,伙伴系统中每个大小类都是2的幂。基本的思路是假设一个堆的大小为2m个字,我们为每个块大小2k维护一个分离空闲链表。其中0<=k<=m。请求块大小向上舍入到最接近的幂。最开始时,只有一个大小为2m个字的空闲块。

为了分配一个大小为2k的块,我们找到第一个可用的、大小为2j的块,其中k<=j<=m。

如果j=k,那么我们就完成了。否则,我们递归地而分割这个块,直到j=k。当我们进行这样的分割时,每个剩下的半块(伙伴)被放置在相应的空闲链表中。要释放一个大小为2k块,我们继续合并空闲的伙伴。当我们遇到一个已分配的伙伴时,我们就停止合并。

关于伙伴系统的一个关键事实是,给定地址和块的大小,很容易计算出它的伙伴的地址。例如,地址

xxxx…x00000

他的伙伴的地址为

xxxx…x10000

换句话说,一个块的地址和它的伙伴的地址只有一位不相同。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  编程 c语言 存储 内存