垃圾回收是编程中鲨鱼出没最多的领域之一,但是在这篇文章中,我会给你一个不错的儿童游泳池,你可以在里面游泳。 (可能还有鲨鱼在里面,但至少它会比较浅。)
垃圾回收背后的基本思想是,该语言(在大多数情况下)似乎可以访问无限内存。 开发人员只需要保持分配、分配和分配,就像魔术一样,它永远不会失败。
当然,机器没有无限的内存。 所以实现的方式是,当它正在运行时内存空间不够并需要分配一点内存,它会收集垃圾。
此上下文中的“垃圾”是指它以前分配的不需要再被使用的内存。 为了让无限记忆的想法实现,语言必须在处理“不再被使用”内存时非常安全。 如果随机对象在程序试图访问它们时就开始被回收,那就没有意思了。
为了能够实现可回收,这种语言必须确保程序无法再次使用这个对象。 如果它不能得到对象的引用,那么它显然不能再次使用它。 因此,“在用”的定义实际上非常简单:
任何由作用域中的变量引用的对象都在使用中。 任何被另一个正在使用的对象引用的对象都在使用中。 复制代码
第二条规则是递归规则。
如果对象 a 被一个变量引用,并且它有一些引用对象 b 的字段,那么 b 就被使用了,因为你可以通过 a 找到它。 复制代码
最终的结果是一个可到达所有对象的引用图,你可以从一个变量开始遍历所有对象。 对于程序来说,任何不在这个图中的对象都是死的,它的内存已经过期,可以收获了。
有许多不同的方法可以实现查找和回收所有未使用对象的过程,但有史以来为此发明的最简单也是最早的算法被称为“标记-清除”。 它是由 John McCarthy 发明的,他发明了 Lisp 和 beards,所以现在实现垃圾回收器就像是在和一个老神交流,但是希望不是以 Lovecraftian 的方式,以你的头脑和视网膜被炸得干干净净结束。
“标记-清除”的工作原理几乎和我们对可达性的定义一模一样:
从根开始,遍历整个对象图。 每次你到达一个对象时,在它上面设置一个“标记”位为 true。
完成之后,找到所有标记位未设置的对象并删除它们。
在我们开始实现这两个步骤之前,让我们先做一些准备工作。 我们实际上不会为某种语言实现一个解释器 ーー 没有解析功能、字节码或其他任何愚蠢的东西ーー但我们确实需要一些最少量的代码来创建一些垃圾来进行收集。
让我们假设我们正在为一种小语言写一个解释器。 它是动态类型的,有两种类型的对象: int 和 pairs。 这里有一个枚举来标识一个对象的类型:
typedef enum { OBJ_INT, OBJ_PAIR } ObjectType; 复制代码
一个 pair 可以是任意的一对,两个 int、一个 int 和另一个 pair ... 。 仅凭这一点,你就可以走得非常远。 由于 VM 中的对象可以是这两种中的任何一种,因此 c 中实现它的典型方法是使用带标记的联合(union)。
我们就这样定义它:
typedef struct sObject { ObjectType type; union { /* OBJ_INT */ int value; /* OBJ_PAIR */ struct { struct sObject* head; struct sObject* tail; }; }; } Object; 复制代码
主对象结构有一个类型字段,用于标识它的值类型 ー int 或 pair。然后它有一个联合来保存 pair 或 int 的数据。 如果您对 c 语言不熟悉,那么 union 就相当于是一个结构体,其中字段在内存中重叠。 因为给定的对象只能是 int 或者 pair,所以没有理由同时在一个对象中为所有三个字段保留内存。 union 可以做到这一点,太棒了。
现在我们可以将它包装在一个小的虚拟机结构中。它在这个故事中的角色是拥有一个堆栈,用于存储当前作用域中的变量。大多数语言虚拟机要么基于堆栈(如 JVM 和 CLR) ,要么基于寄存器(如 Lua)。在这两种情况下,实际上仍然有一个堆栈。 它用于存储表达式中间所需的局部变量和临时变量。
我们将这样简单明了地建模:
#define STACK_MAX 256 typedef struct { Object* stack[STACK_MAX]; int stackSize; } VM; 复制代码
现在我们已经准备好了基本的数据结构,让我们组合一些代码来创建一些东西。 首先,让我们编写一个函数来创建并初始化一个 VM:
VM* newVM() { VM* vm = malloc(sizeof(VM)); vm->stackSize = 0; return vm; } 复制代码
一旦我们有了一个 VM,我们需要能够操纵它的堆栈:
void push(VM* vm, Object* value) { assert(vm->stackSize < STACK_MAX, "Stack overflow!"); vm->stack[vm->stackSize++] = value; } Object* pop(VM* vm) { assert(vm->stackSize > 0, "Stack underflow!"); return vm->stack[--vm->stackSize]; } 复制代码
好,现在我们可以把东西放进堆栈中,我们需要能够真正创建对象。 首先是一个小小的帮助函数:
Object* newObject(VM* vm, ObjectType type) { Object* object = malloc(sizeof(Object)); object->type = type; return object; } 复制代码
它执行实际的内存分配并设置类型标记。我们稍后将重新讨论这个问题。使用这个,我们可以编写函数将每种类型的对象推送到 VM 的堆栈中:
void pushInt(VM* vm, int intValue) { Object* object = newObject(vm, OBJ_INT); object->value = intValue; push(vm, object); } Object* pushPair(VM* vm) { Object* object = newObject(vm, OBJ_PAIR); object->tail = pop(vm); object->head = pop(vm); push(vm, object); return object; } 复制代码
这就是我们的小 VM。 如果我们有一个解析器和一个解释器来调用这些函数,那么我们手头上就会有一个实实在在的语言。 而且,如果我们有无限的内存,它甚至可以运行真正的程序。 既然没有,我们就开始收集垃圾吧。
第一个阶段是标记。我们需要遍历所有可到达的对象并设置它们的标记位。我们需要做的第一件事就是给 Object 添加一个标记位:
typedef struct sObject { unsigned char marked; /* Previous stuff... */ } Object; 复制代码
当我们创建一个新对象时,我们将修改 newObject() 函数将初始化标记设置为零。为了标记所有可访问的对象,我们从内存中的变量开始,这意味着遍历堆栈。类似是这样的:
void markAll(VM* vm) { for (int i = 0; i < vm->stackSize; i++) { mark(vm->stack[i]); } } 复制代码
我们将分阶段构建它。第一步:
void mark(Object* object) { object->marked = 1; } 复制代码
毫不夸张地说,这是最重要的部分。我们已经将对象本身标记为可达的,但是请记住,我们还需要处理对象中的引用: 可达性是递归的。 如果对象是一对,它的两个字段也是可到达的。 处理这个问题很简单:
void mark(Object* object) { object->marked = 1; if (object->type == OBJ_PAIR) { mark(object->head); mark(object->tail); } } 复制代码
但是这里有个漏洞。 你看到了吗? 我们现在正在一个循环中,但是我们没有检查循环的机制。如果在一个循环中有一堆指向彼此的对,则会溢出堆栈并崩溃。
为了处理这个问题,我们只需要在到达一个已经处理过的对象时退出。所以完整的 mark() 函数的实现为:
void mark(Object* object) { /* If already marked, we're done. Check this first to avoid recursing on cycles in the object graph. */ if (object->marked) return; object->marked = 1; if (object->type == OBJ_PAIR) { mark(object->head); mark(object->tail); } } 复制代码
现在我们可以调用 markAll () ,它将正确地标记内存中每个可到达的对象!
下一步是扫描我们分配的所有对象,并释放任何没有标记的对象。但是这里有一个问题: 根据定义,所有未标记的物体都是不可到达的! 我们接近不了他们!
Vm 已经为对象引用实现了语言的语义: 因此我们只在变量和 pair 元素中存储指向对象的指针。一旦一个对象不再被这些对象中的任何一个指向,我们就完全失去了它,并且实际上泄漏了内存。
解决这个问题的诀窍在于,VM 可以拥有自己的对象引用,这些对象与语言用户可见的语义不同。 换句话说,我们可以自己跟踪他们。
要做到这一点,最简单的方法就是维护我们分配的每个对象的链接列表。 我们将扩展 Object 本身作为列表中的节点:
typedef struct sObject { /* The next object in the list of all objects. */ struct sObject* next; /* Previous stuff... */ } Object; The VM will keep track of the head of that list: 复制代码
虚拟机将跟踪这个列表的头部:
typedef struct { /* The first object in the list of all objects. */ Object* firstObject; /* Previous stuff... */ } VM; 复制代码
在 newVM() 函数中,我们将确保初始化 firstObject 为 NULL。 每当我们创建一个对象,我们把它添加到列表中:
Object* newObject(VM* vm, ObjectType type) { Object* object = malloc(sizeof(Object)); object->type = type; object->marked = 0; /* Insert it into the list of allocated objects. */ object->next = vm->firstObject; vm->firstObject = object; return object; } 复制代码
这样,即使语言找不到对象,语言的实现仍然可以。 要清除和删除未标记的对象,我们只需遍历列表:
void sweep(VM* vm) { Object** object = &vm->firstObject; while (*object) { if (!(*object)->marked) { /* This object wasn't reached, so remove it from the list and free it. */ Object* unreached = *object; *object = unreached->next; free(unreached); } else { /* This object was reached, so unmark it (for the next GC) and move on to the next. */ (*object)->marked = 0; object = &(*object)->next; } } } 复制代码
由于那个指向指针的指针,这段代码有点难以阅读,但是如果你仔细研究一下,就会发现它非常简单。它只是遍历整个链表。每当它命中一个没有标记的对象时,它就释放内存并将其从列表中删除。当这样做,我们将删除每一个不可到达的对象。
祝贺你!我们有一个垃圾回收器!只是还少了一点: 真正的调用它。首先让我们把这两个阶段结合在一起:
void gc(VM* vm) { markAll(vm); sweep(vm); } 复制代码
您不能要求更明显的标记-清除实现。 最棘手的部分是确定什么时候调用这个函数。 “内存不足”到底是什么意思,尤其是在具有近乎无限虚拟内存的现代计算机上?
事实证明,这个问题没有明确的正确或错误答案。这实际上取决于您使用 VM 的目的是什么,以及它运行在什么样的硬件上。为了保持这个示例的简单性,我们只需要在一定数量的分配之后收集。这实际上就是一些语言实现的工作方式,而且很容易实现。
我们将扩展 VM 来跟踪我们创建了多少对象:
typedef struct { /* The total number of currently allocated objects. */ int numObjects; /* The number of objects required to trigger a GC. */ int maxObjects; /* Previous stuff... */ } VM; 复制代码
然后初始化它们:
VM* newVM() { /* Previous stuff... */ vm->numObjects = 0; vm->maxObjects = INITIAL_GC_THRESHOLD; return vm; } 复制代码
INITIAL_GC_THRESHOLD 是将要启动第一个 gc() 函数时的对象数。越小的数字在内存方面越保守,越大的数字花在垃圾收集上的时间越少。根据需求调整。
每当我们创建一个对象,我们都会增加 numObjects ,如果它是否达到最大值就运行一次回收:
Object* newObject(VM* vm, ObjectType type) { if (vm->numObjects == vm->maxObjects) gc(vm); /* Create object... */ vm->numObjects++; return object; } 复制代码
我不打算显示它,但是我们也会在每次释放一个对象时调整 sweep() 以减少 numObjects 最后,我们修改 gc() 来更新 max:
void gc(VM* vm) { int numObjects = vm->numObjects; markAll(vm); sweep(vm); vm->maxObjects = vm->numObjects * 2; } 复制代码
在每次垃圾回收之后,我们根据回收之后剩余的活动对象数量更新 maxObjects。那里的倍增器让我们的堆随着活动对象数量的增加而增长。同样,如果一堆对象最终被释放,它也会自动收缩。
你成功了!如果您遵循了所有这些步骤,那么现在已经掌握了一个简单的垃圾回收器算法。如果你想看到它的全貌,这里是完整的代码。让我在这里强调,虽然这个垃圾回收器是简单的,它不是一个玩具。
您可以在此基础上构建大量的优化(在 GC 和编程语言等领域,优化占了90% 的工作量),但是这里的核心代码是一个合法的真正的 GC 。它与 Ruby 和 Lua 中的垃圾回收器非常相似。您可以发布使用类似这样的东西的生产代码。现在去建造一些很棒的东西吧!
原文链接