前言
本文简单论述下动态分配的原理,并自己实现一个最简单的动态分配器,本文几乎完全参照《深入理解计算机系统》,以此来当做一个笔记记录一下学习历程。下边就进入正文。
动态内存分配
可执行程序运行时被加载到内存中,这个进程的虚拟内存区域分成几部分,这个我们之前的文章讲过。动态内存分配器就是维护着虚拟内存中的堆。堆区域向上生长(向更高的地址)。
分配器有两种基本风格。两种风格都要求应用程序显式的分配块,他们的不同之处在于由哪个实体负责释放已分配的块。
- 显示分配器,要求应用程序显式的释放已分配的块,例如C,C++语言都是自己来分配和释放
- 隐式分配器,要求分配器检查到已分配块何时不再使用,那么释放这个块,也叫做垃圾回收。例如lisp,java之类的语言。
分配器的要求
显式分配器有一些约束条件:
- 处理任意请求序列:一个应用可以有任意的分配请求和释放请求队列,只要满足约束条件。分配器不可以假设分配和释放的顺序。
- 立即响应请求:分配器需要立即响应分配请求,不允许分配器为了提高性能重新排列或者缓冲请求
- 只使用堆:为了分配器是可扩展的,分配器使用的任何非标量数据结构都保存在堆里
- 对齐块:分配器必须对齐块,是的可以保存任何类型的数据对象,对齐依赖于在32位模式还是64位模式下运行,32模式下,malloc总是返回8的倍数,64模式下总是返回16的倍数。
碎片
我们在堆中分配内存,是要考虑对利用率,但是造成堆利用率很低的主要原因碎片现象。有两种形式的碎片,内部碎片和外部碎片。
- 内部碎片是在一个已分配的内存块比在这个内存块的有效载荷大时发生的。很多原因都可能造成这个问题,例如,分配器实现可能对已分配的内存块有最小的要求,这个大小比请求的有效载荷大,亦或者分配器为了对齐原则来增加分配块的大小。
- 外部碎片是当空闲的内存块合起来能满足一个分配请求,但是单独每一个大小都不能满足请求时,比如说现在有一个2字节的空闲块,和一个4字节空闲块,且不相邻,请求6字节,即便内存中有足够的空间,但是这样仍无法满足这个请求。
实现问题
我们带着问题来实现动态分配器
- 空闲块的记录,我们如何来记录空闲块
- 放置,如何选择一个合适空闲位置放置新分配的块,这里有没有什么算法
- 分割,放置之后,我们如何分割剩余的空闲位置
- 合并,释放之后我们如何合并两个相邻的空闲块
这些问题弄弄明白了,我们还需要秉承着提高内存利用率且降低时间复杂度的宗旨来实现我们的分配器,然后我们的分配器就实现了。😃
空闲块的组织结构
我们以隐式空闲链表为例,他来区分块的边界,并以此来区分已分配块和空闲块,大多数分配器将这些信息嵌在块本身。如图:
讲解一下这个图,将头部的32位中 3~31位记录块的大小,最后3位来记录其他的,最后一位表示空闲还是已分配,有效载荷部分的就是请求的分配块,如果请求的块不满足对齐要求,需要填充来满足对齐的要求,这里我们使用8字节对齐。即整个块的大小必须是8字节的倍数。
这种结构我们称为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含连接着,分配器可以通过遍历堆中所有的块,从而间接的遍历整个空闲块的集合。我们看下书中给出的例子:
有颜色的部分表示是已经分配了的,空白的地方表示空闲块,每个虚线的间隔表示是8个字节(表示对齐),即每个小块是4字节。
首先8/0表示是个空白块集,大小是8,未分配内存,通过大小可以找到下一部分。
16/1部分表示大小是16,有一个填充块,说明分配的内存时小于等于8字节的
32/0 …
16/1 …
可以看出来整个分配和未分配的集合是通过块头部可以连接起来的
放置块
接下来我们看放置分配块,当分配请求到来时,我们需要找到足够的空间来放置分配的块。这时分配器质性搜索的方式由放置策略来确定,常见的策略首次适配,下一次适配,最佳适配。
- 首次适配就是从头开始搜索空闲链表,选择第一个合适的空闲块
- 下一次首配和首次适配很相似,只不过不是从头开始搜索空闲链表,而是从上一次查询结束的地方开始
- 最佳适配检查所有的空闲块,选择最满足所需请求大小的最小空闲块
首次适配的优点就是趋向于大的空闲块保留在后面,缺点就是趋向于在起始处留下小的空闲块碎片,这样就增大了对较大块的搜索时间。下一次适配作为首次适配的替代品提出,下一次适配比首次适配运行明显快,但是内存利用率要低一点。最佳适配的缺点就是要求对堆进行彻底的搜索,比较耗时。我们代码实现使用首次适配来练手。
合并空闲块
当分配器释放了一个已分配的块,要检查这个块的前边和后边是否有空闲块,如果有空闲块,需要将两个块合并。
合并时机
那么什么时候执行合并也是一些策略决定,分配器可以选择立即合并或者推迟合并,比如说分配请求失败,扫描整个堆进行合并空闲块。
立即合并简单明了,常数时间即可完成,但是对于某些请求模式这种方式会产生抖动,块反复合并,马上分割。比如我们反复分配释放3字节的块。但是推迟合并需要一定算法支持,我们这里先简单实用立即合并。
合并方式
那么分配器是如何合并呢,释放当前块后,我们合并当前块的下一个块很简单,当前块头部大小可以知道下一个块的头部位置,并判断是不是空闲块,如果是空闲块,就是将下一个空闲块的大小加到当前块的头部。
但是我们如何合并上一个块呢?暴力法就是搜索整个链表保存上一个块的位置,遍历到当前块时,然后就能进行合并了。但是这样每次free都要遍历,而且越是释放后边的块,遍历的时间就越长,这个方法不好。我们换一个方法,如图所示:
Knuth提出的边界编辑,如图就是在块的尾部添加一个头部内容和大小都相同的脚部,那么这样就可以检查上一块的脚部来合并上一个空闲块了。可能大家又会想,这样多出来一个脚部,不是又增加了内存空间,如果要考虑对齐是不是更麻烦呢,其实不是,因为只有前边块是空闲块时,才会用到他的脚部,如果前边块是已分配的块就是真实的内容,可是怎么能知道前一个块是不是用了脚部呢,大家想,我们头部总共是32位,3~31位表示块的大小,最后一位表示是否空闲,还剩下两位没有表示,那我们我们就可以用剩余的位来表示。比如说我们用当前块的第2位表示上一个块是不是空闲。这样是不是很巧妙的解决了合并的问题。
合并场景
假设我们释放n/a的块
- 场景1:前边的m1和后边的m2都是已分配的,所以不能合并
- 场景2:后边m2是空闲块,前边m1是已分配的,所以合并m2为n+m2/f
- 场景3:前边m1是空闲块,后边m2是已分配的,所以合并m1为n+m1/f
- 场景4:m1和m2都是空闲块,合并后是n+m1+m2/f
代码实现
先看下代码结构
class DynamicAllocator
{
public:
DynamicAllocator();
~DynamicAllocator();
void memFree(void* bp);
void* memMalloc(unsigned size);
private:
void init();
void* memBrk(int incr);
int memInit();
void* extendHeap(unsigned words);
void* coalesce(void* bp);
void* findFit(unsigned asize);
void place(void* bp, unsigned asize);
private:
char* mem_heap_ = nullptr;
char* mem_brk_ = nullptr;
char* mem_max_addr_ = nullptr;
void* heap_listp_ = nullptr;
};
提供给外部使用的只是memMalloc和memFree,及一些供内部使用的函数。mem_heap_指针模仿堆的基地址,mem_brk_模仿堆的brk的指针(linux系统中brk表示将进程堆的最高地址指针往高地址推)值,
mem_max_addr_模仿堆的最大位置。我们这里从堆中分配了大块内存,用以模仿整个堆。
- 程序初始时我们调用init和memInit():
void DynamicAllocator::init()
{
mem_heap_ = (char*)malloc(mh::MAX_HEAP);
mem_brk_ = mem_heap_;
mem_max_addr_ = (char*)(mem_heap_ + mh::MAX_HEAP);
}
int DynamicAllocator::memInit()
{
if ((heap_listp_ = memBrk(4 * mh::WSIZE)) == nullptr) {
return -1;
}
mh::put(heap_listp_, 0);
mh::put((char*)heap_listp_ + (1 * mh::WSIZE), mh::pack(mh::DSIZE, 1));
mh::put((char*)heap_listp_ + (2 * mh::WSIZE), mh::pack(mh::DSIZE, 1));
mh::put((char*)heap_listp_ + (3 * mh::WSIZE), mh::pack(0, 0x3));
heap_listp_ = (char*)heap_listp_ + 2 * mh::WSIZE;
if (extendHeap(mh::CHUNKSIZE / mh::WSIZE) == nullptr) {
return -1;
}
return 0;
}
init没什么好看的,我们看下memInit,首先在堆中扩展4字(这里1字表示4字节)的内存。
第一个字设置为0,表示开头,然后分配2个字也是作为开始的头部和尾部,这里不存放数据,用来表示链表的开始块 (heap_listp_),方便后边查询,然后分配一个字作为链表的结尾。
大致讲解下我们结构,这里和书中有些出入,我们块的结构采用空闲时有头部和尾部,分配时只有头部,所以我们需要下一个块来记录上一个块分配情况,我们用第二位来表示上一个块是分配(1)还是空闲(0),第一位表示本块是分配(1)还是空闲(0),然后这里的pack和put实现是:
constexpr unsigned pack(unsigned size, unsigned alloc) {
return (size | alloc);
}
constexpr void put(void* p, unsigned val) {
*(unsigned*)p = val;
}
比如结尾的块pack(0, 0x3),结果最后两位二进制就是11,第一位表示结尾块的分配,第二位表示上一个块是分配了的。
- 然后我们调用extendHeap来扩展brk来为后边应用程序分配内存做准备:
void* DynamicAllocator::extendHeap(unsigned words)
{
unsigned bytes = (words % 2) ? (words + 1) * mh::WSIZE : words * mh::WSIZE;
char* bp = (char*)memBrk(bytes);
if (bp == nullptr) {
return nullptr;
}
// merge last block or epilogue header and init new free block
unsigned herder_alloc_num = mh::get_prev_alloc(mh::hdrp(bp)) ? 0x2 : 0;
mh::put(mh::hdrp(bp), mh::pack(bytes, herder_alloc_num)); // block header
mh::put(mh::ftrp(bp), mh::pack(bytes, 0)); // block footer
mh::put(mh::hdrp(mh::next_blkp(bp)), mh::pack(0, 1)); // new epilogue header
return coalesce(bp);
}
首先我们使用memBrk来扩展堆内存,看下实现:
void* DynamicAllocator::memBrk(int incr)
{
char *old_brk = mem_brk_;
if (incr < 0 || mem_brk_ + incr > mem_max_addr_) {
std::cout << "ERROR: memBrk failed, out of memory" << std::endl;
return nullptr;
}
mem_brk_ += incr;
return old_brk;
}
调整指针返回原来的位置。
然后我们继续extendHeap,把扩展出来的内存当做一整个未分配的块,同样也要记录上一个块的分配情况,这里有一点巧妙的地方,我们扩展完的块正好用链表结尾的块作为扩展出来的块头部。然后就剩余一个块来表示链表的结尾块,我们看下hdrp(块的头部)和ftrp(块的尾部)实现。
constexpr char* hdrp(void* bp) {
return (char*)bp - WSIZE;
}
constexpr char* ftrp(void* bp) {
return (char*)bp + get_size(hdrp(bp)) - DSIZE;
}
扩展完内存我们需要进行一次合并,coalesce,以便于合并相邻的两个空闲块:
void* DynamicAllocator::coalesce(void* bp)
{
bool prevAlloc = mh::get_prev_alloc(mh::hdrp(bp));
bool nextAlloc = mh::get_alloc(mh::hdrp(mh::next_blkp(bp)));
unsigned size = mh::get_size(mh::hdrp(bp));
if (prevAlloc && nextAlloc) {
return bp;
}
else if (prevAlloc && !nextAlloc) {
size += mh::get_size(mh::hdrp(mh::next_blkp(bp)));
mh::put(mh::hdrp(bp), mh::pack(size, 0x2));
mh::put(mh::ftrp(bp), mh::pack(size, 0));
}
else if (!prevAlloc && nextAlloc) {
size += mh::get_size(mh::hdrp(mh::prev_blkp(bp)));
mh::put(mh::ftrp(bp), mh::pack(size, 0));
void* prev_blk = mh::prev_blkp(bp);
unsigned alloc_num = mh::get_prev_alloc(mh::hdrp(prev_blk)) ? 0x2 : 0;
mh::put(mh::hdrp(mh::prev_blkp(bp)), mh::pack(size, alloc_num));
bp = mh::prev_blkp(bp);
}
else {
size += mh::get_size(mh::hdrp(mh::prev_blkp(bp))) +
mh::get_size(mh::hdrp(mh::next_blkp(bp)));
void* prev_blk = mh::prev_blkp(bp);
unsigned alloc_num = mh::get_prev_alloc(mh::hdrp(prev_blk)) ? 0x2 : 0;
mh::put(mh::hdrp(mh::prev_blkp(bp)), mh::pack(size, alloc_num));
mh::put(mh::ftrp(mh::next_blkp(bp)), mh::pack(size, 0));
bp = mh::prev_blkp(bp);
}
return bp;
}
首先判断这个块的前一个块和后一个块是分配还是空闲,然后分成四种情况,就和我们上边讲解的,但是我们使用的结构不太一样,代码表示的和上边的图也不一样。
- 然后我们就来看看memMalloc怎么实现的:
void* DynamicAllocator::memMalloc(unsigned size)
{
unsigned asize = 0;
if (size == 0) {
return nullptr;
}
if (size <= mh::WSIZE) {
asize = 2 * mh::WSIZE;
}
else {
asize = (mh::WSIZE + size) % mh::DSIZE == 0 ?
(mh::WSIZE + size) : ((mh::WSIZE + size) / mh::DSIZE + 1) * mh::DSIZE;
}
void *bp = nullptr;
if ((bp = findFit(asize)) != nullptr) {
place(bp, asize);
return bp;
}
unsigned extendSize = std::max(asize, mh::CHUNKSIZE);
if ((bp = extendHeap(extendSize / mh::WSIZE)) == nullptr) {
return nullptr;
}
place(bp, asize);
return bp;
}
调用findFit来找一个是不是合适的块,有的话就放置(place),没有的话就扩展内存(extendHeap)然后再放置(place)。我们用的是首次适配策略,看下实现:
void* DynamicAllocator::findFit(unsigned asize)
{
for (void* bp = heap_listp_; mh::get_size(mh::hdrp(bp)) > 0; bp = mh::next_blkp(bp)) {
if (!mh::get_alloc(mh::hdrp(bp)) && mh::get_size(mh::hdrp(bp)) >= asize) {
return bp;
}
}
return nullptr;
}
heap_listp_是链表的开始,然后向后遍历,判断到大小为空(链表的尾部)的话就终止,如果遍历过程中是未分配的块,且大小大于等于需要的大小,就使用这个块。
- 讲完memMalloc我们再来看下memFree:
void DynamicAllocator::memFree(void* bp)
{
unsigned size = mh::get_size(mh::hdrp(bp));
unsigned herder_alloc_num = mh::get_prev_alloc(mh::hdrp(bp)) ? 0x2 : 0;
mh::put(mh::hdrp(bp), mh::pack(size, herder_alloc_num));
mh::put(mh::ftrp(bp), mh::pack(size, 0));
mh::set_alloc_to_next_hdrp(bp, false);
coalesce(bp);
}
首先我们将这个块是否分配的标志置为0,然后将下一个块记录本块分配状态清除,然后合并空闲块。
写在后边
基本上我们自动实现的动态分配器就搞定了,但是为了让大家充分的理解,我们还有一些知识点要分享下。
其他的组织结构
显式空闲链表
我们使用的隐式空闲链表把整个堆内存用链表来串联起来,这个链表中每个元素,可能是空闲的块,也可能是分配的块,但是大家可能想一下,我们这个链表中其实在分配新的块的时候,只需要遍历空闲块链表就可以,释放的时候也只是合并当前块的前后块,这样一想是不是我们把分配的块串在链表里会影响到我们查找空闲块的效率,所以书中提到一种新的组织结构,显式空闲链表:
在分配的情况下和我们之前很像,但是在空闲的时候块中又增加了两个部分,pred和succ,分别表示前一个空闲块的位置和后一个空闲块的位置,这样我们就真的将空闲的块串起来,在使用首次适配策略来找空闲块时,时间就会随着分配块的增加而大幅度减小。但是这样有一个缺点,就是这个块的最小的大小就要变大,这也就会造成我们前边说的内部碎片。
分离空闲链表
除了显式空闲链表,还有一种叫做分离空闲链表,就是说维护多个空闲链表,每个链表的块有大致相等的大小,例如使用2的幂来划分块的大小:
{1},{2},{3 ~ 4 } , {5 ~ 8} … {1025~2048}, { 2049 ~ 4096}, {4097 ~ ∞ } 表示每个大小类,每个大小类维护一个空闲块,需要分配n大小时,就去相应的大小类中寻找相应的空闲块,有兴趣可以深入了解。
完
文章到这里结束了,欢迎交流及指正
代码链接:
https://github.com/zhangdexin/allocator