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

简介: 每当我倍感压力以及有很多事情要做的时候,我总是有这样一种反常的反应,那就是希望做一些其他的事情来摆脱这种状况。通常情况下,这些事情都是些我能够编写并实现的独立的小程序。

每当我倍感压力以及有很多事情要做的时候,我总是有这样一种反常的反应,那就是希望做一些其他的事情来摆脱这种状况。通常情况下,这些事情都是些我能够编写并实现的独立的小程序。


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


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


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


精简、复用、再复用

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


当然,我们从来都没有无限的内存。所以计算机实现收集的方式就是当机器需要分配一些内存,而内存又不足时,让它收集垃圾。


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

为了实现收集,编程语言需要确保程序不再使用那个对象。如果该程序不能得到一个对象的引用,那么显然它也不会再去使用它。所以关于”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 structsObject {

 ObjectType type;

 union {

   <span style="color: #999999;">/* OBJ_INT */</span>

   int value;

  <span style="color: #999999;"> /* OBJ_PAIR */</span>

   struct {

     structsObject* head;

     structsObject* tail;

   };

 };

} Object;

这个Object结构拥有一个type字段表示它是哪种类型的值——要么是int要么是pair。接下来用一个union来持有这个int或是pair的数据。如果你对c语言很生疏,一个union就是一个结构体,它将字段重叠在内存中。由于一个给定的对象只能是int或是pair,我们没有任何理在一个单独的对象中同时为所有这3个字段分配内存。一个union就搞定。帅吧。


小虚拟机

现在我们可以将其包装在一个小的虚拟机结构中了。它(指虚拟机)在这里的角色是用一个栈来存储在当前作用域内的变量。大多数语言虚拟机要么是基于栈(如JVM和CLR)的,要么是基于寄存器(如Lua)的。但是不管哪种情况,实际上仍然存在这样一个栈。它用来存放在一个表达式中间需要用到的临时变量和局部变量。

我们来简洁明了地建立这个模型,如下:

#define STACK_MAX 256

typedefstruct {

 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;

 returnobject;

}

它实现了内存的分配和设置类型标记。我们一会儿会重温它的。利用它,我们可以编写方法将每种类型的对象压到虚拟机的栈上:

void pushInt(VM*vm, intintValue) {

 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):

typedefstructsObject {

 unsignedchar marked;

 <span style="color: #999999;">/* Previous stuff... */</span>

} Object;

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

voidmarkAll(VM* vm)

{

 for (int i = 0; i < vm->stackSize; i++) {

   mark(vm->stack[i]);

 }

}

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

voidmark(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 structsObject {

 /* The next object in the list of all objects. */

 structsObject* next;

 /* Previous stuff... */

} Object;

虚拟机会保留这个链表头的痕迹:

typedefstruct {

 /* 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;

 returnobject;

}

这样一来,即便是语言找不到一个对像,它还是可以被实现。想要清理并删除那些未被标记的对象,我们只需要遍历该链表:

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)”是个什么意思?尤其是对于现在的计算机,它们几乎拥有无限的虚拟内存!

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

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

typedefstruct {

 /* 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++;

 returnobject;

}

我不会费心的显示它(指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中的收集器非常的相似。你可以使用一些类似的代码到你的项目中。去做些很酷的事情吧!

相关文章
|
机器学习/深度学习 算法 搜索推荐
当你打开天猫的那一刻,推荐系统做了哪些工作?
当年打开天猫的那一刻,它为你完成了华丽的变身,成为世上独一无二的“天猫”,这就是智能推荐的力量。今天,来自阿里巴巴搜索推荐事业部的算法工程师陈启伟为你介绍天猫如何玩转首页个性化推荐,揭开搜索推荐的神秘面纱。
20249 0
|
2天前
|
云安全 安全 网络安全
|
2天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
1661 0
|
4天前
|
存储 人工智能 自然语言处理
阿里云千问办公官网登录入口:qwenwork.cn网页版免下载,注册送2000积分、登录每日再送100积分
千问办公(QwenWork)是阿里巴巴推出的一站式AI办公平台,定位"不止于对话,更注重交付"。它基于千问大模型,具备任务拆解与多步骤自动执行能力,用户一句话即可完成PPT生成、数据分析、网页搭建、视频剪辑、信息调研等复杂任务,覆盖网页端、桌面端及钉钉生态。网页版免下载,访问qwenwork.cn注册即得2000积分,每日登录再送100积分。个人版免费起用,另有付费版与企业版可选。
|
8天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
1359 29
|
2天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
13天前
|
存储 人工智能 安全
千问办公官网入口网页版登录:qwenwork.cn 注册送2000积分、登录送100积分
阿里云千问办公(QwenWork)是AI智能工作平台,支持一句话生成PPT、表格、网页、视频及数据分析。个人免费版送2000积分+每日登录赠100分,含1GB存储、5个发布页;企业版198元/席/月起。阿里千问办公QwenWork官网:https://t.aliyun.com/U/0VCTGt 阿里AI工作平台,一句话完成数据分析、PPT 生成、视频剪辑、网页搭建等复杂任务
|
9天前
|
安全 Linux 芯片
【万字长文】Ollama下载、安装、配置、使用保姆级教程(2026最新图解)
Ollama 是一款免费开源的本地大模型运行工具,支持 DeepSeek、Qwen 等主流模型,一条命令即可下载运行。2026 最新版 v0.35.1 官方安装包(1.47GB)已提供国内直连下载,纯本地、离线可用、无广告、无隐私泄露风险。
894 2
|
8天前
|
人工智能 开发者
Qoder CN 大使计划正式开启|无限创造,等你一起
全年免费积分、优先内测、活动支持与更多共创机会,邀请用 Qoder 把想法变成现实的你。
632 6
|
1月前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
10132 33

热门文章

最新文章