垃圾回收是编程中鲨鱼出没最多的领域之一,但是在这篇文章中,我会给你一个不错的儿童游泳池,你可以在里面游泳。 (可能还有鲨鱼在里面,但至少它会比较浅。)
减少废物,循环再用,循环再造
垃圾回收背后的基本思想是,该语言(在大多数情况下)似乎可以访问无限内存。 开发人员只需要保持分配、分配和分配,就像魔术一样,它永远不会失败。
当然,机器没有无限的内存。 所以实现的方式是,当它正在运行时内存空间不够并需要分配一点内存,它会收集垃圾。
此上下文中的“垃圾”是指它以前分配的不需要再被使用的内存。 为了让无限记忆的想法实现,语言必须在处理“不再被使用”内存时非常安全。 如果随机对象在程序试图访问它们时就开始被回收,那就没有意思了。
为了能够实现可回收,这种语言必须确保程序无法再次使用这个对象。 如果它不能得到对象的引用,那么它显然不能再次使用它。 因此,“在用”的定义实际上非常简单:
任何由作用域中的变量引用的对象都在使用中。
任何被另一个正在使用的对象引用的对象都在使用中。
第二条规则是递归规则。
如果对象 a 被一个变量引用,并且它有一些引用对象 b 的字段,那么 b 就被使用了,因为你可以通过 a 找到它。
最终的结果是一个可到达所有对象的引用图,你可以从一个变量开始遍历所有对象。 对于程序来说,任何不在这个图中的对象都是死的,它的内存已经过期,可以收获了。
标记和清扫
有许多不同的方法可以实现查找和回收所有未使用对象的过程,但有史以来为此发明的最简单也是最早的算法被称为“标记-清除”。 它是由 John McCarthy 发明的,他发明了 Lisp 和 beards,所以现在实现垃圾回收器就像是在和一个老神交流,但是希望不是以 Lovecraftian 的方式,以你的头脑和视网膜被炸得干干净净结束。
“标记-清除”的工作原理几乎和我们对可达性的定义一模一样:
从根开始,遍历整个对象图。 每次你到达一个对象时,在它上面设置一个“标记”位为 true。
完成之后,找到所有标记位未设置的对象并删除它们。
A pair of objects
在我们开始实现这两个步骤之前,让我们先做一些准备工作。 我们实际上不会为某种语言实现一个解释器 ーー 没有解析功能、字节码或其他任何愚蠢的东西ーー但我们确实需要一些最少量的代码来创建一些垃圾来进行收集。
让我们假设我们正在为一种小语言写一个解释器。 它是动态类型的,有两种类型的对象: 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。 如果我们有一个解析器和一个解释器来调用这些函数,那么我们手头上就会有一个实实在在的语言。 而且,如果我们有无限的内存,它甚至可以运行真正的程序。 既然没有,我们就开始收集垃圾吧。
Marky mark
第一个阶段是标记。我们需要遍历所有可到达的对象并设置它们的标记位。我们需要做的第一件事就是给 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 () ,它将正确地标记内存中每个可到达的对象!
Sweepy sweep
下一步是扫描我们分配的所有对象,并释放任何没有标记的对象。但是这里有一个问题: 根据定义,所有未标记的物体都是不可到达的! 我们接近不了他们!
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。那里的倍增器让我们的堆随着活动对象数量的增加而增长。同样,如果一堆对象最终被释放,它也会自动收缩。
Simple
你成功了!如果您遵循了所有这些步骤,那么现在已经掌握了一个简单的垃圾回收器算法。如果你想看到它的全貌,这里是完整的代码。让我在这里强调,虽然这个垃圾回收器是简单的,它不是一个玩具。
您可以在此基础上构建大量的优化(在 GC 和编程语言等领域,优化占了90% 的工作量),但是这里的核心代码是一个合法的真正的 GC 。它与 Ruby 和 Lua 中的垃圾回收器非常相似。您可以发布使用类似这样的东西的生产代码。现在去建造一些很棒的东西吧!
网友评论