垃圾回收只知道标记清除?一文帮你打通V8垃圾回收!

3,276 阅读10分钟

前言:本文旨在帮你建议起垃圾回收的体系化知识,仔细品味目录,相信你能体会得的到。

V8 垃圾回收核心思路

第一步 通过 GC Root 标记活动对象与非活动对象

如何判断活动对象?

V8 通过 可访问性算法 来判断堆中对象是否是活动对象。

具体地讲:将一些 GC Root(包括Window对象文档DOM树)作为初始存活的对象的集合,从GC Root出发,遍历所有的对象。遍历到的对象我们认为它是可访问的,即活动对象;反之是非活动对象。

第二步 回收非活动对象的内存

第三步 内存整理

为什么要做内存整理?

回收内存后,内存中出现大量不连续的空间,如果不清理,那么当我们需要分配大量连续内存时,就会内存不足。

详解 V8 垃圾回收

依据:代际假说

两个重要特点:

  • 大部分对象存活的时间很短(比如函数内部声明的变量),很快就变的 不可访问
  • 不死的对象:全局的 window 、 DOM 、Web API

划分新生代与老生代

为了达到最好的效果,V8把堆分为新生代与老生代。

新生代通常只支持 1~8M 的容量,而老生代支持的容量很大。

副垃圾回收器:负责新生代的垃圾回收。

主垃圾回收器:负责老生代的垃圾回收。

副垃圾回收器

大多数小的对象都会被分配到新生代,垃圾回收比较频繁。

分区

使用 Scavenge 算法,把新生代空间对半划分为两个区域,一半是对象区 ,一半是空闲区,如图:

新生代区域划分.jpg

如何清理?

新加入的对象都会被存放到对象区域,当对象区域快被写满时,执行一次垃圾回收

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

复制到空闲区.jpg

复制后,对象区与空闲区角色互换。这样就完成了垃圾回收。

同时,这种角色互换的操作还能让新生代中的这两块区域无限重复使用下去。

问:为什么新生区空间设置的很小?

  • 副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区复制到空闲区,复制操作需要时间成本。如果新生区空间设置得太大了,那么每次清理的时间就会过久。
  • 也正是因为新生区的空间不大,所以对象区很容易被装满,一旦对象区装满了,便执行垃圾回收。

对象晋升策略

副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。

主垃圾回收器

老生代对象来源

  • 新生代中晋升的对象
  • 一些大的对象会直接被分配到老生代里

老生代对象特点

一个是对象占用空间大; 另一个是对象存活时间长

为什么不使用副垃圾回收器回收方式?

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

标记 - 清除 如何工作?

标记阶段

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

垃圾清除阶段

主垃圾回收器直接将标记为垃圾的数据清理掉。

标记清除.jpg

标记 - 清除的优化 :标记 - 整理

标记清除导致内存碎片越来越多,所以引入另外一种算法——标记 - 整理(Mark-Compact)

标记过程与标记 - 清除算法一样,先标记可回收对象,但后续步骤不是直接清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端以外的内存。

标记整理.jpg

如何优化垃圾回收器效率?

全停顿

由于 JavaScript 运行在主线程上的,因此,一旦执行垃圾回收算法,都需要暂停主线程上的其他任务,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做 全停顿(Stop-The-World)

全停顿.jpg

显然:垃圾回收时全停顿会导致用户感到卡顿。

解决思路

  • 第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;
  • 第二,将标记对象、移动对象等任务转移到另一个线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题。

并行回收

思路就是主线程在执行垃圾回收的任务时,引入多个辅助线程来并行处理,这样就会加速垃圾回收的执行速度,因此 V8 团队引入了并行回收机制。

并行回收.jpg

V8副垃圾回收器所采用的就是并行回收,它在执行垃圾回收的过程中,启动了多个线程来负责新生代的垃圾清理操作,这些线程同时将对象区中的数据移动到空闲区。(由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针)

不足

虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是全停顿的垃圾回收方式,这依然存在效率问题。

这尤其体现在老生代,老生代存放的都是一些大的对象,如 window、DOM 这种,完整执行老生代的垃圾回收,时间依然会很久。

所以,增量回收来了。

增量回收

所谓增量回收,是指垃圾收集器将标记工作分解为更小的块,穿插在主线程不同的任务之间执行。采用增量回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分。

增量回收.jpg

增量标记算法需要满足的要求

  • 垃圾回收可以被暂停和重启,暂停时需要保存当时的标记结果,等下一次垃圾回收继续启动。
  • 在暂停期间,被标记好的垃圾数据如果被 JavaScript 修改了,那么垃圾回收器需要能够正确地处理。

这里我们需要知道,在没有采用增量回收以前,V8 使用黑色和白色来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为黑色。遍历结束之后,被标记为黑色的数据就是活动数据,那些白色数据就是垃圾数据。

如果内存中的数据只有两种状态,非黑即白。那么当你暂停了当前的垃圾回收器之后,恢复时垃圾回收器就不知道从哪个位置继续开始执行了。

三色标记法

除了黑色和白色,还额外引入了灰色。

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

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

写屏障机制

const obj={}
obj.a=[1]
obj.a=[2]
//此时,obj.a为白色节点
//然而,obj.a即[2]是可以被访问的

所以我们用写屏障机制来实现不能让黑色节点指向白色节点这个约束条件。

也就是说,当发生了黑色节点引用了白色节点,写屏障机制会强制将被引用的白色节点变成灰色。这个方法也被称为强三色不变性,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是可以清理的。

并发回收

虽然通过三色标记法和写屏障机制可以很好地实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,如果主线程繁忙的时候,增量垃圾回收操作依然会增加降低主线程处理任务的吞吐量 (即没有改变在主线程上运行的本质)。

所谓并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。

并发回收.jpg

并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由地执行 ,在执行的同时,辅助线程可以执行垃圾回收操作。

难点

但是并发回收却是这三种技术中最难的一种,这主要由以下两个原因导致的:

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

尽管并行回收要额外解决以上两个问题,但是权衡利弊,并行回收这种方式的效率还是远高于其他方式的。

主垃圾回收器融合了三种机制

不过,这三种技术在实际使用中,并不是单独的存在,通常会将其融合在一起使用。

三种方式融合使用.jpg

  • 首先主垃圾回收器主要使用并发回收,在主线程执行 JavaScript时,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的。
  • 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作(并行回收)。
  • 另外,主垃圾回收器还采用了增量回收的方式,清理的任务会穿插在各种 JavaScript 任务之间执行。

小结

主垃圾回收器综合采用了三种方案,副垃圾回收器采用了并行回收。

参考

图解 Google V8