阅读 208

从零开始,手写GC算法 | 标记-清除【附完整可运行源码】

看了一大堆的GC算法理论,是不是还是觉得差点什么呢?那就跟着本文的思路,自己动手写个标记-清除算法试试吧

首个值得纪念的 GC 算法就是 GC 标记 - 清除算法(Mark-Sweep GC)。自其问世以来,一直到半个世纪后的今天,它依然是各种处理程序所用的伟大的算法。

GC 标记 - 清除算法由标记阶段和清除阶段构成。

标记阶段是把所有活动对象(可达对象,reachable)都做上标记的阶段。清除阶段是把那些没有标记的对象,也就是非活动对象回收的阶段。通过这两个阶段,就可以复用已释放的空间。

本文内容主要参考《垃圾回收的算法与实现》 ,使用 C 语言实现;文末附有源码地址,完整可运行

名词解释

对象

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

指针

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

mutator

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

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

mutator 的工作有以下两种:

  • 生成对象
  • 更新指针

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

GC ROOTS

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

堆 (Heap)

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

活动对象和非活动对象

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

准备工作

在标记清除算法中,使用空闲链表(free-list)的内存分配策略

空闲链表 (free-list) 内存分配

空闲链表分配使用某种数据结构(一般是链表)来记录空闲内存单元的位置和大小,该数据结构即为空闲内存单元的集合。

在需要分配内存时,顺序遍历每一个内存单元,找到第一个空闲的内存单元使用。

在本文中,为了降低复杂度,只使用了最基本的 空闲链表(free-list)分配法,free-list 数据结构如下图所示:

image.png

为了实现简单,在本文代码中,每个单元只存储一个对象,不考虑单元拆分合并等问题

数据结构设计

首先是对象类型的结构:

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

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;//标记对象是否可达(reachable)
} object;

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

复制代码

free-list 结构设计

struct _node {
    node *next;
    byte used;//是否使用
    int size;//单元大小
    object *data;//单元中的数据
};

复制代码

有了基本的数据结构,下面就可以进行算法的实现了,以下执行 GC 前堆的状态图:

image.png

算法实现

创建对象 & 内存分配

根据前面介绍的 free-list 内存分配策略,在新建对象时只需要搜索出空闲内存单元即可:

node *find_idle_node() {
    for (next_free = head; next_free && next_free->used; next_free = next_free->next) {}

    //还找不到就触发回收
    if (!next_free) {
        gc();
    }

    for (next_free = head->next; next_free && next_free->used; next_free = next_free->next) {}

    //再找不到真的没了……
    if (!next_free) {
        printf("Allocation Failed!OutOfMemory...\n");
        abort();
    }
}

复制代码

在找到的空闲内存单元中分配新对象,并初始化

object *gc_alloc(class_descriptor *class) {

    if (!next_free || next_free->used) {
        find_idle_node();
    }

    //赋值当前freePoint
    node *_node = next_free;

    //新分配的对象指针
    //将新对象分配在free-list的节点数据之后,node单元的空间内除了sizeof(node),剩下的地址空间都用于存储对象
    object *new_obj = (void *) _node + sizeof(node);
    new_obj->class = class;
    new_obj->marked = FALSE;

    _node->used = TRUE;
    _node->data = new_obj;
    _node->size = class->size;

    for (int i = 0; i < new_obj->class->num_fields; ++i) {
        //*(data **)是一个dereference操作,拿到field的pointer
        //(void *)o是强转为void* pointer,void*进行加法运算的时候就不会按类型增加地址
        *(object **) ((void *) new_obj + new_obj->class->field_offsets[i]) = NULL;
    }
    next_free = next_free->next;

    return new_obj;
}

复制代码

GC 代码,当分配新对象并且可用内存不足时调用该方法

void gc() {
    for (int i = 0; i < _rp; ++i) {
        mark(_roots[i]);
    }
    sweep();
}

复制代码

标记阶段

标记阶段,要从 GC ROOTS 开始,遍历对象图(graph),对所有可达(reachable)的对象打上标记

for (int i = 0; i < _rp; ++i) {
    mark(_roots[i]);
}

复制代码

标记的代码逻辑很简单,就是递归查找对象并标记

void mark(object *obj) {
    //避免重复标记,因为一个对象可能被引用多次
    if (!obj || obj->marked) { return; }
    //给对象打上标记
    obj->marked = TRUE;
    //递归标记对象的引用
    //通过对象的field_offsets访问对象的引用对象
    for (int i = 0; i < obj->class->num_fields; ++i) {
        mark(*((object **) ((void *) obj + obj->class->field_offsets[i])));
    }
}


复制代码

从上面的代码逻辑可以得出,标记阶段的耗时和堆大小无关,耗时和存活对象的数量成正比

下图是标记结束后,堆的状态:

image.png

清除阶段

清除阶段需要遍历全堆(这里是遍历 free-list),清除所有没有标记的对象并回收对应的内存单元

void sweep() {
    for (node *_cur = head; _cur && _cur; _cur = _cur->next) {
        if (!_cur->used)continue;
        object *obj = _cur->data;
        if (obj->marked) {
            obj->marked = FALSE;
        } else {
            //回收对象所属的node
            memset(obj, 0, obj->class->size);

            //通过地址计算出,对象所在的node
            node *_node = (node *) ((void *) obj - sizeof(node));
            _node->used = FALSE;
            _node->data = NULL;
            _node->size = 0;

            //将next_free更新为当前回收的node
            next_free = _node;
        }
    }

}

复制代码

清除阶段后,堆的状态如下图所示:

image.png

缺点

标记阶段的耗时和堆大小无关,耗时和存活对象的数量成正比。如果存活对象很少,那么在标记阶段的开销就有点大了。

所以会有分代回收算法,根据对象特点进行分代,每代执行不同的回收算法。本文中的清除算法就不适用于“年轻代”,因为年轻代每次存活少,清除算法中要清除大量的对象,更适合存活多的“老年代“,需要清除的对象足够少

由于本文没有实现 free-list 中空闲单元的拆分与合并,所以没有涉及内存碎片化 (fragmentation) 问题.

如果实现空闲单元拆分合并的话,可能会导致不断的拆分后,出现无数的小分散单元遍布整个堆,造成极大的内存浪费,并且增加 free-list 的扫描时间。

完整代码

github.com/kongwu-/gc_…

参考

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

原创不易,转载请在开头著名文章来源和作者。如果我的文章对您有帮助,请点赞/收藏/关注鼓励支持一下吧❤❤❤❤❤❤

文章分类
后端
文章标签