Google V8剖析之垃圾回收

vicki2024 2024-09-18 14:03:02 阅读 76

JavaScript 是一门自动垃圾回收的语言,开发者使用时并不需要手动回收垃圾数据,而是交由V8 的垃圾回收器自动完成。下边来具体看看V8垃圾回收机制。

1. 为什么需要垃圾回收

在Chrome中,v8被限制了内存的使用(64位约1.4G/1464MB , 32位约0.7G/732MB),限制的原因主要是V8最初为浏览器而设计,不太可能遇到用大量内存的场景,并且清理大量的内存垃圾是很耗时间,导致JavaScript线程暂停执行,产生卡顿。

2. 垃圾回收流程

垃圾回收大致可以分为以下几个步骤:

第一步,通过 GC Root 标记空间中<code>活动对象code>和非活动对象

采用可访问性(reachability)算法来判断堆中的对象是否是活动对象。具体地讲,就是将一些GC Root作为初始存活的对象的集合,从 GC Roots 对象出发,遍历 GC Root 中的所有对象:

通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable),那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。

那么GC Roots 对象有哪些呢?

在浏览器环境中,GC Root 有很多,通常包括了以下几种:全局的 window 对象(位于每个 iframe 中);文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;存放栈上变量。

第二步,回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。

第三步,做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。

3. V8垃圾回收器

V8 为了更高效地回收垃圾, 采用了两个垃圾回收器,主垃圾回收器 -Major GC副垃圾回收器 -Minor GC (Scavenger)

在 V8 中,会把堆分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,为了执行效率,一般新生区的空间被设置的比较小,通常只支持 1~8M 的容量。老生代中存放生存时间久的对象。

副垃圾回收器 -Minor GC (Scavenger),主要负责新生代的垃圾回收。主垃圾回收器 -Major GC,主要负责老生代的垃圾回收。

副垃圾回收器

副垃圾回收器主要负责新生代的垃圾回收。通常情况下,大多数小的对象都会被分配到新生代,因此这个区域虽然不大,但是垃圾回收还是比较频繁的。

新生代中的垃圾数据用Scavenge 算法,具体就是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域

在这里插入图片描述

新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域。

在这里插入图片描述

完成复制后,将两个区域互换,这样就完成了垃圾对象的回收操作。

在这里插入图片描述

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

主垃圾回收器

主垃圾回收器主要负责老生代中的垃圾回收,会经历标记、清除和整理过程。除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。因此,老生代中的对象有两个特点:

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

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

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

接下来就是垃圾的清除过程。不同于副垃圾回收器的垃圾清除过程,主垃圾回收器会直接将标记为垃圾的数据清理掉。

在这里插入图片描述

由于对同一块内存多次执行标记-清除算法后,会产生大量不连续的碎片,进而导致大的对象无法分配足够的连续内存,于是引入了另外一种算法–<code>标记 - 整理(Mark-Compact)。

这个算法的标记过程仍然与标记 - 清除算法里的是一样的,先标记可回收对象,区别在于标记后不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存。过程见下图:

在这里插入图片描述

4.垃圾回收优化

V8 最开始的垃圾回收器有两个特点,第一个是垃圾回收在主线程上执行,第二个特点是一次执行一个完整的垃圾回收流程。因此一旦执行垃圾回收算法,都需要将正在执行的其他任务暂停下来,待垃圾回收完毕后再恢复执行。

在这里插入图片描述

如果占用主线程时间过长,就很容易造成主线程卡顿,也就是用户可见的页面卡顿,体验不佳。所以 V8 采用了很多优化执行效率的方案,主要是从这两方面解决垃圾回收效率问题的:

第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。

下来具体来看看V8的优化方案:

第一个方案是<code>并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。

在这里插入图片描述

垃圾回收所消耗的时间 = 总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间)+同步开销的时间

这个方案,由于主线程任务和垃圾回收标记/清理操作是串行的,就不用考虑回收的状态被更改,只要确定只有一个协助线程访问对象即可。

这个方案缺点是仍然是一种主线程全停顿的垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,效率是一个问题,比如在回收老生代存放的都是一些大的对象,如 window、DOM 这种,完整执行老生代的垃圾回收,时间依然会很久。

第二个方案是<code>增量式垃圾回收,垃圾回收器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。

在这里插入图片描述

增量标记的算法是并发的,不是全停顿。要实现增量执行,需要满足:

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

咱们先来看第一点,V8 是如何实现垃圾回收器的暂停和恢复执行的。

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

在这里插入图片描述

问题一:因为增量回收有暂停状态,如果只有黑色和白色,那么再次启动,无法知道从哪开始,于是V8 采用了<code>三色标记法,引入了灰色,表示当前正在处理的节点。

问题二:标记好的垃圾数据被 JavaScript 修改了,V8 要如何处理的?

为了解决这个问题,增量垃圾回收器添加了一个约束条件:不能让黑色节点指向白色节点。

实现这个约束条件,也就是说,当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点变成灰色的,这样就保证了黑色节点不能指向白色节点的约束条件。它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放。

第三个方案是<code>并发回收,回收线程在执行 JavaScript 的过程,辅助线程能够在后台完成的执行垃圾回收的操作。

在这里插入图片描述

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

在实际应用中,这三种优化方案通常会融合使用,主垃圾回收器就综合采用了所有的方案,副垃圾回收器采用采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,并且复制完成后,还需要同步更新引用这些对象的指针,以便于其他线程判断该对象是否已被复制。

以上,如有理解不恰当之处,欢迎指正~



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。