1、动态内存分配概述
动态内存分配器维护着一个进程的虚拟内存区域, 称为堆(heap)
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的, 要么是空闲的
分配器有两种风格,都要求显示的分配块,但是其不同之处在于如何释放块。
- 显式分配器要求显示的释放已分配的块,如C中的free函数
- 隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,就释放块。
使用动态内存分配的目的
有时候直到程序运行的时候,才知道某些数据结构的大小。如果我们硬编码对于维护和充分利用设备来说都是灾难。一种更好的方法是在运行时, 在已知了n的值之后, 动态地分配这个数组。
2、malloc和free函数
1、malloc函数
1. malloc 的功能
malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。
malloc不初始化它返回的内存。
calloc将分配的内存初始化为零
要改变一个以前已分配块的大小,可以使用realloc函数
2. malloc如何分配内存
- 可以使用mmap和unmap显示的分配和释放内存。
- 可以使用sbrk函数,通过将内核的brk指针增加incr来扩展和收缩堆
2、free函数
ptr参数必须指向一个从malloc、calloc或者realloc获得的分配块的起始位置。如果不是,那么free的行为就是未定义的。
3、碎片问题
碎片现象就是指虽然当前有足够的可利用的空间,但是不能用来满足分配请求的现象。分为内部碎片和外部碎片。
- 内部碎片是指已分配的块比有效载荷大的情况。
- 外部碎片是指当空闲的内存合计起来能够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。
4、分配器的实现问题
主要关注四个问题
- 空闲块如何组织
- 如何选择一个合适的空闲块来分配给请求(放置问题)
- 如何处理一个块的内部碎片(分割问题)
- 如何处理一个刚释放的块(合并问题)
1. 隐式空闲链表
1. 结构
我们使用链表将堆组织成了一个连续的已分配块和空闲块的序列。每个块的结构如下
每个块包含了三个部分
- 块头
包含了这个块是已分配的还是空闲的信息,以及块的大小(包括头部及填充)。如果我们强加一个双字的对齐约束条件, 那么块大小就总是8的倍数, 且块大小的最低3 位总是零。因此, 我们只需要内存大小的29 个高位, 释放剩余的3 位来编码其他信息。 - 有效载荷(只有已分配的块才有)
- 一些额外的填充
需要填充有很多原因。比如, 填充可能是分配器策略的一部分, 用来对付外部碎片。或者也需要用它来满足对齐要求。
2. 优缺点
优点:简单
缺点:任何操作开销大
2. 放置已分配的块
当应用请求一个k字节大小的块时,分配器便去查看空闲的链表,找到一个合适大小的块来放置。而如果有多个可以选择的块时,分配器会按照放置策略进行选择。常见的策略有:1. 首次适配 2.下一次适配 3. 最佳适配
1. 首次适配
首次适配从头开始搜索空闲链表, 选择第一个合适的空闲块。
- 优点是它趋向于将大的空闲块保留在链表的后面
- 缺点是它趋向于在靠近链表起始处留下小空闲块的"碎片“, 这就增加了对较大块的搜索时间
2. 下一次适配
下一次适配和首次适配很相似, 只不过不是从链表的起始处开始每次搜索, 而是从上一次查询结束的地方开始。
- 想法:如果我们上次在某个空闲块里已经发现了一个匹配, 那么很可能下一次我们也能在这个剩余块中发现匹配。
3. 最佳适配
最佳适配检查每个空闲块, 选择适合所需请求大小的最小空闲块。
3. 分割空闲块
一旦分配器找到一个匹配的空闲块, 它就必须做另一个策略决定, 那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷, 但是主要的缺点就是它会造成内部碎片。如果放置策略趋向于产生好的匹配, 那么额外的内部碎片也是可以接受的。然而, 如果匹配不太好, 那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块, 而剩下的变成一个新的空闲块。
4. 获得额外的内存
如果分配器不能为请求块找到合适的空闲块将发生什么呢?
- 一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块。
- 如果这样还不够,分配器就会通过调用sbrk函数, 向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中, 然后将被请求的块放置在这个新的空闲块中。
5. 合并空闲块
1. 假碎片
当分配器释放一个已分配块时, 可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象, 叫做假碎片(fault fragmentation), 就是有许多可用的小的相邻的空闲块,我无法被直接使用。
2. 合并相邻空闲块
为了解决假碎片问题, 任何实际的分配器都必须合并相邻的空闲块, 这个过程称为合并(coalescing)。有两种
- 立即合并
也就是在每次一个块被释放时, 就合并所有的相邻块。但是可能会造成一种形式的抖动,就是刚合并完又有请求分割,然后由合并分割。 - 推迟合并
也就是等到某个稍晚的时候再合并空闲块。例如, 分配器可以推迟合并, 直到某个分配请求失败后扫描整个堆, 合并所有的空闲块。一般会选择推迟合并
3. 带边界标记的合并
1. 调整块结构
我们把当前想要释放的块成为当前块。那么合并下一个块的时候很简单,只需要将下一个块的大小加到当前块的头部即可。而如何合并前面的块呢?按照现在的块的结构,需要线性的时间才能合并。于是我们需要对块进行改造。
在每个块的结尾处添加一个脚部(footer, 边界标记), 其中脚部就是头部的一个副本。如果每个块包括这样一个脚部, 那么分配器就可以通过检查它的脚部, 判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
这样释放当前块的时候,可能存在下面四种情况。
2. 缺点及改进
在应用程序操作许多个小块时,拥有头部和脚部会产生显著的内存开销。我们发现只有在前面的块是空闲时, 才会需要用到它的脚部。如果我们把前面块的已分配/空闲位存放在当前块中多出来的低位中, 那么已分配的块就不需要脚部了, 这样我们就可以将这个多出来的空间用作有效载荷了。不过请注意, 空闲块仍然需要脚部。
6. 显示空闲链表
对于通用分配器,隐式链表是不适合的。一种更好的方法是将空闲块组织为某种形式的显式数据结构。这里介绍使用双向链表组织的方式。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率, 接近最佳适配的利用率。
7. 分离的空闲链表
一种流行的减少分配时间的方法成为分离存储。就是维护多个空闲链表,每个链表中的空闲块的大小大致相等。一般的思路是将可能的大小块分成一些等价类。也叫大小类。例如可以按照2的幂来划分块大小。
分配器维护着一个空闲链表数组, 每个大小类一个空闲链表, 按照大小的升序排列当分配器需要一个大小为n的块时, 它就搜索相应的空闲链表。如果不能找到合适的块与之匹配, 它就搜索下一个链表, 以此类推。
有两种基本的分离存储的方法 1. 简单分离存储 2. 分离适配
1. 简单分离存储
每个链表中的块大小是固定的。对于每个大小类,每个块会被划分为该大小类的最大范围的大小(假设一个大小类的范围为{20-32},那么每个块的大小就是32)。
- 分配
如果找到了合适的块,会把所有的部分全部分配给这个块。不会分割。
如果链表没有剩余空间,就去申请新的额外内存片,并将该片组织诚信的空闲链表。 - 释放
释放时简单的将该块放置到链表前侧即可。
2. 分离适配
每个链表里的块大小不是固定的,但是都会在同一个区间里面。
- 分配
为了分配一个块, 必须确定请求的大小类, 并且对适当的空闲链表做首次适配, 查找一个合适的块。 如果找到了一个, 那么就(可选地)分割它, 并将剩余的部分插入到适当的 空闲链表中。 如果找不到合适的块, 那么就搜索下一个更大的大小类的空闲链表。 如此重复, 直到找到一个合适的块。 如果空闲链表中没有合适的块, 那么就向操作系统请求额外的堆内存, 从这个新的堆内存中分配出一个块, 将剩余部分放置在适当的大小类中。 - 释放
要释放一个块, 我们执行合并, 并将结果放置到相应的空闲链表中。
3. 伙伴系统
伙伴系统(buddy system)是分离适配的一种特例, 其中每个大小类都是2 的幂。
1. 基本思路
假设一个堆的大小为 2 m 2^m 2m个字,系统为每个块大小 2 k 2^k 2k维护一个分离空闲链表。其中k<= m。对于请求块向上舍入到最近的2的幂,初始时只有一个大小为 2 m 2^m 2m个字的空闲块。
- 分配
现在来了请求需要 2 k 2^k 2k个字(已经向上舍入了)。于是我们需要找到第一个可用的大小 2 j 2^j 2j个字的块,其中k <= j。
如果k == j,那么就分配完毕了。否则我们递归的二分割这个块,直到j==k。而分割剩余的半块(也叫伙伴),我们将它放置到相应的空闲链表中。 - 释放
要释放一个块的时候,我们继续合并其空闲的伙伴,直到遇到一个已分配的伙伴。
之所以可以很方便的合并是因为给定地址和块的大小,很容易计算出他的伙伴的地址。例如, 一个块, 大小为32字节, 地址为:
x
x
x
x
x
x
.
.
x
x
00000
xxxxxx..xx00000
xxxxxx..xx00000,它的伙伴的地址就是
x
x
x
x
x
x
.
.
x
x
10000
xxxxxx..xx10000
xxxxxx..xx10000。
换句话说, 一个块的地址和它的伙伴的地址只有一位不相同。
2. 优点
主要优点是它的快速搜索和快速合并
3. 缺点
主要缺点是要求块大小为2的幂可能导致显著的内部碎片。因此, 伙伴系统分配器不适合通用目的的工作负载。然而, 对于某些特定应用的工作负载, 其中块大小预先知道是2的幂, 伙伴系统分配器就很
有吸引力了。