编写你的第一个垃圾收集器

每当我倍感压力以及有不少事情要作的时候,我老是有这样一种反常的反应,那就是但愿作一些其余的事情来摆脱这种情况。一般状况下,这些事情都是些我可以编写并实现的独立的小程序。git

一天早上,我几乎要被一堆事情给整疯了——我得看一本书、处理一些工做上的事情、还要准备一场Strange Loop的演讲,而后这时我忽然想到:“我该写一个垃圾收集器了”。github

是的,我知道那一刻让我看上去有多疯狂。不过个人神经故障倒是你实现一段基础的程序语言设计的免费教程!在100行左右毫无新意的c代码中,我设法实现一个基本的标记和扫描模块。算法

垃圾收集被认为是有更多编程牛人出没的水域之一,但在这里,我会给你一个漂亮的儿童游泳池去玩耍。可能这里面仍然会有一些能手,但至少这会是一个浅水区。编程

精简、复用、再复用

垃圾收集背后有这样一个基本的观念:编程语言(大多数的)彷佛总能访问无限的内存。而开发者能够一直分配、分配再分配——像魔法同样,取之不尽用之不竭。小程序

固然,咱们历来都没有无限的内存。因此计算机实现收集的方式就是当机器须要分配一些内存,而内存又不足时,让它收集垃圾。安全

“垃圾(Garbage)”在这里表示那些事先分配过但后来再也不被使用的内存。而基于对无限内存的幻想,咱们须要确保“再也不被使用”对于编程语言来讲是很是安全的。要知道在你的程序试图访问一些随机的对象时它们却恰好正在获得回收,这可不是一件好玩的事情。session

为了实现收集,编程语言须要确保程序再也不使用那个对象。若是该程序不能获得一个对象的引用,那么显然它也不会再去使用它。因此关于”in use”的定义事实上很是简单:数据结构

任何被一个变量引用的对象,仍然在做用域内,就属于”in use”状态。
任何被另外一个对象引用的对象,仍在使用中,就是”in use”状态。
若是对象A被一个变量引用,而它又有一些地方引用了对象B,那么B就是在使用中(“in use”),由于你可以经过A来访问到它。编程语言

这样到最后的结果就是获得一张可访问的对象图——以一个变量为起点并可以遍历到的全部对象。任何不在图中的对象对于程序来讲都是死的,而它的内存也是时候被回收了。函数

标记并清理

有不少不一样的方法能够实现关于查找和回收全部未被使用的对象的操做,可是最简单也是第一个被提出的算法就是”标记-清除”算法。它由John McCarthy——Lisp(列表处理语言)的发明者提出,因此你如今作的事情就像是与一个古老的神在交流,但但愿你别用一些洛夫克拉夫特式的方法——最后以你的大脑和视网膜的彻底枯萎而结束。

该算法的工做原理几乎与咱们对”可访问性(reachability)”的定义彻底同样:
1. 从根节点开始,依次遍历整个对象图。每当你访问到一个对象,在上面设置一个”标记(mark)”位,置为true。
2. 一旦搞定,找出全部标记位为”not”的对象集,而后删除它们。
对,就是这样。我猜你可能已经想到了,对吧?若是是,那你可能就成为了一位被引用了数百次的文章的做者。因此这件事情的教训就是,想要在CS(计算机科学)领域中出名,你没必要开始就搞出一个很牛的东西,你只须要第一个整出来便可,哪怕这玩意看上去很搓。

对象对

在咱们落实这两个步骤以前,让咱们先作些不相关的准备工做。咱们不会为一种语言真正实现一个解释器——没有分析器,字节码、或任何这种愚蠢的东西。但咱们确实须要一些少许的代码来建立一些垃圾去收集。

让咱们伪装咱们正在为一种简单的语言编写一个解释器。它是动态类型,而且有两种类型的变量:int 和 pair。 下面是用枚举来标示一个对象的类型:

typedef enum {
  OBJ_INT,
  OBJ_PAIR
} ObjectType;

其中,pair能够是任何一对东西,两个int、一个int和另外一个pair,什么均可以。随你怎么想都行。由于一个对象在虚拟机中能够是这两个当中的任意一种类型,因此在c中实现对象的典型方法是时用一个标记联合体(tagged union)

