跳到主要内容

垃圾回收

2024年04月07日
柏拉文
越努力,越幸运

一、认识


数据是存储在两种内存空间中的,所以接下来我们就来分别介绍栈中的垃圾数据堆中的垃圾数据是如何回收的。

二、栈数据的垃圾回收


执行函数时,JavaScript 引擎会创建函数的上下文,并将函数的执行上下压入到调用栈中。与此同时,还有一个记录当前执行状态的指针ESP,指向调用栈中当前执行的函数上下文。当函数执行完毕,JavaScript 会将 ESP 下移,这个下移的操作就是销毁函数上下文的过程。

三、堆数据的垃圾回收


目前,V8 采用两个垃圾回收器:

  • 主垃圾回收器 - Major GC: 主要负责老生代的垃圾回收

  • 副垃圾回收器 - Minor GC (Scavenger) : 主要负责新生代的垃圾回收

V8 之所以使用了两个垃圾回收器,主要是受到了 代际假说(The Generational Hypothesis) 的影响,代际假说是垃圾回收领域中一个重要的术语,它有以下两个特点:

  • 第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;

  • 第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。

V8 的垃圾回收策略,就是建立在该假说的基础之上的。接下来,我们来分析下 V8 是如何实现垃圾回收的。如果我们只使用一个垃圾回收器,在优化大多数新对象的同时,就很难优化到那些老对象,因此你需要权衡各种场景,根据对象生存周期的不同,而使用不同的算法,以便达到最好的效果。所以,在 V8 中,会把分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。新生代通常只支持 1~8M 的容量,而老生代支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

3.1 副垃圾回收器 - Minor GC

副垃圾回收器主要负责新生代的垃圾回收。通常情况下,大多数小的对象都会被分配到新生代,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。新生代中的垃圾数据Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域 (from-space),一半是空闲区域 (to-space), 如下图所示:

Preview

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。

垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时,这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

不过,副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。

3.2 主垃圾回收器 - Major GC

主垃圾回收器主要负责老生代中的垃圾回收。除了新生代晋升的对象,一些大的对象会直接被分配到老生代里。因此,老生代中的对象有两个特点:

  • 一个是对象占用空间大

  • 另一个是对象存活时间长

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

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

  2. 接下来就是垃圾的清除过程, 它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉

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

四、V8 是如何优化垃圾回收执行效率


V8 最开始的垃圾回收器有两个特点:

  1. 第一个是垃圾回收在主线程上执行
  2. 第二个特点是一次执行一个完整的垃圾回收流程

由于这两个原因,很容易造成主线程卡顿,所以 V8 采用了很多优化执行效率的方案:

  1. 第一个方案是并行回收: 在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。采用并行回收时,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。这种方式比较简单,因为在执行垃圾标记的过程中,主线程并不会同时执行 JavaScript 代码,因此 JavaScript 代码也不会改变回收的过程。 虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。

  2. 第二个方案是增量式垃圾回收: 所谓增量式垃圾回收是指: 垃圾回收器标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为增量回收并发的(concurrent),要实现增量执行,需要满足两点要求:

    • 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动。
    • 在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理
  3. 第三个方案是并发回收: 所谓并发回收,是指回收线程在执行 JavaScript 的过程,辅助线程能够在后台完成的执行垃圾回收的操作。并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由地执行 ,在执行的同时,辅助线程可以执行垃圾回收操作。但是并发回收却是这三种技术中最难的一种,这主要由以下两个原因导致的:

    • 第一,当主线程执行 JavaScript 时,中的内容随时都有可能发生变化,从而使得辅助线程之前做的工作完全无效
    • 第二,主线程辅助线程极有可能在同一时间去更改同一个对象,这就需要额外实现读写锁的一些功能了

主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案。

  • 主垃圾回收器:

    • a. 首先主垃圾回收器主要使用并发标记,我们可以看到,在主线程执行 JavaScript辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的
    • b. 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。
    • c. 另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行
  • 副垃圾回收器: V8 的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。

五、V8 是如何实现垃圾回收器的暂停和恢复执行的


  • 没有增量算法之前的垃圾回收: V8 使用黑色白色来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为黑色。遍历结束之后,被标记为黑色的数据就是活动数据,那些白色数据就是垃圾数据问题: 如果内存中的数据只有两种状态,非黑即白,那么当你暂停了当前的垃圾回收器之后,再次恢复垃圾回收器,那么垃圾回收器就不知道从哪个位置继续开始执行了。

  • 有增量算法之后的垃圾回收: V8 采用了三色标记法,除了黑色和白色,还额外引入了灰色:

    • 黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了
    • 灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点
    • 白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。

    引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。

    但是,仅用三色标记法有一个问题: 当垃圾回收器将某个节点标记成了黑色,然后这个黑色的节点被续上了一个白色节点,那么垃圾回收器不会再次将这个白色节点标记为黑色节点了,因为它已经走过这个路径了,但是这个新的白色节点的确被引用了,所以我们还是需要想办法将其标记为黑色。为了解决这个问题,增量垃圾回收器添加了一个约束条件:不能让黑色节点指向白色节点,通常我们使用写屏障 (Write-barrier) 机制实现这个约束条件,也就是说,当发生了黑色的节点引用了白色的节点写屏障机制强制被引用的白色节点变成灰色的,这样就保证了黑色节点不能指向白色节点的约束条件。这个方法也被称为强三色不变性,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放。