从零开始,手写GC算法 | 整理算法| 附完整可运行源码

3,097 阅读5分钟

这是我参与更文挑战的第 8 天,活动详情查看: 更文挑战

GC 标记 - 整理(也称压缩)算法(Mark Compact GC)是将 GC 标记 - 清除算法与 GC 复制算法相结合的产物。

本文实现的是Donald E. Knuth研究出来的 Lisp2 算法,基于C语言

在标记 - 整理算法中,标记阶段和标记 - 清除算法中的的标记阶段完全一样;然后对堆进行几次搜索来整理活动对象。

整理算法也是移动式的算法,不会有碎片化的问题,并且和复制算法相比不用牺牲半个堆的空间

名词解释

对象

对象在GC的世界里,代表的是数据集合,是垃圾回收的基本单位。

指针

可以理解为就是C语言中的指针(又或许是handle),GC是根据指针来搜索对象的。

mutator

这个词有些地方翻译为赋值器,但还是比较奇怪,不如不翻译……

mutator 是 Edsger Dijkstra 琢磨出来的词,有“改变某物”的意思。说到要改变什么,那就是 GC 对象间的引用关系。不过光这么说可能大家还是不能理解,其实用一句话概括的话,它的实体就是“应用程序”。

mutator的工作有以下两种:

  • 生成对象
  • 更新指针

mutator 在进行这些操作时,会同时为应用程序的用户进行一些处理(数值计算、浏览网页、编辑文章等)。随着这些处理的逐步推进,对象间的引用关系也会“改变”。伴随这些变化会产生垃圾,而负责回收这些垃圾的机制就是 GC。

GC ROOTS

GC ROOTS就是引用的起始点,比如栈,全局变量

堆(Heap)

堆就是进程中的一段动态内存,在GC的世界里,一般会先申请一大段堆内存,然后mutatar在这一大段内存中进行分配

活动对象和非活动对象

活动对象就是能通过mutatar(GC ROOTS)引用的对象,反之访问不到的就是非活动对象。

准备工作

在标记-整理算法中,使用顺序内存分配(sequential allocation)策略,顺序分配流程如下图所示

维护一个free pointer,每次分配内存后移动该指针,limit-free的就是当前堆中可用内存的大小

数据结构设计

首先是对象类型的结构:

为了动态访问“对象”的属性,此处使用属性偏移量来记录属性的位置,然后通过指针的计算获得属性

typedef struct class_descriptor {
    char *name;//类名称
    int size;//类大小,即对应sizeof(struct)
    int num_fields;//属性数量
    int *field_offsets;//类中的属性偏移,即所有属性在struct中的偏移量
} class_descriptor;

然后是对象的结构,虽然C语言中没有继承的概念,但是可以通过共同属性的struct来实现:

typedef struct _object {
    class_descriptor *class;//对象对应的类型
    byte marked;//是否可达
    object *forwarding;//目标位置
} object;
//继承
//"继承对象"需和父对象object基本属性保持一致,在基本属性之后,可以定义其他的属性
typedef struct emp {
    class_descriptor *class;//对象对应的类型
    byte marked;//是否可达
    object *forwarding;//目标位置
    int id;
    dept *dept;
} emp;

有了基本的数据结构,下面就可以进行算法的实现了

算法实现(Lisp2)

Lisp2 算法在对象头里为 forwarding 指针留出了空间。这里的forwarding 指针跟 GC 复制算法中的用法一样。

假设我们要在下面这种情况下执行 GC

标记

首先是标记阶段,标记-整理中的标记算法和标记-清除中一致;标记阶段结束后的堆状态如下图:

mark代码:

void mark(object *obj) {
    if (!obj || obj->marked) { return; }

    obj->marked = TRUE;
    printf("marking...\n");
    //递归标记对象的引用
    for (int i = 0; i < obj->class->num_fields; ++i) {
        mark(*((object **) ((void *) obj + obj->class->field_offsets[i])));
    }
}

整理

整理代码:

void compact() {
    set_forwarding();
    adjust_ref();
    move_obj();
}

整理阶段分为三个步骤:

计算并设置整理后的对象forwarding指针

void set_forwarding() {
    int p = 0;
    int forwarding_offset = 0;

    //遍历堆的已使用部分,这里不用遍历全堆
    //因为是顺序分配法,所以只需要遍历到已分配的终点即可
    while (p < next_free_offset) {
        object *obj = (object *) (p + heap);

        //为可达的对象设置forwarding
        if (obj->marked) {

            obj->forwarding = (object *) (forwarding_offset + heap);

            forwarding_offset = forwarding_offset + obj->class->size;
        }
        p = p + obj->class->size;
    }
}

调整对象的引用为移动后的地址

如上图所示,调整引用后,gc roots和其他对象的引用都已经更新为了预先计算的forwarding指针

void adjust_ref() {
    int p = 0;

    //先将roots的引用更新
    for (int i = 0; i < _rp; ++i) {
        object *r_obj = _roots[i];
        _roots[i] = r_obj->forwarding;
    }

    //再遍历堆,更新存活对象的引用
    while (p < next_free_offset) {
        object *obj = (object *) (p + heap);

        if (obj->marked) {
            //更新引用为forwarding
            for (int i = 0; i < obj->class->num_fields; i++) {
                object **field = (object **) ((void *) obj + obj->class->field_offsets[i]);
                if ((*field) && (*field)->forwarding) {
                    *field = (*field)->forwarding;
                }
            }

        }
        p = p + obj->class->size;
    }
}

移动对象

void move_obj() {
    int p = 0;
    int new_next_free_offset = 0;
    while (p < next_free_offset) {
        object *obj = (object *) (p + heap);

        if (obj->marked) {
            //移动对象至forwarding
            obj->marked = FALSE;
            memcpy(obj->forwarding, obj, obj->class->size);
            new_next_free_offset = new_next_free_offset + obj->class->size;
        }
        p = p + obj->class->size;
    }

    //清空移动后的间隙
    memset((void *)(new_next_free_offset+heap),0,next_free_offset-new_next_free_offset);

    //移动完成后,更新free pointer为新的边界指针
    next_free_offset = new_next_free_offset;

}

通过上图我们能够确认,整理后,活动对象 B、C、D、F 分别对应整理后的BꞋ 、CꞋ、DꞋ 、FꞋ 。在 Lisp2 算法中,整理阶段并不会改变对象的排列顺序,只是缩小了它们之间的空隙,把它们聚集到了堆的一端。

以上就是对标记-整理算法的说明

优点

没有碎片化问题,而且可以利用整个堆,不用像复制算法那样将堆一分为二

缺点

整理成本过高,在上述实现中,对堆进行了3次搜索。也就是说该算法的时间花费是和堆大小成正比的,和存活对象数量无关

完整代码

github.com/kongwu-/gc_…

相关文章

参考

  • 《垃圾回收的算法与实现》 中村成洋 , 相川光 , 竹内郁雄 (作者) 丁灵 (译者)
  • 《垃圾回收算法手册 自动内存管理的艺术》 理查德·琼斯 著,王雅光 译