malloc 底层实现
动态存储器分配器
malloc 又称显示动态存储器分配器,动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆。
我们假设堆紧接着未初始化.bss段后开始,并向上生长,对于每个进程,由内核维护着堆顶(brk —- break)
分配器将堆视为一组不同大小的块,每个块则是一个连续的虚拟存储器片,要么是已分配的,要么是空闲的。
已分配的块供应用程序使用,空闲块则可以用来分配。
已分配的块保持已分配的状态,直到它被释放。
malloc
我们在 UNIX 系统下讨论 malloc 如何分配空间
标准库函数
#include <stdlib.h>
void* malloc(size_t size);
正如我们平时所使用一样,malloc 函数返回一个指针,指向大小(至少)为 size 字节的存储器块,这个块可能会包含在这个块内的任何数据对象类型做对齐。
—— 在 UNIX 系统上,malloc 返回一个 8 字节边界对齐的块 ———
特性 :
如果 malloc 出现错误,那么它返回 NULL,并设置 errno。
malloc 不初始化它返回的存储器。
如果想要初始化可以使用 calloc 函数,calloc 是一个基于 malloc 的包装函数,他将分配的存储器初始化为 0。
如果想改变一个以前分配块的大小,可以使用 realloc 函数。
sbrk 函数 :
#include <unistd.h>
void *sbrk(intptr_t incr);
sbrk() 函数通过将内核的 brk 指针增加 incr 来扩展和收缩堆。
如果成功,返回 brk 的旧值,否则返回 -1,并设置 errno。
用一个负值来调用 sbrk 函数是合法的,因为返回值指向距新堆顶向上 incr 字节处。
free 函数
#include <stdlib.h>
void free(void *ptr);
ptr 必须指向一个从 malloc / calloc / realloc 函数获得的已分配块的起始位置。
如果不是,那么 free 的行为将是未定义。这时就会产生一些运行时错误 。
现在我们展示malloc free 是如何管理一个C 程序的堆的,每个方框代表一个 4 字节的字。
分配器的特性
- 处理任意请求序列
一个应用可以有任意的分配请求和释放请求序列。 - 立即响应
分配器必须立即相应分配需求。 - 只使用堆
分配器使用的任何非标量数据都必须保存在堆里。 - 对齐
分配器必须对齐块,这是为了使得他们可以保存任何类型的数据对象。 不修改已分配块
分配器只能对空闲块进行操作。最大化吞吐量
一个分配请求的最糟糕运行时间与空闲块的数量成线性关系,但释放请求的运行时间是个常数。- 最大化存储器利用率
由于虚拟存储器的数量是受磁盘上交换空间的数量限制的,所以必须高效的使用。
而分配器则是在这两个要求之间找到一个合适的平衡。
碎片
碎片是造成堆利用率很低的一个主要原因。当有未使用的存储器但不能来满足分配请求时,就会发生这种现象。
碎片分为 :内部碎片和外部碎片
- 内部碎片 :
内部碎片是在分配一个已分配块比有效核载大时发生的。
例如 当一个分配器对已分配的块强加一个最小的大小值,而这个大小值比某个请求的有效核载大。
正如我们上面的例子,当 p2 申请5个字的空间时,由于要满足对齐约束,分配器就增加了块的大小为6个字,此时多出来的那一个字的大小就被称为内部碎片 。 - 外部碎片:
当空闲存储器合起来足够满足一个分配请求,但是没有一个单独的空闲块可以满足这个请求。
同样借鉴上面的例子,当 p4 申请了2个字之后,我们再想申请5个字,此时是可以满足的,但是如果申请6个字节就会出现空闲块足够但是无法分配的情况。
外部碎片取决于请求的模式。
概念
在这里我们先思考 一个动态分配器需要做的事情,并且规划出一个蓝图。
由于外部碎片的难以量化和不可预测,所以分配器通常维持少量的大空闲块,而不是维持大量的小空闲块。
在实现时我们需要考虑
- 我们如何记录空闲块
- 我们如何选择一个空闲块来放置一个新分配的快
- 在分配后,我们如何处理这个空闲块中的剩余部分
- 我们如何处理一个刚刚被释放的块
记录空闲块
隐式空闲链表
我们用一个数据结构来描述我们的空闲块,包括块的边界,以及区别已分配和空闲块。然后将这个数据结构用链表进行维护。
其中 a = 1 代表已分配 a = 0 代表未分配
块大小包括头部,有效核载和填充。
如果我们要强加一个双字的对齐约束条件,那么块的大小应该是 8 的倍数。
在头部后面就应该是调用 malloc 时请求的有效核载,有效核载后面是一片不使用的填充块(分配器策略或用于满足对其要求)。
这样我们就可以利用上述的头部来将堆组织为一个连续已分配和空闲块的序列,其中彩色块代表已分配。空白代表空闲 。
在这里我们并不需要一个前后指针来指向下一个空闲节点/分配节点,只需要读出头部的块大小并以当前地址为起始+块大小就可以计算出下一个空闲块/分配块的地址。
这样的结构就被称为隐式空闲链表。因为空闲块是通过头部中的大小字段隐含地连接着的 ,从而使得分配器通过遍历堆中的所有块,从而间接的遍历整个空闲块的集合。
但是隐式空闲链表也有一个明显的缺点就是,当我们要分配块时,空闲链表的搜索与堆中已分配块和空闲块的总数呈线性关系。
显示空闲链表
在隐式空闲链表中,由于块的分配与堆块的总数呈线性关系,所以对于通用分配器来说,隐式空闲链表是不合适的。
如果我们将空闲块组织为某种显示的数据结构,由于程序不需要一个空闲块的主题,所以我们将数据结构的指针存放在空闲块的主体里面,我们将堆组织为一个双向空闲链表,在每个空闲块中都包含一个 pred 前驱和 succ 后继指针。
使用双向链表后,使得首次适配的分配时间从块总数的线性时间减少到空闲块数量的线性时间。不过释放一个块的时间可以是线性的,也可以是常数的。
释放时间取决于放置策略
- 后进先出
将新释放的块放置在链表的开始处,释放和合并可以在常数时间内完成。
- 按地址放置
按照地址顺序来维护,每个块的地址都小于它的后继。具有更高的存储器利用率。
分离的空闲链表
在显示空闲链表中,一个使用单向空闲块链表的分配器需要与空闲块数量呈线性关系的时间来分配块。
而分离,就是维护多个空闲链,其中每个链表中的块有大致相等的大小,一般是将所有可能的块分成一些等价类。
分配器维护着一个空闲链表数组 ,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为 n 的块时,他就搜索相应的空闲链表,如不能找到则搜索下一个链表,以此类推。
简单的分离存储:
每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。
为了分配给一个给定大小的块,我们检查相应的空闲链表,如果链表为空,我们简单地分配其中第一块的全部。此时空闲块是不会分割以满足分配请求的。如果链表为空,分配器就向操作系统申请一个固定大小的额外存储器片,将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。释放时,只需要简单的将这个块插入到相应的空闲链表前部。
这样的话,分配和释放都可以在常数时间内完成。由于我们不进行分割,那么也就没有合并,所以我们就不需要一个已分配/空闲标记,已分配块也就不需要头部,因为没有合并,同样也不需要脚部。
缺点 :容易造成外部碎片和内部碎片。
放置已分配块
当一个应用请求一个 k 字节的块时,分配器搜索空闲链表,查找一个可以放置所请求块的空闲块。这就和分配器的放置策略相关联了
- 首次适配
从头开始搜索空闲链表,选择第一个合适的空闲块 。
优点 :总是将大的空闲块保留在链表的最后面。
缺点 :在靠近链表起始出会留下小空闲块,加大了对较大块的搜索时间。
- 下一次适配
与首次适配基本相似,只不过不是从头部开始搜索,而是从上一次查询结束的地方开始。
优点 :下一次适配比首次适配的运行时间更快。
缺点 :在存储器利用率方面比首次适配低得多。
- 最佳适配
检查每个空闲块 ,选择适合所需请求大小的最小空闲块。
优点 :存储器利用率最高。
缺点 :要求对堆进行彻底的搜索。
分割空闲块
一旦分配器找到一个匹配的空闲块,那么此时需要考虑的就是,分配这个空闲块中的多少空间。
如果选择使用整个空闲块,虽然速率较快,但是会造成内部碎片 。(但是如果趋向于产生好的匹配,那么内部碎片可以接受)。
如果匹配的不太好,分配器通常会选择将空闲块一分为二 ,第一部分变成分配块,而剩下的部分变成一个新的空闲块 。
合并空闲块
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些相邻的空闲块可能会造成一种 ‘假碎片’ 现象(有许多可用的空闲块被切割为小的无法使用的空闲块)。
为了解决这一个问题,任何分配器都必须执行合并相邻的空闲块,这个过程就被称为合并。
分配器可以选择立即合并,也可以选择推迟合并。
那么分配器如何实现合并?
以我们前面所设计的数据结构为例,当我们释放一个分配块时,合并下一个空闲块很简单且高效,但是如何合并前面的块就成了一个问题,所以我们需要对前面所设定的数据结构加以改进
在这里我们添加了一个脚部,那么分配器就可以通过检查它的脚部来判断前一个块的起始位置。
但是这样会造成,我们的每个块都保持一个头部和一个脚部,如果一个应用程序大量的申请小块空间时,会产生显著的存储器开销。
所以我们需要对前面的头部+脚部的形式进行改进。
——– 因为我们只有在合并的时候才会使用到脚部,所以对于已分配的块只需要一个头部而不需要脚部,但是空闲块依然需要脚部。
malloc 底层
C 标准库 函数 malloc 在底层使用的是 —– 分离适配
使用这种方法,分配器维护着一个空闲链表数组,每个空闲链表被组织成某种类型的显示/隐式链表。每个链表包含大小不同的块,这些块的大小是大小类的成员。
当要分配一个块时,我们确定了大小类之后,对适当的空闲链表做首次适配,查找一个合适的块,如果找到,那么可选地分割它,并将剩余的部分插入到适当的空闲链表中。如果每找到,那就搜索下一个更大的大小类的空闲链表,重复直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆存储器,从这个新的堆存储器中分配一个块,将剩余部分放置在适当的大小类中。
当释放一个块时,我们执行合并,并将结果放在相应的空闲链表中。
优点 :
存储器利用率高,分配效率高。减少了搜索时间。
对分离空闲链表的首次适配搜索,存储器利用率接近最佳适配搜索的存储器利用率。
这也就是 C 标准库中 malloc 采用的方法。
上一篇: Linux下启动tomcat的方法