给自己的 JavaScript 解释器写个简易垃圾回收——设计篇

742 阅读8分钟

前情提要:之前我趁着春节前的空闲,从零写了一个支持 ES5 语法的 JavaScript 解释器,名字叫 es:我从零写了一个支持 ES5 语法的 javascript 解释器

今天是春节假期的最后一天了(感谢公司给我们多放一天假!),我也紧赶慢赶地给 es 写出来了一个差强人意的垃圾回收模块,在这里和大家分享一下当时的选型思路,和实现过程中的一些收获。因为内容比较多,一篇文章写完大家看得比较累,所以拆成两篇发出来。这篇会介绍垃圾回收在设计上的一些基本知识,下一篇会介绍一下实现过程中的一些比较有意思的点。

一、垃圾回收简介

首先还是要简单介绍一下什么是垃圾回收机制,以及有哪些常见的方案。对这方便有一定了解的朋友可以先直接跳到第三节。因为能力和篇幅都有限,所以我也只能非常简略地提及一下,想更系统地入个门,还是推荐大家去读一下这两本书:

前者的理论内容更全面一些,后者则会对一些经典的 VM 的古早设计(毕竟原著是 10 年出的,书里提到 V8 的时候还在向大家科普“新兴浏览器” chrome...)进行较为详细的剖析。

垃圾回收是自动管理动态内存的一种策略,也是一个更好听一点的名字(有点像人工智能之于机器学习...)。在一些语言中,程序员需要手动管理分配在堆上的内存,例如在 C 中 malloc 分配内存,用 free 释放;在 C++ 中用 new 分配,用 delete 回收。手动管理很灵活,同时也很复杂,给广大程序员带来了很大的心智负担,因为可能一不小心就会漏释放某段内存,导致内存泄漏。为了让大家的日子都好过一点,很多高级语言提出,让语言的虚拟机/运行时(后文均称 VM)来帮着做内存分配和回收,从而让大家能腾出脑子来写需求。例如我们运行这样一个 javascript 函数:

function order(n) {
  var s = ""
  for (var i = 0; i < n; i++) {
    s += i
  }
  return s
}

order(5) 的结果会是字符串 "01234" ,在运行的过程中还会生成 ""、 "0"、 "01"、 "012"、 "0123" 这几个字符串。在调用完这个函数之后,显然我们就访问不到这 5 个字符串了,这种没法访问到的变量就是所谓的垃圾。垃圾回收,顾名思义就是怎么处理它们。

有 4 种最基础的垃圾回收算法,他们是 mark and sweep、mark and compact、复制垃圾回收和引用计数法。其余的所有方法都是基于它们进行的组合和变形。在这里我也简单地介绍一下它们都做了什么。

Mark and Sweep

从名字可以看出来 mark and sweep 分 2 步,mark(标记)和 sweep(清扫)(我不知道它的中文翻译是啥,好像中文社区的大家也都喜欢叫他的英文名...)。这个方法的思路非常简单。前文不是提到所有无法访问到的变量就都可以回收了嘛,那我就从一些根节点,例如 JavaScript 的 Global Object 出发,给所有能访问到的变量都打上标记,然后遍历堆(heap),把所有没标记的变量都回收了就行了。举个例子来说,如果下面的这一行是堆,堆里面存了变量 1~6 之后,发现已经分配不了一个新的变量 7 了:

|| 1 |  2  |   3   | 4 |   5   | 6 | |
heap start                           heap end

 |  7  | 放不下了...

那么 mark and sweep 就会先遍历查看哪个变量能被访问到,例如只有 3 号和 5 号,那么就会给他们做上标记:

Mark
|| 1 |  2  |---3---| 4 |---5---| 6 | |
heap start                           heap end

然后把没有标记的变量都回收掉:

Sweep
|          |---3---|   |---5---|     |
heap start                           heap end

之后为了下一次回收把标记清除掉,并把 7 放进来:

||  7  |   |   3   |   |   5   |     |
heap start                           heap end

可以看到,mark and sweep 有个很明显的特点,就是回收前后是不移动变量的,3 和 5 都还是留在原来的位置,我们称这种不对变量进行移动的垃圾回收算法为 non-moving gc。不移动的好处就是实现简单,因为不需要调整指针了,在回收前我们从哪个地址读数据,回收后还是从哪儿读;缺点则是会出现内存碎片,也就是说,假如我们要插入的 7 有下面这个例子这么大,虽然总的空余空间是够大的,但是分配不出一段足够大的连续空间了。

