谈谈V8引擎GC原理

5,087 阅读9分钟

前言

过去这些年 V8 的垃圾回收器(GC)发生了很多的变化,Orinoco 项目采用了 stop-the-world 垃圾回收器,以使其变成了一个更加并行,并发和增量的垃圾回收器。

调用栈中数据是如何回收的?

我们先来说说调用栈中数据是如何回收的垃圾的。首先是调用栈中的数据,我们还是通过一段示例代码的执行流程来分析其回收机制,具体如下:

function foo(){
    var a = 1
    var b = {name:" 极客邦 "}
    function showName(){
      var c = " 极客时间 "
      var d = {name:" 极客时间 "}
    }
    showName()
}
foo()

当执行到第 6 行代码时,其调用栈和堆空间状态图如下所示:

image.png
从图中可以看出,原始类型的数据被分配到栈中,引用类型的数据会被分配到堆中。我们可以想想,当执行完foo函数之后,在堆中的内存是如何回收的呢?

接着,当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP(录当前执行状态的指针) 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程。具体如下图

image.png

所以说,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

堆中的数据是如何回收的?

从上面我们知道,当执行完foo函数之后,ESP 应该是指向全局执行上下文的,但是保存在堆中的两个对象依然占用着空间,如下图所示:

image.png

要回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器了。

代际假说&&V8回收垃圾机制

代际假说

在垃圾回收中有一个重要的术语:“代际假说”;代际假说表明很多对象在内存中存在的时间很短。换句话说,从垃圾回收的角度来看,很多对象一经分配内存空间随即就变成了不可访问的。这个假说不仅仅适用于 V8 和 JavaScript, 代际假说有以下两个特点: 1.第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问; 2.第二个是不死的对象,会活得更久。

分代堆布局

堆在 V8 中会分为两块不同的区域,这两块区域分别称之为老生代和新生代,新生代又进一步分为 ‘nursery’(from-space) 子代和 ‘intermediate’ (to-space)子代两块区域; 一个对象第一次分配内存时会被分配到新生代中的‘from-space’ 子代;如果进过下一次垃圾回收这个对象还存在新生代中,这时候我们移动到 ‘to-space’ 子代,再经过下一次垃圾回收这个对象还在新生代,这时候我们就会把这个对象移动到老生代。

image.png
V8中堆分成两代,如果经过垃圾回收对象还存活的话会从新生代移动到老生代

V8两种垃圾回收器

V8 有两个垃圾回收器,一个是主垃圾回收器,一个是副垃圾回收器。主垃圾回收器从整个堆中回收垃圾,副垃圾回收器(Scavenger)从新生代中回收垃圾。主垃圾回收器可以很有效的从整个堆中回收垃圾,但是代际假说告诉我们新分配内存的对象也极有可能需要垃圾回收。

副垃圾回收器 —— (Scavenger)

副垃圾回收器只从新生代中回收垃圾,幸存的对象总是会被分配到内存页中去。在清理时,初始的空闲区域称之为“To-Space”,复制对象过来的区域称之为“From-Space”;在最坏的情况下,如果每一个对象在清理的时候存活了下来,那我们就要复制每一个对象。

对于清理,我们会维护一个额外的根集,这个根集里会存放一些从旧到新的引用。这些引用是在旧空间(old-space)中指向新生代中对象的指针。我们使用这个指针来维护从旧到新的引用列表,而不是跟踪整个堆中的每一个对象变更。当堆和全局对象结合使用时,我们知道每一个在新生代中对象的引用,而无需追踪整个老生代。

然后将所有的活动对象移动到连续的一块内存中,这样做的好处就是完全移除内存碎片(清理非活动对象时留下的内存碎片);然后我们把两块内存空间互换,即把 ‘To-Space’ 变成 ‘From-Space’,反之亦然。一旦垃圾回收完成,新分配的内存空间将从 ‘From-Space’ 下一个空闲内存地址开始。

image.png
副垃圾回收器移动活动对象到一个新的内存页

如果仅仅是凭借这一策略,我们就会很快的耗尽新生代的内存空间;为了新生代的内存空间不被耗尽,在下一次垃圾回收的时候,我们会把活动对象移动到老生代,而不是 ‘To-Space’。

清理的最后一步是把移动后的对象的指针地址更新,每一个被复制对象都会留下一个转发地址,用于更新指针以指向新的地址。

image.png
副垃圾回收器移动 ‘to-space’ 子代的活动对象到老生代

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

由于老生区的对象比较大,若要在老生区中使用 Scavenge算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高, 同时还会浪费一半的空间。因而,主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。下面我们来看看该算法是如何工作的。

首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

image.png

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程:

image.png

上面的标记过程和清除过程就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact)这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。你可以参考下图

image.png

Orinoco回收执行机制

Orinoco 是 V8 垃圾回收器项目的代号,它利用最新的和最好的垃圾回收技术来降低主线程挂起的时间, 比如:并行(parallel)垃圾回收,增量(incremental)垃圾回收和并发(concurrent)垃圾回收。

并行垃圾回收

并行是主线程和协助线程同时执行同样的工作,但是这仍然是一种 ‘stop-the-world’ 的垃圾回收方式,但是垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。这是这三种技术中最简单的 JavaScript 垃圾回收方式;因为没有 JavaScript 的执行,因此只要确保同时只有一个协助线程在访问对象就好了。

image.png

增量垃圾回收

增量式垃圾回收是主线程间歇性的去做少量的垃圾回收的方式。我们不会在增量式垃圾回收的时候执行整个垃圾回收的过程,只是整个垃圾回收过程中的一小部分工作。做这样的工作是极其困难的,因为 JavaScript 也在做增量式垃圾回收的时候同时执行,这意味着堆的状态已经发生了变化,这有可能会导致之前的增量回收工作完全无效。从图中可以看出并没有减少主线程暂停的时间(事实上,通常会略微增加),只会随着时间的推移而增长。但这仍然是解决问题的的好方法,通过 JavaScript 间歇性的执行,同时也间歇性的去做垃圾回收工作,JavaScript 的执行仍然可以在用户输入或者执行动画的时候得到及时的响应。

image.png

并发垃圾回收

并发是主线程一直执行 JavaScript,而辅助线程在后台完全的执行垃圾回收。这种方式是这三种技术中最难的一种,JavaScript 堆里面的内容随时都有可能发生变化,从而使之前做的工作完全无效。最重要的是,现在有读/写竞争(read/write races),主线程和辅助线程极有可能在同一时间去更改同一个对象。这种方式的优势也非常明显,主线程不会被挂起,JavaScript 可以自由地执行 ,尽管为了保证同一对象同一时间只有一个辅助线程在修改而带来的一些同步开销。

image.png

总结

大部分 JavaScript 开发人员并不需要考虑垃圾回收,但是了解一些垃圾回收的内部原理,可以帮助你了解内存的使用情况,以及采取合适的编范式。

从上面的分析你也能看出来,无论是垃圾回收的策略,还是处理Orinoco回收执行机制的策略,往往都没有一个完美的解决方案,你需要花一些时间来做权衡,而这需要牺牲当前某几方面的指标来换取其他几个指标的提升。