編寫你的第一個垃圾收集器

每當我倍感壓力以及有不少事情要作的時候,我老是有這樣一種反常的反應,那就是但願作一些其餘的事情來擺脫這種情況。一般狀況下,這些事情都是些我可以編寫並實現的獨立的小程序。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

相關文章
相關標籤/搜索