|          |   3   |   |   5   |     |
heap start                           heap end
 |     7     | 还是放不下...

那么根据上面的这个思路,就出现了 mark and compact 算法。

Mark and Compact

同样从名字可以看出,mark and compact 还是分 2 步:第一步还是标记,和上面一样;第二步则是 compact(压缩),也就是把剩下的变量都压到一起去。还是用上面的例子,压缩的时候会把 3 和 5 都移动到堆的底部:

Compact
||---3---|---5---|                   |
heap start                           heap end

这样压缩了之后就可以放下比较大的 7 了:

||   3   |   5   |     7     |       |
heap start                           heap end

mark and compact 通过移动变量(moving gc),降低了内存碎片化程度,使得堆空间利用率提高了。不过它的 throughput 一般来说相较于其他方法不太好,倒不是因为移动带来的内存拷贝,而是 mark and compact 的实现需要对堆进行比 mark and sweep 更多次的遍历,这有一定程度是因为移动导致我们需要更新所有指向被移动的变量的引用。所以一般都是在使用 mark and sweep 垃圾回收算法的过程中,发现内存碎片化程序高到一定程度的时候,转而调用 mark and compact 整理一下。

注:这里为了简单起见只介绍了按顺序往一侧压缩的 sliding order,还有一些别的压缩方法,感兴趣的朋友可以去看看上面两本书。

Copying GC

复制垃圾回收采用了一个比较反直觉的思路。它会将堆平分为 2 部分,分别称为 from space 和 to space。VM 只会向 to space 中申请内存,如果 to space 满了,就把能访问到的变量拷贝到 from space 中,然后把 from space 和 to space 交换一下(flip)。例如初始状态如下:

tospace   || 1 |  2  |   3   | 4 |   5   | 6 | |
          heap start                           heap end
fromspace |                                    |
           |  7  | 放不下了...

把能访问到的 3 和 5 拷贝到 from space:

Copy
tospace   || 1 |  2  |   3   | 4 |   5   | 6 | |
          heap start                           heap end
fromspace ||   3   |   5   |                   |

交换并清空 from space:

Copy
fromspace |                                    |
          heap start                           heap end
tospace   ||   3   |   5   |                   |

最后插入 7:

Copy
fromspace |                                    |
          heap start                           heap end
tospace   ||   3   |   5   |  7  |             |

很显然,复制 gc 的问题在于直接要牺牲一半的堆空间。但实际上,正是因为这种牺牲,使得复制 gc 的实现较为高效,同时在堆结构的设计上也有一定的优势。在堆空间相对宽裕的情况下,能够有更好的表现。我个人非常喜欢复制 gc 算法,因为它明晃晃地告诉我们要时刻考虑系统设计中的 trade off,有的时候,直接牺牲一半的内存,可能也是个不错的设计。

引用计数

上面的 3 种算法都有一个特点,就是他们都是在堆栈空间不够的时候会暂停用户程序进行垃圾回收,完成后再继续用户程序。我们称这种模式为 stop-the-world gc。

image.png

不过毕竟程序员不是替身使者(至少不全都是...),所以 stop-the-world gc 的问题就是在进行 gc 的时候,会出现比较明显的卡顿,会给像游戏这样对实时性要求比较高的应用带来比较大的麻烦。为了降低 gc 的时延,大佬们在上面 3 个算法的基础上推出了 incremental gc(每次只回收一小部分)、concurrent gc(让垃圾回收和程序运行并发)等等的黑魔法。那些内容基本就和本文标题中的简易没啥关系了,所以就不展开了,有兴趣的朋友可以去看 handbook 以及读论文~

引用计数则是直接绕过了这个问题。它的思路是这样的,我们只要给每个变量加一个 counter,用来记录有几个地方引用了它,如果这个计数归零了,说明没有地方引用它了,也就是我们访问不到它了,那么我们就可以进行回收。因为每个变量都是单独计数的,所以就可以把内存的回收平摊到整个程序运行时间里。同时引用计数算法可以独立于运行时实现,这方面最典型的例子就是 C++ 的只能指针。

但是可想而知,天下没有免费的午餐,引用计数也有它的问题。对于最简化版本的引用计数来说,它无法处理循环引用的问题,类似于:

a.b = b; b.a = a;

那么 a 和 b 的引用计数都没法清零了...

另一个比较棘手的问题就是,引用计数会把所有的读操作都转为写操作,这和 linux 的 copy on write 机制有冲突,在多进程的情况下会出现一些问题。同时所有操作都增加了一个加法。当然啦,伴随着研究人员们的心血,这些问题也都有了一些解决方法,但是一般还是认为引用计数的 throughput 会相对差一些。

