首页 > 代码库 > linux内存管理之heap篇
linux内存管理之heap篇
文章来源——博客园绿色冰点
前几次我们分析了Linux系统中用户进程的4G虚存大致分为了几个部分,介绍了3G用户空间中数据段,代码段等静态区域的虚存管理,重点分析了栈的使用。这次我们来分析一下虚存使用中另一个重要部分--堆。前面的介绍中,我们知道编译器,操作系统担负着大量栈分配管理的工作。不论是静态分配的栈空间还是用户动态分配的栈空间,在函数返回的时候就自动释放了。堆的使用比之栈而言更为灵活,允许程序员动态的分配并释放,但也意味着,堆的使用需要程序员更为小心。
4.5 堆的内存管理
在学习"数据结构"的时候,我们知道堆,栈都是基本的数据结构。但是在内存管理的时候,虽然我们常常将堆区和栈区放到一起来说,但其实他们在很多方面都存在着不同。栈区内确实是栈数据结构,并且由计算机硬件,操作系统,以及编译器配合完成,是计算机运行的基本数据结构。在汇编语言中,我们常说的"堆栈",其实就是指的栈。堆区其实指的是在程序运行过程中动态分配的内存区域,它的管理通常在函数库中完成。之所以叫做堆是因为通常是使用堆这种数据结构来管理分配的内存。换句话说,其实也可以用任何的数据结构来管理,甚至是一个简单的链表。之所以用堆,是因为在速度,空间利用,和可调节性上,堆有着其自己的优势。
4.5.1 堆管理的相关库函数
在ISO C中规定了三个动态分配内存的函数,分别是:
void *malloc(size_t size);
void *calloc(size_t nmemb, size_t size);
void *realloc(void *ptr, size_t size);
在这三个库函数中,大家最常用的就是malloc。调用malloc函数可以分配长度为size的内存空间,内存空间的数据没有初始化。其返回值就是指向这段被分配空间的指针。calloc和malloc相似,只不过返回的是一个有nmemb个元素的数组,每个元素的大小是size bytes。也就是分配了nmemb*size大小的内存空间,并将空间内的数据都初始化为0。
realloc是一个比较奇妙的函数,它能将ptr指向的内存块改为size bytes(ptr由先前malloc,calloc,realloc函数返回)。如果size比以前ptr指向的内存块大,则会增加分配一块内存,新增的内存块没有初始化。如果size比以前的内存小,则会删除一块内存。而保留下来的旧内存里的数据则不会有变化。如果ptr==NULL,则realloc等价于malloc函数,而如果size==0,则realloc等价于free(ptr)函数。realloc的返回值要特别注意。realloc的作用,是对ptr指向的内存大小进行重新调整,但是调整之后的内存空间和原来的内存空间可能不是同一内存地址。也就是说ptr指向的内存块因大小调整被移动了。所以要把realloc返回的地址指针重新赋值给ptr,即:
ptr = realloc(ptr,size);
free函数是被用于释放被分配内存的函数:
void free(void *ptr);
4.5.2 堆管理的相关系统调用
malloc系列函数的实现与Linux中提供的两个基本调用是分不开的:
int brk(void *end_data_segment);
void *sbrk(intptr_t increment);
brk: brk()的作用和它的名字一样用于打破系统给进程设置的访存限制,用于设定进程的内存边界。如前文所述,堆是从虚存低地址向高地址增长。brk()用于设定堆访存的上限,也就是堆顶。就像是一个盖子,随着堆的分配释放而上下移动。在这个盖子之下的内存空间,操作系统都认为是合法的。与brk()相关的还有一个sbrk()函数,sbrk()不是系统调用,而是一个库函数。sbrk(+/-n)意味着将当前访存的上限增加/减少n个字节。
void *mmap(void *addr, size_t len, int prot, int flags, int fildes, off_t off);
int munmap(void *addr, size_t len);
mmap: mmap()的使用较brk()更为灵活,用途也更为广泛。可以将虚拟内存地址映射到文件,共享内存等,方便用户以访存的方式读写文件,完成进程间通信。当然映射后虚存地址就变为合法的了。所以在堆分配的时候,常常借用mmap能向进程添加可访问虚存空间的能力,加之并不需要读写文件等别的要求,所以一般用匿名映射(MAP_ANONYMOUS)来完成。munmap与之所做的事情相反,常用以释放mmap分配的虚存。
4.5.3 堆的内部管理
对于程序员而言,主要是通过malloc/free来使用动态分配的内存。malloc的实现方式有很多,Glibc中使用的是Doug Lea和Wolfram Gloger实现的版本(dlmalloc),此外还有phkmalloc,Solaris上的malloc等。当然你也完全可以自己实现一个简单的malloc。无论实现版本怎样malloc包含着两部分的内容:内存分配和内存管理。
4.5.3.1 堆空间内存分配
当malloc()分配内存的时候,首先会先调用上面提到的brk()或者mmap()来向操作系统申请一块内存。其实也就是让操作系统知道这块内存的虚存地址是有效的。在使用这些虚存地址的时候为其分配相应的物理内存,而不是报Segmentation fault.
...
int *l = sbrk(0);
k=l+1023;
printf("k=%d,at %p\n",*k,k);
...
运行程序将会抛出:
Segmentation fault
如果改为:
...
int *l = sbrk(0);
sbrk(1);
k=l+1023;
printf("k=%d,at %p\n",*k,k);
...
程序将正常运行,并输出:
k=100,at 0x804affc
第一段代码出错是因为程序访问了还没分配的内存,超过了当前堆的上限。第二段代码使用了sbrk(1)动态分配了内存,所以访问就成功了。注意虽然这里sbrk(1),表面上只把当前堆增加了1个字节。但是因为系统的内存分配是以页为单位的,当前堆实际增加了4KB, 因此对k = l+1023的访问也是合法的。
brk()和mmap()虽然在内存分配的时候用途一样,但是各有各的优点,每次brk()的虚存空间是连续的,便于合并,重用,并更为节省页对齐浪费的空间,但是可能形成内存空洞(见下文),适合较小的内存分配。mmap()不会像brk()那样形成空洞,但不能复用,合并。且开销和具体的平台相关,并会把分配的内存初始化为0,所以适合大空间的分配。在dlmalloc中,如果malloc分配的内存小于128KB, 使用brk()来增加进程使用的内存。如果分配的内存大于等于128KB,则使用mmap()来分配内存(128KB这个值在不同的平台上是可调的)。
下面来看一个例子:
...
int *heap_var = malloc(sizeof(int)); //较小的内存块分配请求
int *large_var = malloc(256*1024); //较大的内存块分配请求
printf("Address of heap_var (Heap):%p\n",heap_var);
printf("Address of large_var (Heap):%p\n",large_var);
...
输出结果为:
Address of heap_var (Heap):0x804a008
Address of large_var (Heap):0xb7db2008
如果用strace命令跟踪,可以发现这段代码执行了如下的系统调用:
brk(0x806b000) = 0x806b000
mmap2(NULL, 266240, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb7db2000
我们可以清楚地看到,对于较小的内存分配,使用了brk()系统调用,对于较大的内存块分配请求,使用了mmap系统调用。并且我们发现这两个地址相差较远,所以堆区常又被分为两个部分,一个是brk分配的内存,通常位于低地址。另一个是mmap分配的内存,也叫地址映射区,通常位于高地址。当然用不同系统调用分配的内存,也可以混合管理,这取决于具体的实现。
4.5.3.2 堆空间的内存管理
接下来就是要对用brk和mmap分配好内存进行管理了。因为brk(),mmap()是系统调用,如果每次调用malloc动态分配内存都执行一次系统调用,那开销是比较大的。再者,如果每次申请的内存较小,但是系统分配的内存都是固定大小的倍数(一般是4KB,一页),这样就会有大量的浪费。所以malloc一般会实现一个内存堆来管理这些内存,malloc分配的内存都会以若干chunk的方式放到内存堆中。每次用户调用malloc动态分配内存的时候,malloc会先到内存堆里进行查找,如果内存堆里没有合适的空闲chunk,再利用brk/malloc系统调用分配一大块内存,然后把新分配的大块内存放到内存堆中,并生成一块合适的chunk块返回给用户。当用户用free释放chunk的时候,可能并不立即使用系统调用释放内存,而是将释放的chunk作为空闲chunk加入内存堆中,和其他的空闲chunk合并,便于下次分配的时候再次使用。
一般说来,释放的chunk如果标记为mmap申请的,则使用munmap释放。如果是brk申请的,进一步判断堆顶之下的空闲chunk是否大于128KB,如果是,则使用brk()释放。如果小于128KB,仍由内存堆维护。这样对brk()的使用就会有个问题,当brk()释放的内存块在堆顶之下,且内存块到堆顶之间还有未释放的内存。那么这块内存的释放将不会成功,从而形成内存空洞。
malloc中为每块chunk都会分配一个数据结构用于管理,也就是chunk head。chunk head有多大?我们来看看malloc(0)时的情况。
...
int *heap_var = malloc(0);
int *heap_var1 = malloc(0);
printf("Address of heap_var: %p\n",heap_var);
printf("Address of heap_var1: %p\n", heap_var1);
...
这段代码的输出为:
Address of heap_var: 0x804a008
Address of heap_var1: 0x804a018
两者指向的位置相差了16个字节,可以看出,对于malloc(0),也会分配16个字节供chunk head使用,即便这个chunk内包含的内存大小为0。而在c99标准中则对malloc(0)的返回未定义。chunk head中记录的一个很重要的信息就是当前chunk的大小。当malloc一块chunk的时候,malloc的内存大小就存放在chunk head中,释放的时候通过地址指针,找到相应块的chunk_head,从而知道要释放的chunk大小。这也是为什么我们在malloc的时候需要指定分配内存的大小,而释放的时候只需要给出释放内存的地址指针就行了。如果free(p)时的指针不是malloc时得到的,那么malloc就会报Segmentation fault,或者./chunk: free(): invalid pointer。
4.5.4 堆物理内存的使用
堆的使用和栈的使用一样,都是虚存中的概念。堆物理内存的使用和栈也一样,采用了延迟分配策略。只有当真正使用虚存的时候才分配相应的物理内存。如:
...
int *large_var = malloc(4*1024*1024);
free(large_var);
...
查看/proc/pid/statm,第一列为虚拟内存大小,第二列是进程所使用的物理内存大小,都是以页面(4k)为单位。
malloc之前: 342 78 63 1 0 27 0
malloc之后;1367 86 70 1 0 1052 0
free之后: 342 85 70 1 0 27 0
可以看到,malloc之后因为large_var没有被使用,所以虽然虚拟内存增加了1000多个页面(约4M),但是物理内存只增加了几个页面。
如果程序改为:
...
int *large_var = malloc(4*1024*1024);
memset(large_var,0,4*1024*1024);
free(large_var);
...
再次查看/proc/pid/statm,结果为:
malloc之前: 343 78 63 1 0 28 0
malloc之后: 1368 1110 70 1 0 1053 0
free之后: 343 85 70 1 0 28 0
因为用memset使用了分配的内存,所以这次不仅虚存增加了1000多个页面,物理内存相应也增加了1000多个页面。
4.5.5 内存泄漏
在堆的使用过程中,一个很重要的问题就是"内存泄漏"。也就是malloc出来的内存,在不使用之后,用户未能及时调用free释放。因为虚存没有释放,相应的物理内存也没有释放,内存泄漏的堆积最终将耗尽系统所有的内存。为了克服内存泄漏问题,Small Pointer, Garbage Collection等技术被大量的研究和使用。但最有效的办法还是在编写程序的时候时刻留意这个问题,小心处理每一次malloc操作。但是"内存泄漏"只是运行时问题,当进程结束的时候,操作系统就会收回所有分配给进程的内存。
小结:
1. 无论是堆,还是栈都是对虚存的操作和管理。
2. 系统调用brk()和mmap()用来动态分配虚存空间,也就是表明这些虚存地址是合法的,访问的时候,系统应为其分配物理内存,而不是报错。
3. 堆的本质是动态申请的虚存空间。理论上可以用任何方式去管理这块空间。但数据结构--"堆"是最常用的一种,所以这块分配的空间常称为被堆。
4. 和栈不一样,堆的管理是在用户函数库中进行,malloc/free等函数是堆的入口。
5. 每次分配的内存块大小都会被记录下来,释放的时候只需要指定要释放的内存地址就行了。这就是为什么malloc的时候要指定大小,free的时候不用。
6. 堆和栈一样,仍然使用了物理内存的延迟分配策略。