堆区的动态内存分配

【前言】前面有一篇文章介绍了堆区栈区的区别。栈区的核心主要集中在操作一个栈结构,一般由操作系统维护。堆区,主要是我们程序员来维护,核心就是动态内存分配。

一、动态内存分配器

    虽然低级的mmap和munmap函数来创建和删除虚拟内存区域,但是C程序运行时在需要额外的存储空间时,一般会使用动态存储器分配器,它维护着一个进程的虚拟存储器区域,称为堆。堆是一个请求二进制零的区域,内核为每个进程维护一个变量 brk ,指向堆的顶部。分配器将堆视为一组不同大小的块,每个块为虚拟存储器的一个连续组块,是已分配的或空闲的。

  有两种分配器。显式分配器要求应用显式地释放任何已分配的块,如C的 malloc/free 和C++的 new/delete 。隐式分配器则由分配器检测不再被使用的已分配块,并释放块,也称为垃圾收集器,Lisp、ML和Java等高级语言使用垃圾收集。

二、显式分配器

C标准库提供了 malloc 程序包作为显式分配器,包括 malloc 、 calloc 、 realloc 、 free 函数。malloc返回一个指针,会自动数据对齐。32系统分配的块的地址总是8的倍数,64位系统是16的倍数。malloc不初始化他返回的内存,calloc将内存初始化为0,realloc改变一个以前分配的大小。

动态存储分配器可以使用 mmap 和 munmap 函数显式地分配和释放堆,还有 sbrk 函数:

#include <unistd.h>

/** 将内核的brk指针增加increment来扩展和收缩堆,increment为0时返回brk当前值
 * @return      返回brk的旧值,出错返回-1,并设errno为ENOMEM */
void *sbrk(intptr_t increment); 

显式分配器有一些约束条件:

  • 能够处理任意(分配和释放)请求的序列,释放请求必须对应以前分配请求分配的块。
  • 立即响应请求。
  • 对齐块,使可以保存任何类型的数据对象,因此大多数系统中分配器返回的块为8字节对齐的。
  • 不修改已分配的块。

     分配器力图做到吞吐量最大化和存储器利用率最大化,在两者之间平衡。吞吐量指单位时间内完成的请求数,一般要求分配请求的最差运行时间和空闲块的数量成线性关系,释放请求的运行时间为常数。描述存储器利用率常用峰值利用率,即请求序列的某个时刻时已分配的总有效载荷和堆的当前大小(为整个请求序列时间的最大值)的比值。

     碎片会造成堆的利用率低,产生于未使用的存储器不能满足分配请求的情况。有内部碎片和外部碎片。内部碎片在已分配块比有效载荷大时发生,比如由于对齐要求。外部碎片在没有单独的空闲块足够满足请求时发生,尽管它们合起来足够大。

     分配器需要处理空闲块的组织,放置、分隔和合并块。实际的分配器会使用一些数据结构来区别块边界,已分配块和空闲块。

三、隐式空闲链表

下图中示意了用隐式空闲链表来组织堆的方式。

/media/note/2012/03/29/virtual-memory/fig7.png

简单的堆块的格式和隐式空闲链表的组织

1、放置块时,分配器搜索空闲链表,常见有首次适配、下一次适配和最佳适配的放置策略。首次适配从头开始搜索空闲链表,下一次适配从链表的上一次查询结束的地方开始搜索,最佳适配检查所有空闲块,选择最小满足的。下一次适配运行最快,但利用率低得多;最佳适配最慢,利用率最高。

2、分配器找到匹配的空闲块后,根据情况可能分割它。如果没有合适的空闲块,合并空闲块来创建更大的空闲块。如果还是不能满足需要,分配器向内核请求额外的堆存储器,转成空闲块加入到空闲链表中。

3、分配器可以选择立即合并或推迟合并,一般为防止抖动,会采用某种形式的推迟合并。

