Malloc Lab 要求用 C 语言编写一个动态存储分配器,即实现 malloc,free 和 realloc 函数,并尽可能优化这个分配器,平衡吞吐率和空间利用率
性能表现主要考虑两点:
- 空间利用率:mm_malloc 或 mm_realloc 函数分配且未被 mm_free 释放的内存与堆的大小的比值。应该找到好的策略使碎片最小化,以使该比率尽可能接近 1
- 吞吐率:每单位时间完成的最大请求数,即要使时间复杂度尽可能小
吞吐率和空间利用率是冲突的,不可能吞吐率高又空间利用率高,因为高的吞吐率必然采用诸如链表,哈希表,树等结构,这些结构必然导致空间利用率降低,所以得平衡
一、基本代码
/* 头部/脚部的大小 */
#define WSIZE 4
/* 双字 */
#define DSIZE 8
/* 扩展堆时的默认大小 */
#define CHUNKSIZE (1 << 12)
/* 设置头部和脚部的值, 块大小+分配位 */
#define PACK(size, alloc) ((size) | (alloc))
/* 读写指针p的位置 */
#define GET(p) (*(unsigned int *)(p)) //注释1
#define PUT(p, val) ((*(unsigned int *)(p)) = (val))
/* 从头部或脚部获取大小或分配位 */
#define GET_SIZE(p) (GET(p) & ~0x7) //注释2
#define GET_ALLOC(p) (GET(p) & 0x1) //注释3
/* 给定有效载荷指针, 找到头部和脚部 */
#define HDRP(bp) ((char*)(bp) - WSIZE) //注释4
#define FTRP(bp) ((char*)(bp) + GET_SIZE(HDRP(bp)) - DSIZE)
/* 给定有效载荷指针, 找到前一块或下一块 */
#define NEXT_BLKP(bp) ((char*)(bp) + GET_SIZE(((char*)(bp) - WSIZE)))
#define PREV_BLKP(bp) ((char*)(bp) - GET_SIZE(((char*)(bp) - DSIZE)))
- 首先通过GET(p)来获取指针p所指向的块的值。因为返回的p为(void *),所以这里先强制转换为(unsigned int *),再用' * '符号取p所指向的值。
- ~0x7是将0x7进行按位取反操作得到的结果。在二进制中,0x7的二进制表示为0...0111(29位0),对其取反操作得到的结果为1...1000(29位1)。这个操作的目的是取得前29位的二进制数信息,该信息表示数据载荷的大小,所以目的是得到数据载荷的大小。
- 由于0x1的二进制表示为00000001,按位与运算会将块的值的最后一位与1进行按位与运算,即获取最后一位的信息,若为1则表示该块已被分配,若为0则表示该快未被分配。
在内存分配和管理中,通常会使用序言块(prologue block)和结尾块(epilogue block)来辅助管理内存块之间的边界信息。序言块是位于每个分配块之前的小块,用于存储一些元数据信息,如块的大小和分配状态。结尾块通常位于堆的末尾,用于标志堆的结束。
int mm_init(void)
{
/* 申请四个字节空间 */
if((heap_list = mem_sbrk(4*WSIZE)) == (void *)-1)
return -1;
PUT(heap_list, 0); /* 对齐 */
/*
* 序言块和结尾块均设置为已分配, 方便考虑边界情况
*/
PUT(heap_list + (1*WSIZE), PACK(DSIZE, 1)); /* 填充序言块(头部) */
PUT(heap_list + (2*WSIZE), PACK(DSIZE, 1)); /* 填充序言块(脚部) */
PUT(heap_list + (3*WSIZE), PACK(0, 1)); /* 结尾块 */
heap_list += (2*WSIZE);
/* 扩展空闲空间 */
if(extend_heap(CHUNKSIZE/WSIZE) == NULL)
return -1;
return 0;
}
这里使用4次PUT()函数是为了进行初始化,而该初始化有一个默认的规则,即填充'0','8/1','8/1','0/1'。
void mm_free(void *bp)
{
if(bp == 0)
{
return;
}
size_t size = GET_SIZE(HDRP(bp)); //访问头部地址,然后使用GET_SIZE从头部地址获取size信息
if(heap_listp == 0) //即指向堆内存的起始位置的指针为0,则初始化并使用默认规则填充该块
{
mm_init();
}
PUT(HDRP(bp),PACK(size,0)); //将头部脚部设置为未填充状态
PUT(FDRP(bp),PACK(size,0));
coalesce(bp); //释放后块要合并
}
static void *coalesce(void *bp)
{
size_t prev_alloc = GET_ALLOC(FTRP(PREV_BLKP(bp))); //注释1
size_t next_alloc = GET_ALLOC(HDRP(NEXT_BLKP(bp))); //
size_t size = GET_SIZE(HDRP(bp));
if(prev_alloc && next_alloc)
{
return bp;
}
else if(prev_alloc && !next_alloc)
{
size += GET_SIZE(HDRP(NEXT_BLKP(bp)));
PUT(HDRP(bp),PACK(size,0));
PUT(FTRP(bp),PACK(size,0));
}
else if(!perv_alloc && next_alloc)
{
size += GET_SIZE(HDRP(PREV_BLKP(bp)));
PUT(HDRP(PREV_BLKP(bp)),PACK(size,0));
PUT(FTRP(bp),PACK(size,0));
bp=PREV_BLKP(bp);
}
else if(!prev_alloc && !next_alloc)
{
size += GET_SIZE(HDRP(PREV_BLKP(bp)))+GET_SIZE(HDRP(PREV_BLKP(bp)));;
PUT(HDRP(PREV_BLKP(bp)),PACK(size,0));
PUT(FTRP(NEXT_BLKP(bp)),PACK(size,0));
bp=PREV_BLKP(bp);
}
return bp;
}
1. 既然头部和脚部报存的信息一样,那为什么不能换成全为头部获取或者全为脚部获取呢?
这是因为在大多数内存管理实现中,使用前一个块的脚部和后一个块的头部来检查它们的状态更为直接和可靠。这是由于内存块的布局和块状态信息的获取方式决定的,大多数内存管理实现中,使用前一个块的脚部和后一个块的头部来检查它们的状态更为直接和可靠。这是由于内存块的布局和块状态信息的获取方式决定的。
size_t prev_alloc = GET_ALLOC(HDRP(PREV_BLKP(bp))); //
size_t next_alloc = GET_ALLOC(FTRP(NEXT_BLKP(bp))); //比如这样的代码,逻辑上存在潜在问题,可能无法准确获取前一个块的状态信息,导致内存管理逻辑的错误。
因此,为了确保内存管理逻辑的正确性,推荐使用前一个块的脚部和后一个块的头部来检查它们的分配状态。
void place(void *bp,size_t asize)
{
size_t csize = GET_SIZE(HDPR(bp));
if((csize-asize) >= 2 * DSIZE)//分割空闲块要考虑剩下的空间是否足够放置头部和脚部
{
PUT(HDRP(bp),PACK(asize,1));
PUT(FTRP(bp),PACK(asize,1));
bp = NEXT_BLKP(bp);
PUT(HDRP(bp),PACK(csize-asize),0);
PUT(FTRP(bp),PACK(csize-asize),0);
}
else
{
PUT(HDRP(bp),PACK(csize,1));
PUT(FTRP(bp),PACK(csize,1));
}
//如果不能分离出新的空闲块,就将整个块标记为已分配,以避免产生过小的空闲块,这样的块可能无法满足未来的分配请求,从而降低内存利用率和分配效率。
}
void *extend_heap(size_t words)
{
/* bp总是指向有效载荷 */
char *bp;
size_t size;
/* 根据传入字节数奇偶, 考虑对齐 */
size = (words % 2) ? (words+1) * WSIZE : words * WSIZE;
/* 分配 */
if((long)(bp = mem_sbrk(size)) == -1)
return NULL;
/* 设置头部和脚部 */
PUT(HDRP(bp), PACK(size, 0)); /* 空闲块头 */
PUT(FTRP(bp), PACK(size, 0)); /* 空闲块脚 */
PUT(HDRP(NEXT_BLKP(bp)), PACK(0, 1)); //设置新的堆结尾块的头部,标记其大小为0,并将其标记为已分配,维护堆的边界条件。
/* 判断相邻块是否是空闲块, 进行合并 */
return coalesce(bp);
}
二、匹配策略
1. 首次匹配
void *find_fit(size_t asize)
{
void *bp;
for(bp = heap_list; GET_SIZE(HDRP(bp)) > 0; bp = NEXT_BLKP(bp)){
if((GET_SIZE(HDRP(bp)) >= asize) && (!GET_ALLOC(HDRP(bp)))){
return bp;
}
}
return NULL;
}
2. 最佳匹配
void *best_fit(size_t asize){
void *bp;
void *best_bp = NULL;
size_t min_size = 0;
for(bp = heap_list; GET_SIZE(HDRP(bp)) > 0; bp = NEXT_BLKP(bp)){
if((GET_SIZE(HDRP(bp)) >= asize) && (!GET_ALLOC(HDRP(bp)))){//如果当前块的大小大于等于请求的大小asize,且当前块未被分配,则继续进行下一步检查。
{
if(min_size ==0 || min_size > GET_SIZE(HDRP(bp)))//如果当前没有找到最适合块(min_size为0)或当前块比之前找到的最小块更适合(块大小更小),则更新最适合块。
{ min_size = GET_SIZE(HDRP(bp));
best_bp = bp;
}
}
}
return best_bp;
}
三、主体函数
void *mm_malloc(size_t size)
{
size_t asize; // 调整后的块大小
size_t extendsize; // 需要扩展的堆大小
char *bp; // 指向块的指针
// 处理特殊情况:请求大小为0
if (size == 0)
return NULL;
// 调整请求的大小,确保有足够的空间
if (size <= DSIZE)
asize = 2 * DSIZE; // 最小块大小,适用于小请求
else
asize = DSIZE * ((size + (DSIZE) + (DSIZE-1)) / DSIZE); // 向上对齐
// 寻找合适的空闲块
if ((bp = best_fit(asize)) != NULL) {
place(bp, asize); // 放置块
return bp; // 返回块的指针
}
// 找不到合适的空闲块,则扩展堆
extendsize = MAX(asize, CHUNKSIZE); // 确定扩展大小
if ((bp = extend_heap(extendsize / WSIZE)) == NULL)
return NULL; // 扩展失败,返回NULL
place(bp, asize); // 放置块
return bp; // 返回块的指针
}