伯乐在线补充:本文作者 Bob Nystrom 是 Google Dart 团队的一名工程师,所以下文中”处理一些工作上的事情 “中的链接是指向了 Dart 官网。Bob 之前(曾在 EA 公司)做过游戏开发,UI 设计。更多信息,请看他的简历 。
每当我倍感压力以及有很多事情要做的时候,我总是有这样一种反常的反应,那就是希望做一些其他的事情来摆脱这种状况。通常情况下,这些事情都是些我能够编写并实现的独立的小程序。
一天早上,我几乎要被一堆事情给整疯了——我得写我那本《游戏编程模式》 、处理一些工作上的事情 、还要准备一场Strange Loop的演讲 ,然后这时我突然想到:“我该写一个垃圾收集器了”。
是的,我知道那一刻让我看上去有多疯狂。不过我的神经故障却是你实现一段基础的程序语言设计的免费教程!在100行左右毫无新意的c代码中,我设法实现一个基本的标记和扫描模块。
有人认为,垃圾收集好比是有更多鲨鱼出没的危险水域,但在这篇文章中,我会给你一个漂亮的儿童游泳池去玩耍。可能这里面仍然会有一些坑,但至少这是一个浅水区。
垃圾收集背后有这样一个基本的观念:编程语言(大多数的)似乎总能访问无限的内存。而开发者可以一直分配、分配再分配——像魔法一样,取之不尽用之不竭。
当然,我们从来都没有无限的内存。所以计算机实现收集的方式就是当机器需要分配一些内存,而内存又不足时,让它收集垃圾。
“垃圾(Garbage)”在这里表示那些事先分配过但后来不再被使用的内存。而基于对无限内存的幻想,我们需要确保“不再被使用”对于编程语言来说是非常安全的。要知道在你的程序试图访问一些随机的对象时它们却刚好正在得到回收,这可不是一件好玩的事情。
为了实现收集,编程语言需要确保程序不再使用那个对象。如果该程序不能得到一个对象的引用,那么显然它也不会再去使用它。所以关于”in use”的定义事实上非常简单:
如果对象A被一个变量引用,而它又有一些地方引用了对象B,那么B就是在使用中(“in use”),因为你能够通过A来访问到它。
这样到最后的结果就是得到一张可访问的对象图——以一个变量为起点并能够遍历到的所有对象。任何不在图中的对象对于程序来说都是死的,而它的内存也是时候被回收了。
有很多不同的方法可以实现关于查找和回收所有未被使用的对象的操作,但是最简单也是第一个被提出的算法就是”标记-清除”算法。它由John McCarthy——Lisp(列表处理语言)的发明者提出,所以你现在做的事情就像是与一个古老的神在交流,但希望你别用一些洛夫克拉夫特 式的方法——最后以你的大脑和视网膜的完全枯萎而结束。
该算法的工作原理几乎与我们对”可访问性(reachability)”的定义完全一样:
对,就是这样。我猜你可能已经想到了,对吧?如果是,那你可能就成为了一位被引用了数百次的文章的作者。所以这件事情的教训就是,想要在CS(计算机科学)领域中出名,你不必开始就搞出一个很牛的东西,你只需要第一个整出来即可,哪怕这玩意看上去很搓。
在我们落实这两个步骤之前,让我们先做些不相关的准备工作。我们不会为一种语言真正实现一个解释器——没有分析器,字节码、或任何这种愚蠢的东西。但我们确实需要一些少量的代码来创建一些垃圾去收集。
让我们假装我们正在为一种简单的语言编写一个解释器。它是动态类型,并且有两种类型的变量:int 和 pair。 下面是用枚举来标示一个对象的类型:
typedef enum {
OBJ_INT,
OBJ_PAIR
} ObjectType;
typedef struct sObject {
ObjectType type;
union {
/* OBJ_INT */
int value;
/* OBJ_PAIR */
struct {
struct sObject* head;
struct sObject* tail;
};
};
} Object;
这个Object结构拥有一个type字段表示它是哪种类型的值——要么是int要么是pair。接下来用一个union来持有这个int或是pair的数据。如果你对c语言很生疏,一个union就是一个结构体,它将字段重叠在内存中。由于一个给定的对象只能是int或是pair,我们没有任何理在一个单独的对象中同时为所有这3个字段分配内存。一个union就搞定。帅吧。
现在我们可以将其包装在一个小的虚拟机结构中了。它(指虚拟机)在这里的角色是用一个栈来存储在当前作用域内的变量。大多数语言虚拟机要么是基于栈(如JVM和CLR)的,要么是基于寄存器(如Lua)的。但是不管哪种情况,实际上仍然存在这样一个栈。它用来存放在一个表达式中间需要用到的临时变量和局部变量。
我们来简洁明了地建立这个模型,如下:
#define STACK_MAX 256
typedef struct {
Object* stack[STACK_MAX];
int stackSize;
} VM;
现在我们得到了一个合适的基本数据结构,接下来我们一起敲些代码来创建些东西。首先,我们来写一个方法创建并初始化一个虚拟机:
VM* newVM() {
VM* vm = malloc(sizeof(VM));
vm->stackSize = 0;
return 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;
}
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;
}
第一个阶段就是标记(marking)。我们需要扫遍所有可以访问到的对象,并设置其标志位。现在我们需要做的第一件事就是为对象添加一个标志位(mark bit):
typedef struct sObject {
unsigned char marked;
/* Previous stuff... */
} Object;
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);
}
}
下一个阶段就是清理一遍所有我们已经分配过(内存)的对象并释放那些没有被标记过的(对象)。但这里有一个问题:所有未被标记的对象——我们所定义的——都不可达!我们都不能访问到它们!
虚拟机已经实现了对象引用的语义:所以我们只在变量和pair元素中储存指向对象的指针。当一个对象不再被任何指针指向时,那我们就完全失去它了,而这也实际上造成了内存泄露。
解决这个问题的诀窍是:虚拟机可以有它自己的对象引用,而这不同于对语言使用者可读的那种语义。换句话说,我们自己可以保留它们的痕迹。
这么做最简单的方法是仅维持一张由所有分配过(内存)的对象(组成)的链表。我们在这个链表中将对象自身扩展为一个节点:
typedef struct sObject {
/* The next object in the list of all objects. */
struct sObject* next;
/* Previous stuff... */
} Object;
typedef struct {
/* The first object in the list of all objects. */
Object* firstObject;
/* Previous stuff... */
} VM;
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);
}
事实证明,我们没有完全正确或错误的答案。这真的取决于你使用虚拟机的目的以及让它运行在什么样的硬件上。为了让这个例子看上去很简单,我们仅在进行了一定数量的内存分配之后开始收集。事实上一些语言的实现就是这么做的,而这也很容易。
我们将邀请虚拟机来追踪我们到底创建了多少(对象):
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;
}
每当我们创建一个对象,我们增加numObjects,如果它达到最大值就启动一次收集:
Object* newObject(VM* vm, ObjectType type) {
if (vm->numObjects == vm->maxObjects) gc(vm);
/* Create object... */
vm->numObjects++;
return object;
}
void gc(VM* vm) {
int numObjects = vm->numObjects;
markAll(vm);
sweep(vm);
vm->maxObjects = vm->numObjects * 2;
}
你成功了!如果你全部照做了,那你现在已经得到了一个简单的垃圾收集算法的句柄。如果你想看完整的代码,在这里 。我再强调一点,尽管这个收集器很简单,但它可不是一个玩具。
你可以在这上面做一大堆的优化(像在GC和程序设计语言这些事情中,90%的努力都在优化上),但它的核心代码可是真正的GC。它与目前Ruby和Lua中的收集器非常的相似。你可以使用一些类似的代码到你的项目中。去做些很酷的事情吧!