4、合并需要在常数时间内完成,对于空闲链表来说,它是单链表,可以方便地查看后面的块是否空闲块,但前面的块则不行,一个好办法是在块的脚部使用边界标记,它是头部的副本,这样就可以在常数时间查看前后块的类型了。为了避免边界标记占用空间,可以只在空闲块中加边界标记。

四、显式空闲链表

  对于通用的分配器,隐式空闲链表并不适合,因为它的块分配和堆块的总数呈线性关系。可以在空闲块中增加一种显式的数据结构。下面是双向空闲链表的堆块的格式。双向链表使首次适配时间从块总数的线性时间减少到了空闲块数的线性时间。

/media/note/2012/03/29/virtual-memory/fig8.png

双向空闲链表的堆块的格式

显式链表的缺点是空闲块必须足够大来包含结构,这增大了最小块的大小,也潜在提高了内部碎片的程度。

五、分离的空闲链表

分离的空闲链表利用分离存储来减少分配时间。分配器维护一个空闲链表数组,每个空闲链表为一个大小类。大小类的定义方式有很多,如2的幂。有简单分离存储和分离适配方法。

简单分离存储的大小类的空闲链表包含大小相等的块,块大小为大小类中最大元素的大小。分配和释放块都是常数时间,不分割,不合并,已分配块不需要头部和脚部,空闲链表只需是单向的,因此最小块为单字大小。缺点是很容易造成内部和外部碎片。

分离适配的分配器维护一个空闲链表的数组,每个链表和一个大小类相关联,包含大小不同的块。分配块时,确定请求的大小类,对适当的空闲链表做首次适配。如果找到合适的块,可以分割它,将剩余的部分插入适当的空闲链表中;如果没找到合适的块,查找更大的大小类的空闲链表。分离适配方法比较常见,如GNU malloc包。这种方法既快、利用率也高。

六、垃圾收集

垃圾收集器是一种动态存储分配器,自动释放程序不再需要的已分配块(垃圾)。支持垃圾收集的系统中,应用显式分配堆块,但从不显式释放它们。

垃圾收集器将存储器视为一个有向可达图,节点分为根节点和堆节点,堆节点对应堆中的已分配块,根节点对应包含指向堆中的指针但不在堆中的位置,如寄存器、栈里的变量、虚拟存储器中读写数据区域内的全局变量。当存在根节点到p的有向路径时,称p是可达的,不可达节点无法被应用再次使用,即为垃圾。

Java等语言对于创建和使用指针有严格的控制,能够回收所有垃圾。C/C++语言的垃圾收集器通常不能维护可达图的精确表示,称为保守的垃圾收集器,它不能回收所有垃圾。

七、和存储器有关的错误

在使用C语言和虚拟存储器打交道时,很容易犯一些错误,而且它们常常是致命的。

  • 间接引用坏指针。间接引用指向空洞或只读区域的指针,会造成段异常或保护异常而终止。
  • 读未初始化的存储器。.bss存储器位置总是被加载器初始化为0,但堆存储器不是这样,假定它为0会造成不可预料的结果。
  • 允许栈缓冲区溢出。不检查串的大小就写入栈中的目标缓冲区可能会有缓冲区溢出错误。
  • 假设指针和指向的对象大小相同。这可能会导致分配器的合并代码失败,但没有明显的原因。
  • 造成错位错误。如超出循环造成覆盖错误。
  • 引用指针,而不是指向的对象。
  • 误解指针运算。指针的算术操作是以指向的对象的大小为单位进行的,而不是字节。
  • 引用不存在的变量。比如栈中的局部变量,栈弹出后它就不再合法了。
  • 引用空闲堆块中的数据。和上一个类似,这回发生在被释放的堆中。
  • 引起存储器泄漏。忘记释放已分配块,产生垃圾,对于不终止的程序(守护进程、服务器),存储器泄漏的错误非常严重。

猜你喜欢

转载自www.cnblogs.com/huangfuyuan/p/9190371.html