RadiomM
文章22
标签13
分类1
v8垃圾回收

v8垃圾回收

距离上一次发文已经过去一个半月了,其实写这篇文章是很快就可以出来的,为什么会这么久才写呢?其实是这样的,最近在研究vue3的源码,简单的实现了一下里面的API,熟悉一下新版vue的核心逻辑,其实里面我最想了解的是编译模块,我的理解是,学会了编译模块才能算的上一个高级工程师。好了,废话说多了,现在上主菜,这次要说的就是上一篇文章说要补充的v8垃圾回收机制,由于我们开发基本在测试过程中基本用的是chrome浏览器,它的内核是v8,所以有必要去了解一下它是如何在浏览器中进行垃圾回收工作的。

V8 垃圾回收工作原理

在上一篇文章中我们说了,现在主流的垃圾回收的算法基本都是标记清除,V8其实就是而且存在了不少的优化,下面来介绍一下工作原理。

首先,通过 GC Root 标记空间中活动对象非活动对象。将一些 GC Root 作为初始存活的对象的集合,从 GC Roots 对象出发,遍历 GC Root 中的所有对象:

  • 通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable)
    那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;

  • 通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable)
    那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。

在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):

  • 全局的 window 对象(位于每个 iframe 中);

  • 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;

  • 存放栈上变量。

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

最后做的是内存碎片化整理。一般来说,经历过频繁的垃圾回收的话,会出现大量不连续空间,这些不连续空间就可以成为是内存碎片。当内存碎片多的时候,假如出现要一次性分配较大的连续内存大的时候,就会出现内存不足的情况,所以需要在最后一步整理内存碎片,将他们整理成一个连续的内存空间。但是,不一定每次的垃圾回收都会产品内存碎片,所以未必都会做到这一步。

v8的垃圾回收实现

目前V8采用了两个垃圾回收器,主垃圾回收器 -Major GC 和副垃圾回收器 -Minor GC (Scavenger)。为什么会使用两个回收器呢?是受到代际假说(The Generational Hypothesis)的影响,简单的归纳就是两点:

  • 1、大部分的对象存活的时间不久,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁,所以这些内存实际上会很快就被回收到。

  • 2、有一些基本不会被回收的对象,比如全局的 window、DOM、Web API 等对象。

在此基础上,V8还有关于的优化,将堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生带中存放生存时间久的对象。。新生代通常只支持1~8M的容量,而老生代支持的容量会大很多。
其实对应的就是上面的两个回收器。

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

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

副垃圾回收器

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

新生代中的垃圾数据用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域 (from-space),一半是空闲区域 (to-space),如下图所示:

V8的堆空间

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

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

垃圾回收图例1

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

垃圾回收图例2

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

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

主垃圾回收器

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

  • 一个是对象占用空间大;

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

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

那么,标记 - 清除算法是如何工作的呢?

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

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

你可以理解这个过程是清除掉下图中红色标记数据的过程,你可参考下图大致理解下其清除过程:

垃圾回收图例3

对垃圾数据进行标记,然后清除,这就是 标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法—— 标记 - 整理(Mark-Compact)

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

垃圾回收图例4

总结

基本上,我们了解到V8垃圾回收的算法,原理,以及实现,后续可能会更新有关V8垃圾回收的优化。