Generational GC

介绍完了上述的 4 个基础算法。我们会看到他们各有所长,那么有没有什么办法能把他们结合一下,让他们各取所长呢?实际上是有的,这种结合的方法就是 generational GC,或者称为分代 gc。这种分代的依据来源于一个非常重要的观察,那就是大多数的变量都是临时变量,活不过一次 gc(most objects die young)。所以我们可以把变量分为新旧两代,每次回收的时候先看看新的那些能不能腾出足够的空间,如果不够的话再考虑回收旧的变量。同时把活过一两次的变量从新空间(new space)移到旧空间(old space)去,颇有一种斯大林格勒三天当团长的感觉...

进行这种划分之后,就可以更好地利用基本算法了。例如复制 gc 比较适合在比较空的堆,同时不适合处理 long-lived 变量,因为这种过了好几代的变量会每次都被拷贝一下。而 mark and sweep, mark and compact 组合则适合更适合处理老变量,尤其是 mark and compact 可以把老变量紧紧地压到堆的底部。所以很多分代 gc 都会在新生代使用复制 gc,而在旧生代使用 mark and sweep and compact 组合。

注:一些比较变态的 VM 还会分更多代...

二、堆结构设计

很多文章都会介绍到上面的这些算法,但是一般都会遗漏了垃圾回收设计中一个很关键的部分:堆的设计。因为垃圾回收意味着我们要自己分配和回收内存,那么自然就不能每次分配的时候都用 malloc 去取一点内存出来了(可能 non-moving gc 可以去这么实现...但是估计性能也会比较拉跨...)。为了简单起见,我们可以认为我们的堆是预先分配的一大块连续的内存,那么堆的设计也就在于用什么样的数据结构去表示这段内存,或者说我们以什么样的方式去进行分配。实际上,摆在面前的选择只有 2 个,sequential 分配和 free-list 分配。

Sequential 分配

所谓 sequential,也就是说顺序紧密地一个一个分配出去,伪代码大约是:

sequentialAllocate(n):
  result = free
  newFree = result + n
  if newFree > heap_end
    return null
  free = newFree
  return result

这种分配方式非常好实现,但是它不适用于 mark and sweep,因为 mark and sweep 会在这连续的分配中造出许多洞,我们只能用额外的空间去追踪这些洞的位置。复制 gc 却和它是天造地设的一对儿,因为复制 gc 每次会把 fromspace 直接清空,所以每次回收完只需要把 free 设置为新的 tospace 的底就行了,非常方便。

Free-List 分配

mark and sweep 适合的是用链表的方式对空余内存进行组织。还是拿上面的例子举例:

|    x1    |   3   | x2|   5   |  x3 |
heap start                           heap end

空闲的 x1 、x2 和 x3 就组成了一个 free list:

x1 ---> x2 ---> x3

在分配 7 的时候,就可以从头遍历这个 free list,找到第一个大小比变量 7 大的,使用那一个 free list 节点进行分配,如果节点大了,还是还可以把剩下的部分拆分出来放回 free list 中。伪代码如下:

firstFitAllocate(n):
  prev = addressOf(head)
  while true:
    curr = next(prev)
    if curr == null:
      return null
    else if size(curr) < n:
      prev = curr
    else:
      return listAllocate(prev, curr, n)

listAllocate(prev, curr, n):
  result = curr
  if shouldSplit(size(curr), n):
    remainder = result + n
    next(remainder) = next(curr)
    size(remainder) = size(curr) — n

    next(prev) = remainder
  else:
    next(prev) = next(curr)
  return result

注:这里使用的是 first fit,还有 next fit, best fit 等选取节点的方法。

在链表中查找的复杂度比较高,所以一个常见的优化是按照内存的大小,分为数个不同的链表,这样在分配的时候,只需要去找对应内存范围的链表的就可以了,而且往往节点内存比较小的链表,会设置所有节点的大小一致,这样就把搜索的复杂度从 O(n) 降到 O(1) 了。这种优化被称为 segregated list(segragated storage)。如果对内存池设计有所了解的朋友可能会注意到,这种设计方式和 slab 内存池很像。

不过进行回收/释放的时候,垃圾回收的 free list 和 slab 是不同的。对于手动内存管理来说,每次都只会回收 1 个节点,所以需要考虑这个回收的节点放在链表的什么地方,如何和其他的节点进行合并。然而对于垃圾回收的场景来说,因为每次会进行大量的回收,所以可以在回收的时候直接重新构建 free list,不需要考虑节点的合并,这样也可以很方便地保证链表节点的顺序和其对应的内存的顺序一致。

