对于像 maclloc 这样的显式分配器,应用通过调用 malloc 和 free 来分配和释放堆块,应用要负责释放所有不再需要的已分配块。
不能及时释放内存堆块可能造成严重的内存错误,例如:内存泄漏,如下列代码所示:
1 | void garbage(){ |
在 garbage 返回之前应该释放 p,不幸的是,程序员忘了释放这个块,它在程序的生命周期内都保持已分配状态。
垃圾收集器(garbage collector)是一种动态内存分配器,它自动释放不再需要的已分配块。这些块被称为垃圾(garbage)。
垃圾收集可以追溯到 John McCarthy 在 20世纪 60 年代早期在 MIT 开发的 Lisp 系统,它是诸如 Java,Perl 等现代语言系统的一个重要部分。接下来讨论 McCarthy 独创的 Mark&Sweep(标记 & 清除)算法。这个算法可以建立在已存在的 malloc 包的基础之上,为 C 和 C++ 程序提供垃圾收集。
垃圾收集器基本知识
垃圾收集器将内存视为一张有向可达图(reachability graph),如下图所示:
该图被分为一组根节点(root node)和一组堆节点(heap node)。每个堆节点对应于堆中的一个已分配块。有向边 p -> q意味着块 p 中的某个位置指向块 q 中的某个位置。根节点对应于这样一种不在堆中的位置,它们包含指向堆中的指针。
当存在任意根节点出发到达 p 的有向路径时,称节点 p 是可达的(reachable)。在任何时刻,不可达节点称为垃圾。垃圾收集器的作用就是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期回收它们。
像 Java 这种语言,对应用如何创建和使用指针有很严格的控制,能够维护可达图的一种精确表示,因此能回收所有垃圾。
对于 C 和 C++ 这样的语言的收集器通常不能维持可达图的精确表示,这样的收集器叫做保守的收集器,即每个可达块都被正确地标记为可达了,而一些不可达节点确可能被错误的标记为可达。
收集器可以按需提供它们的服务,也可以和应用并行的独立线程,不断地更新可达图和回收垃圾。例如下图:考虑如何将一个 C 程序的保守的收集器加入到已存在的 malloc 包中。
无论何时需要堆空间,应用都会调用 malloc ,当空闲块不够用时,就启动垃圾收集器,回收不使用的堆块。如果回收空闲块之后还是不够用,就向操作系统要求额外的内存。执行成功返回指向请求块的指针,失败返回空指针。
Mark & Sweep 垃圾收集器
Mark & Sweep 垃圾收集器由标记阶段和清除阶段组成,标记阶段标记根节点的所有可达的和已分配的后继,清除阶段释放每个未被标记的已分配块。块头部中空闲的低位中的一位通常用来表示这个块是否被标记了。
Mark & Sweep 使用以下函数:ptr 定义为 typedef void* ptr
1 | ptr isPtr(ptr p); // 如果 p 指向已分配块中的某个字,返回指向这个块的起始位置的指针 b,否则返回 NULLL |
1 | void mark(ptr p){ |
1 | void sweep(ptr b, ptr end){ |
标记阶段为每个根节点调用 mark 函数,如果 p 不指向一个已分配并且未被标记的堆块,mark 函数就立即返回,否则就标记这个块,并对块中每个字递归调用自己。每次调用 mark 函数都标记某个根节点的所有未标记并且可达的后继节点。在清除节点,任何未被标记的节点都被认为是不可达的,应当被清除。
C 程序的保守 Mark & Sweep
Mark & Sweep 对 C程序的垃圾收集是一种合适的方法,它可以就地工作,而不需要移动任何块,然而C 语言中 isPtr 函数的实现具有一定的挑战:
- C 不会用任何类型信息来标记内存位置,因此对于 isPtr 没有一种明显的方式来判断它的输入参数 p 是不是一个指针。
- 即使我们知道 p 是一个指针,对 isPtr 也没有明显的方式来判断 p 是否指向一个已分配块的有效载荷中的某个位置。
对于第二个问题,解决方案就是将已分配块集合维护成一棵平衡二叉树,这棵树保持以下属性:左子树的所有块都放在较小的地址处,而右子树的所有块都放在较大的地址处。这就要求每个已分配块的头部里有两个附加字段(left 和 right),每个字段指向某个已分配块的头部。isPtr (ptr p) 用树来执行对已分配块的二分查找,它依赖于块头部中的大小字段来判断 p 是否落在这个块的范围之内。如下图所示:
这种方式从某种意义讲是保守的,因为它可能不正确地标记实际上不可达的块,因此它可能不正确地标记实际上不可达的块,因此它可能不会释放某些垃圾。比如: C语言不会用类型信息来标记内存位置。因此,像 int 或者 float 这样的标量可以伪装成指针,例如,某个可达的已分配块在它的有效载荷中包含一个 int,其值碰巧对应于某个已分配块 b 的有效载荷中的一个地址。对收集器而言,没有办法判断这个数据实际上是 int 而不是指针。因此,分配器必须保守地将块 b 标记为可达,尽管事实上它可能不可达。