1.内存管理
1.1内存分为三部分
(1)栈区: 存放局部变量。函数调用结束,栈上相应内容被销毁。 (2)堆区: 由malloc或者new分配的内存。由free或者delete决定生命周期,如果没有释放,会一直存在,直到程序结束。 (3)数据区: 存放全局变量、静态变量。 (4)补充栈区和堆区的区别: ①管理方式:堆区的空间需要程序员自己利用malloc函数申请,调用free函数释放的。而栈区的空间是程序进行分配与释放管理的。 ②增长方式:堆区的增长方式是通过低地址向高地址进行扩展的,是不连续的内存区域。而栈区是由高地址向低地址进行扩展的,是连续的内存区域。 ③空间大小:堆区的空间大小比较大。但是栈区的空间大小比较小。 ④内存碎片:堆区产生的内存碎片比较多,因为如果申请的空间小与系统分配出来的大小,那么系统会将剩余的空间依旧放入空闲链表中。但是栈区是不会存在内存碎片的问题,因为栈区的特点是先进后出。 ⑤分配效率:堆区空间的分配是需要调用相关的函数,所以效率比较低。但是栈区是通过专门的寄存器存放栈的地址,压栈出栈由专门的指令执行,效率比较高。 ⑥分配方式:堆区只能动态分配并且手动释放。但是栈区可以动态分配也可以静态分配,静态分配是由编译器完成,动态分配是通过调用alloc函数在栈上申请空间。 ⑦分配后系统相应:操作系统为堆区维护了一个空闲链表,当用户向堆区申请空间,操作系统就会遍历空闲链表寻找第一个大小可用的空间结点,然后将这片空间的结点从空闲链表中删除,并将结点返回给程序使用。如果空闲链表中没有大小可用的空间,就会调用系统的功能去增加程序数据段的内存空间,然后将可用大小的内存返回。但是对于栈区而言,如果剩余空间大于需要申请的空间,系统就会为程序提供内存,否则报告异常提示栈溢出。
1.2内存操作经常出现的五大问题
(1)没有为指针分配合法的内存 (2)为指针分配的内存大小不够 (3)分配内存但未初始化 (4)指针访问的内存越界 (5)内存泄漏
2.动态内存分配
动态内存分配器维护着一个进程的虚拟内存——堆。分配器将堆视为一组大小不同的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已经分配的,要么是空闲的。分配器有两种基本的风格: 显示分配器:要求显示地释放任何已经分配的块。例如:C语言里面的malloc函数分配一个块,使用解释之后调用free函数释放一个块。 隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,就释放这个块。例如垃圾收集器。
那么,问题来了,为什么要使用动态内存分配呢? 原因是当我们在程序设计的时候,需要申请一个数组的大小,但是这个数组的大小我们提前并不知道多大,只有在运行的时候我们才可以根据某个变量来动态的分配数组的空间,如果提前设定好数组的大小为某个值,就会出现数组空间浪费或者数组空间不足的情况,所以动态的分配可以在不修改源码的情况下做到恰到好处。
2.1分配器的要求和目标
(1)要求: 处理任意请求序列 不修改已经分配的块 只在堆区进行分配 遵守内存对齐的原则 立即响应 (2)目标: 最大化吞吐率 最大化内存利用率
2.2分配器的实现问题
由于分配器从不重复使用任何块,内存利用率极差,所以需要考虑到: ①空闲块组织:如何组织空闲块? ②放置:怎样选择一个合适的空闲块来放置一个新分配的块? ③分割:将一个新分配的块放置到某个空闲块之后,如何处理这个空闲块中的剩余部分? ④合并:如何处理被释放的块?
2.3简单分配器的设计与实现——隐式空闲链表
(1)隐式空闲链表的概念: 所谓的隐式空闲链表就是通过空闲块头部的大小字段隐式的连接起来。 (2)隐式空闲链表的优点: 简单。 (3)隐式空闲链表的缺点: 任何操作的开销,如防止分配块,就需要遍历整个空闲链表,寻找空闲块,所以搜索的时间是与堆中已分配块和空闲块的总数呈线性关系。
2.3.1隐式空闲链表的初始化:
(1)使用边界的堆块格式: 使用边界标记(所谓的边界标记就是每一个块的脚部),使得块无论是判断当前块的下一个块是否空闲还是当前块的上一个块是否空闲,都能够在O(1)的时间复杂度下进行判断。 (2)隐式空闲链表的形式: 如下图: 第一个字是一个八个字节边界的不使用的填充字。 序言块是一个八个字节的已分配块,由一个头部和一个脚部组成,在初始化的时候创建,并且用不释放。 序言块后面是多个由malloc或者free调用创建的普通块。 堆总是以一个特殊的结尾块结束,其中的0/1表示:大小为0,已分配的块(用1表示)。 (3)初始化空闲链表代码:
#include <stdio.h>
#include <malloc.h>
#define MAX_HEAP (1<<12)
static char* mem_heap;
static char* mem_brk;
static char* mem_max_addr;
char *heap_listp=NULL;
char *next_listp=NULL;
void mem_init(void)
{
mem_heap = (char*)malloc(MAX_HEAP);
mem_brk = (char*)mem_heap;
mem_max_addr = (char*)mem_heap + MAX_HEAP;
}
void* mem_sbrk(int incr)
{
char* old_brk = mem_brk;
if ((incr<0)||(mem_brk+incr)>mem_max_addr)
{
return (void*)-1;
}
mem_brk += incr;
return (void*)old_brk;
}
(4)分析图: mem_init()函数代码分析图: mem_sbrk()函数代码分析图:
2.3.2操作空闲链表的基本常数和宏:
#define WSIZE 4
#define DSIZE 8
#define CHUNKSIZE (1<<8)
#define MAX(x,y) ((x)>(y)?(x):(y))
#define PACK(size,alloc) ((size)|(alloc))
#define GET(p) (*(unsigned int *)p)
#define PUT(p,val) (*(unsigned int*)(p)=(val))
#define GET_SIZE(p) (GET(p)&~0x7)
#define GET_ALLOC(p) (GET(p)&0x1)
#define HEAD(p) ((char *)(p)-WSIZE)
#define TAIL(p) ((char *)(p)+GET_SIZE(HEAD)-DSIZE)
#define NEXT_BLKP(p) ((char*)(p)+GET_SIZE(((char*)(p)-WSIZE)))
#define PRE_BLKP(p) ((char*)(p)-GET_SIZE(((char*)(p)-DSIZE)))
2.3.3创建初始化空闲链表:
mm_init函数从内存系统得到4个字,并将它们初始化,创建一个空的空闲链表。 然后调用extend_heap函数将堆扩展CHUNKSIZE字节,并且创建初始空闲块。
需要明确的是: extend_heap()函数的调用情况有两种: 第一种情况是在堆被初始化的时候使用。 第二种情况是当堆里面没有合适的匹配块的时候,像系统申请额外的堆空间。 (1)代码:
int mm_init(void)
{
heap_listp = (char*)mem_sbrk(4 * WSIZE);
if (heap_listp == (void*)-1)
{
return -1;
}
PUT(heap_listp, 0);
PUT(heap_listp + (1 * WSIZE), PACK(DSIZE, 1));
PUT(heap_listp + (2 * WSIZE),PACK(DSIZE, 1));
PUT(heap_listp + (3 * WSIZE), PACK(0, 1));
heap_listp += (2 * WSIZE);
if (extend_heap(CHUNKSIZE / WSIZE) == NULL)
{
return -1;
}
return 0;
}
static void* extend_heap(size_t words)
{
char* bp;
size_t size;
size = (words % 2) ? (words + 1) * WSIZE : words * WSIZE;
if ((long)(bp = (char*)mem_sbrk(size)) == -1)
{
return NULL;
}
PUT(HEAD(bp), PACK(size, 0));
PUT(TAIL(bp), PACK(size, 0));
PUT(HEAD(NEXT_BLKP(bp)), PACK(0, 1));
return coalesce(bp);
}
(2)代码分析图: mm_init()函数: extend_heap()函数:
2.3.4放置已分配的块:
(1)放置策略: ①首次适配:从头开始搜索空闲链表,当发现能够满足申请的空闲块时,就使用。 优点:趋向于将大的空闲块保存在链表的后面。 缺点:空闲链表前面的内存碎片比较多,增加了对较大空闲块的搜索时间。 ②下一次适配:之前使用了某一个空闲块,那么下一次适配就是从之前分配的那个空闲块的下一个地方开始寻找满足申请的空闲块。 优点:这种方法比首次适配用起来运行会快一些。 ③最佳适配:从头到尾遍历空闲链表,然后寻找最合适的空闲块用来满足申请。 优点:能够找到最合适的空闲块,减少空闲链表的内存碎片,提高内存利用率。 缺点:需要对空闲链表进行彻底搜索,时间复杂度比较高。 (2)分割空闲块: 分配器会将选择的这个空闲块分为两部分,一部分是变为分配块,然后让剩余的那部分变为新的空闲块,从而减少内部碎片。 (3)获取额外的堆内存: ①合并内存中物理相邻的空闲块,从而组成一个大的空闲块以供使用 ②调用sbrk()函数向内核请求额外的堆内存,分配器将额外的堆内存转化为一个大的空闲块,将这个块插入空闲链表中,然后将被请求的块放置在这个新的空闲块中。 (4)代码:
static char* find_fit(size_t asize)
{
char* i;
for (i = heap_listp; GET_SIZE(HEAD(i))>0; NEXT_BLKP(i))
{
if (!GET_ALLOC(HEAD(i)) && (asize <= GET_SIZE(HEAD(i))))
{
return i;
}
}
return NULL;
}
static void place(char* bp, size_t asize)
{
size_t size = GET_SIZE(HEAD(bp)) - asize;
if (size >= (2 * DSIZE))
{
PUT(HEAD(bp), PACK(asize, 1));
PUT(TAIL(bp), PACK(asize, 1));
PUT(HEAD(NEXT_BLKP(bp)), PACK(size, 0));
PUT(TAIL(NEXT_BLKP(bp)), PACK(size, 0));
}
else
{
PUT(HEAD(bp), PACK(GET_SIZE(HEAD(bp)), 1));
PUT(TAIL(bp), PACK(GET_SIZE(HEAD(bp)), 1));
}
}
void* mm_malloc(size_t size)
{
size_t asize;
size_t extendsize;
char* bp;
if (size == 0)
{
return NULL;
}
if (size <= DSIZE)
{
asize = 2 * DSIZE;
}
else
{
asize = DSIZE * ((size + DSIZE + (DSIZE - 1)) / DSIZE);
}
if ((bp =(char*) find_fit(asize)) != NULL)
{
place(bp, asize);
return bp;
}
extendsize = MAX(asize, CHUNKSIZE);
bp = (char*)extend_heap(extendsize / WSIZE);
if (bp == NULL)
{
return NULL;
}
place(bp, asize);
return bp;
}
(5)代码函数分析图: find_fit()函数和place()函数分析图: mm_malloc()函数分析图:
2.3.5空闲链表的释放与合并:
(1)合并空闲块: 当分配器释放一个已分配块时,可能有其他空闲块与当前释放的这个块相邻,为了能够减少内存碎片,分配器会将这种情况下的空闲块进行合并。合并的策略有两种:一种是立即合并,这种方式说白了就是释放当前块之后,立即检测与它相邻的块是否空闲,如果空闲,立即合并,这种方式有一个弊端:如果释放之后合并,然后又会申请之前释放的那么大一个空间,那么分配器又要分割,用完又要合并,这样反复执行就会显得合并空闲块多此一举。另外一种合并的策略是推迟合并,也就是等到某个稍晚的时候再合并空闲块。
那么如何检测释放的当前块的相邻块是否是空闲呢? 堆块的格式有头部和脚部,脚部是头部的一个副本,分配器可以通过检查下一个块的头部判断是否已分配,通过检查上一个块的脚部判断是否已分配。 (2)代码:
void my_free(void* bp)
{
size_t size = GET_SIZE(HEAD(bp));
PUT(HEAD(bp), PACK(size, 0));
PUT(TAIL(bp), PACK(size, 0));
coalesce(bp);
}
static void* coalesce(void* bp)
{
size_t prev_alloc = GET_ALLOC(TAIL(PRE_BLKP(bp)));
size_t next_alloc = GET_ALLOC(HEAD(NEXT_BLKP(bp)));
size_t size = GET_SIZE(HEAD(bp));
if (prev_alloc && next_alloc)
{
return bp;
}
else if (prev_alloc && !next_alloc)
{
size += GET_SIZE(HEAD(NEXT_BLKP(bp)));
PUT(HEAD(bp), PACK(size, 0));
PUT(TAIL(bp), PACK(size, 0));
}
else if (!prev_alloc && next_alloc)
{
size += GET_SIZE(TAIL(PRE_BLKP(bp)));
PUT(HEAD(PRE_BLKP(bp)), PACK(size, 0));
PUT(TAIL(bp), PACK(size, 0));
bp = PRE_BLKP(bp);
}
else
{
size += GET_SIZE(TAIL(NEXT_BLKP(bp))) + GET_SIZE(HEAD(PRE_BLKP(bp)));
PUT(HEAD(PRE_BLKP(bp)), PACK(size, 0));
PUT(TAIL(NEXT_BLKP(bp)), PACK(size, 0));
bp = PRE_BLKP(bp);
}
return bp;
}
(3)代码函数分析图: coalesce()函数:
2.4显示空闲链表
2.4.1基本概念
与隐式空闲链表相比,显示空闲链表就是把空闲内存块单独拿出来组成一个链表。
2.4.2显示空闲链表的优点:
可以使得首次适配方法的时间复杂度降低,从块总数(n)的线性时间减少到空闲块数量的线性时间(m),O(m)<O(n),其中m<n。 但是释放一个块的时间取决于选择的空闲链表中块的排序策略,如果使用后进先出的顺序维护表,这种情况释放一个块在常数时间内完成。 合并块的时间复杂度是O(1) 。
|