三、著名 JavaScript VM 垃圾回收方法总结

了解了垃圾回收的基本算法以及堆栈的设计方式之后,在定下 es 的设计之前,需要先看看前人都是怎么做的。这里我简单了解了一下 4 个比较主流的 JavaScript VM:V8,JavaScriptCore(JSC),Hermes 和 QuickJS。

V8

V8 的垃圾回收设计非常经典,就是一个 generational gc,新生代复制算法,旧生代 mark and sweep and compact。在这个基础上做了非常多的优化,例如加入了 concurrent gc 等等的优化。印象里这个设计应该学习了 Java 的 HotSpot VM。

JSC

JSC 的垃圾回收有个很显著的特点,就是它是 non-moving gc。对于小于 8 KB 的对象采用基于 segragate storage 的 mark and sweep(当然,也是加了 concurrent 等一系列优化的版本...),大对象博客中说是直接使用 malloc,但是不知道他们是怎么回收的,可能也是单独 mark and sweep 吧。JSC 的博文中提到,因为 JavaScript 不算是特别快的语言,所以往往 gc 速度不会造成瓶颈,采用 non-moving gc 的目的是为了方便做 OSR 以及各种 bytecode/JIT 优化。

Hermes

Hermes 是 meta 为 ReactNative 做的解释器,用来替代之前使用的 JSC。Hermes 的 gc 设计和 V8 比较像,分成新旧 2 代的 generational gc。Hermes 的比较有意思的一点在于它是专注于移动端的,所以没办法像电脑上那样随便分配几个 G 的连续内存,所以在堆设计上进行了比较多的探讨。

QuickJS

跟风去了解了一下 QuickJS 的 gc,发现 Fabrice Bellard 大大竟然选用了引用计数。给出的理由是为了减少内存使用量和增大确定性。我猜可能是为了保持轻量化,并给之后的一些有实时性需求的嵌入式应用做准备?大神的想法不敢妄自揣测...

四、当我设计 es 的 GC 时,我在想什么

好啦,终于到了讨论 es 实际设计的这一节了。设计永远是基于需求的,短期内 es 应该都会处在图一乐的状态,估计不会有啥第三方的人来使用它。所以我对 es 的主要的需求其实就是在写的过程中能学习到比较经典的一些设计,以这一点为前提就可以做一些比较方便的假设,也就不需要去考虑 Hermes 的那种比较奇特的底层硬件结构。

同时,因为只有我自己在写,所以尽可能地不要弄太多需要调参的地方,所以尽可能避开 segregate list 这样需要拍很多 magic number 划分内存的方案。而去采用哪种可能最多只能达到 95%,但是比较容易达到 80% 的方案。

还有就是希望这个设计有一定的可延续性,也就是如果有精力的话,可以在上面进一步地进行设计上或者算法上的优化,从而能体验到更多的东西。

所以我最后决定还是去实现一下 V8 的这种 2 代的 generational gc。new space 采用复制 gc,使用 sequential allocation,old space 采用 mark and sweep and compact,使用 free list allocation。这样的好处在于我可以分阶段一点一点实现,而不用上来就写个上万行,然后再一点一点调试。一个大致的路线图是:

  1. 实现独立的 copying gc;
  2. 实现独立的 mark and sweep;
  3. 实现 1 + 2 的 generational gc;
  4. 如果有必要,加入 compact。

目前我已经实现了 1 和 2,经过测试发现目前 gc 不是太大的瓶颈,所以可能会稍微把 3 往后推一推,等解释器的其他部分的性能提上来时候再完全实现这个 generational gc。就像围棋里面常说的,不要过早定型,在局部保留一下味道。

我个人对 JSC 博客中提到的 moving gc 对 JIT OSR 的影响还是稍稍有些顾虑的,不过感觉自己的知识积累距那里还太远了,等到那个时候也许车到山前必有路吧~ 对于引用计数来说,作为一个常年写 python 的人,实在是不想整个和 cpython 一样的设计...

不知不觉文章已经到了 9000 字... 在实现 1 和 2 两种 gc 的过程中我学到了不少东西,在下一篇文章中再来讨论吧。

最后还是习惯性地讨一下 star~ 求支持一下图一乐的手艺人,点个 star 吧~

zhuzilin/es

Reference

引用文献为上述两本书和提及的 vm 的公开文档与源码。由衷感谢这些大佬们真诚的分享!