typedef struct sObject {
  ObjectType type;

  union {
    <span style="color: #999999;">/* OBJ_INT */</span>
    int value;

   <span style="color: #999999;"> /* OBJ_PAIR */</span>
    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;
  <span style="color: #999999;">/* Previous stuff... */</span>
} Object;

一旦咱们建立了一个新的对象,咱们将修改newObject()方法初始化marked为0。为了标记全部可访问的对象,咱们从内存中的变量入手,这样就意味着要扫一遍堆栈。看上去就像这样:

void markAll(VM* vm)
{
  for (int i = 0; i < vm->stackSize; i++) {
    mark(vm->stack[i]);
  }
}

里面又调用了mark。咱们来分几步搭建它。第一:

void mark(Object* object) {
  object->marked = 1;
}

毫无疑问,这是最重要的一点。咱们标记了这个对象自身是可访问的,但记住,咱们还须要处理对象中的引用:可访问性是递归的。若是该对象是一个pair,它的两个字段也是可访问的。操做很简单:

void mark(Object* object) {
  object->marked = 1;

  if (object->type == OBJ_PAIR) {
    mark(object->head);
    mark(object->tail);
  }
}

可是这里有一个bug。你看到了吗?咱们正在递归,但咱们没有检查循环。若是你有一堆pair在一个循环中相互指向对方,这就会形成栈溢出并崩溃。

为了解决这个状况,咱们仅须要作的是在访问到了一个已经处理过的对象时,退出便可。因此完整的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()方法了,它会准确的标记内存中全部可访问的对象。咱们已经成功一半了!

清理

下一个阶段就是清理一遍全部咱们已经分配过(内存)的对象并释放那些没有被标记过的(对象)。但这里有一个问题:全部未被标记的对象——咱们所定义的——都不可达!咱们都不能访问到它们!

虚拟机已经实现了对象引用的语义:因此咱们只在变量和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;

在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;
    }
  }
}

这段代码读起来有点棘手,由于那个指针(指object)指向的是一个指针,可是经过它的工做你会发现它仍是很是简单的。它只是扫遍了整张链表。只要它碰到了一个未被标记的对象,它就会释放该对象的内存并将其从链表中移除。最后,咱们将会删除全部不可访问的对象。

祝贺你!咱们已经有了一个垃圾收集器!如今只剩下一点工做了:实际调用它!首先咱们将这两个阶段整合在一块儿:

void gc(VM* vm) {
  markAll(vm);
  sweep(vm);
}

没有比这更明显的”标记-清除”算法了。如今最棘手的是搞清楚何时来实际调用它。”内存不足(low on memory)”是个什么意思?尤为是对于如今的计算机,它们几乎拥有无限的虚拟内存!

事实证实,咱们没有彻底正确或错误的答案。这真的取决于你使用虚拟机的目的以及让它运行在什么样的硬件上。为了让这个例子看上去很简单,咱们仅在进行了必定数量的内存分配以后开始收集。事实上一些语言的实现就是这么作的,而这也很容易。

咱们将邀请虚拟机来追踪咱们到底建立了多少(对象):

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;
}

我不会费心的显示它(指numObjects),可是咱们也会稍微调整sweep()方法,每释放一次就递减numObjects。最后,咱们修改了gc()方法来更新最大值:

void gc(VM* vm) {
  int numObjects = vm->numObjects;

  markAll(vm);
  sweep(vm);

  vm->maxObjects = vm->numObjects * 2;
}

每次收集以后,咱们更新maxObjects——以进行收集后仍在活动的对象为基准。乘法器让咱们的堆随着活动中的对象数量的增长而增长。一样,也会随着一些对象最终被释放掉而自动减小。

最后

你成功了!若是你所有照作了,那你如今已经获得了一个简单的垃圾收集算法的句柄。若是你想看完整的代码,在这里。我再强调一点,尽管这个收集器很简单,但它可不是一个玩具。

你能够在这上面作一大堆的优化(像在GC和程序设计语言这些事情中,90%的努力都在优化上),但它的核心代码但是真正的GC。它与目前Ruby和Lua中的收集器很是的类似。你可使用一些相似的代码到你的项目中。去作些很酷的事情吧!


原文连接:Baby's First Garbage Collector
转载自:伯乐在线 - deathmonkey

相关文章
相关标签/搜索