V8垃圾回收机制总结

1,637 阅读14分钟

前方提醒: 篇幅较长,点个赞或者收藏一下,可以在下一次阅读时方便查找

V8的垃圾回收机制

JavaScript是由垃圾回收机制自动进行内存管理的,在我们编写代码的过程中不需要像C/C++程序员那样时刻关注内存的分配和释放问题。在chrome浏览器或者node中,这些工作都是交给V8的垃圾回收器自动完成的。

接下来我们来了解一下V8是如何帮助我们进行垃圾回收的。

1. V8是如何存储数据的?

在了解垃圾回收之前,需要先了解数据是如何保存的。

V8中将内存分为栈空间堆空间

  • 栈空间中的变量保存原始类型的数据和引用类型的数据在堆空间中的地址
  • 堆空间中保存引用类型的数据
var a = 1;
var b = {num: 2};
var c = 3;
var d = b;

上述代码在内存中的保存形式如图:

image.png

我们所要讨论的垃圾回收都是基于堆空间的。

2. 什么是"垃圾"?

有些数据被使用之后,就不在被需要了,但还是保存在内存中,这样的无用数据就是垃圾

修改上面的代码:

var a = 1;
var b = {num: 2};
var c = 3;
var d = b;
b = {num: 4};
d = b;

此时,栈空间和堆空间变为如下的情况:

image.png

堆空间中地址为0x001的对象没有被任何变量所引用,它就变成了垃圾数据,可以被垃圾回机制回收。

2.1 如何判断需要回收的内容

目前V8采用的可访问性(reachability)算法来判断对象是否是活动对象,这个算法是将一些GC Root根对象)作为初始存活的对象的集合,从GC Roots对象出发,遍历GC Root中的所有对象:

  • 通过GC Root能访问到的对象,我们就认为该对象是可访问的,那么必须保证这些对象应该在内存中保留,这些对象为活动对象
  • 通过GC Roots不能访问到的对象就可能被回收,这些对象为非活动对象

GC Root有很多,通常包括了以下几种:

  • 全局对象window、global
  • DOM树,由可以通过遍历文档到达的所有原DOM节点组成
  • 存放在栈上变量
window.test = new Object();
window.test.a = [];

执行上述代码,内存中的情况如下图所示:

image.png

再将另一个对象赋值给a属性:

window.test.a = new Object();

image.png

此时堆中的数组就成为了为非活动对象,因为我们无法从一个GC Root遍历到这个数组,垃圾回收机制会把它自动清理。

3. 代际假说(The Generational Hypothesis)

代际假说是垃圾回收领域中一个重要的术语,它有以下两个特点:

  • 一是大部分对象在内存中存在的时间很短,就是说很多对象一经分配内存,立即被使用后,很快就不在被需要了。比如函数局部变量,或者块级作用域中的变量
  • 二是不死的对象,会存活得很久。比如全局变量、 window、DOM等对象

V8的垃圾回收机制,就是建立在代际假说的基础之上的。接下来,我们来分析下 V8 是如何实现垃圾回收的。

4. 新生代和老生代

在实际的应用中,对象生存周期长短不一,不同的垃圾回收算法只针对特定情况具有最好的效果,针对这种情况,V8将堆内存分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。V8对两个区域使用不同的垃圾回收器,以便达到最好的效果。

  • 新生代在64位系统中大小为64M,32位系统中大小为32M
  • 老生代在64位系统中大小为1400M,32位系统中大小为700M
  • 副垃圾回收器 - Minor GC,主要负责新生代的垃圾回收
  • 主垃圾回收器 - Major GC,主要负责老生代的垃圾回收

image.png

5. 新生代的垃圾回收

新生代中的大部分对象在内存中存活的周期很短,且回收频繁,所以需要一个效率非常高的算法。副垃圾回收器使用Scavenge算法进行处理,该算法把新生代空间对半划分为两个区域,一半是From空间,处于使用状态;一半是To空间,处于闲置状态。

image.png

在新生代分配内存非常容易,我们只需要保存一个指向内存区的指针,不断根据新对象的大小进行指针的递增即可。当该指针到达了新生代内存区的末尾,就需要一次清理。

5.1 Scavenge算法

Scavenge算法是一个空间换时间的复制算法,在占用空间不大的场景上非常适用。 新加入的对象会存放到From空间,当From空间快被写满时,就需要执行一次垃圾清理操作,大致的步骤如下:

  1. 检查From空间的活动对象,将其复制到To空间
  2. 释放掉From空间中的非活动对象
  3. 完成复制和内存释放后,将From空间和To空间的角色进行对换

Scavenge算法伪代码:

def scavenge():
    // From和To进行交换
    swap(fromSpace, toSpace)
    // 在To空间中维护两个指针allocationPtr和scanPtr
    // allocationPtr指向新对象要复制到的地方
    // scanPtr指向即将要进行扫描的对象
    allocationPtr = toSpace.bottom
    scanPtr = toSpace.bottom
    
    // 处理根对象能够直接访问的对象
    for i = 0..len(roots):
        root = roots[i]
        if inFromSpace(root):
            // 将根对象能直接访问到的对象root复制到To空间中allocationPtr指向的地方,并根据root的大小更新allocationPtr
            rootCopy = copyObject(&allocationPtr, root)
            // 更新root的地址
            setForwardingAddress(root, rootCopy)
            roots[i] = rootCopy

    // 采用BFS的遍历方式,开始遍历所有能到达的对象
    while scanPtr < allocationPtr:
        obj = object at scanPtr
        // 每处理一个对象,scanPtr就向后移动
        scanPtr += size(obj)
        n = sizeInWords(obj)
        // 处理obj的所有子节点
        for i = 0..n:
            if isPointer(obj[i]) and not inOldSpace(obj[i]):
                fromNeighbor = obj[i]
                // 如果对象已经被复制到To空间,取它在To空间的地址
                if hasForwardingAddress(fromNeighbor):
                  toNeighbor = getForwardingAddress(fromNeighbor)
                // 如果对象不在To空间,将其复制到To空间allocationPtr所指的位置,并根据该对象的大小更新allocationPtr
                else:
                  toNeighbor = copyObject(&allocationPtr, fromNeighbor)
                  setForwardingAddress(fromNeighbor, toNeighbor)
                obj[i] = toNeighbor
    // 当scanPtr == allocationPtr时,所有能到达的对象被处理完成,都被复制到了To空间,此时From空间将被清理

def copyObject(*allocationPtr, object):
  copy = *allocationPtr
  // 根据对象大小更新allocationPtr
  *allocationPtr += size(object)
  // 将object复制到copy指向的位置,也就是更新之前的allocationPtr位置
  memcpy(copy, object, size(object))
  return copy

Scavenge算法过程:

  • 上述伪代码如果不好理解的话,可以看如下的例子:
var A = {C: {}};
var B = {
 D: {
     F: {},
     G: {}
 },
 E: {}
};
  • 该代码表示的引用关系如图:

image.png

  • 再执行:
delete A.C;
  • 变成了下图所示的情况:

image.png

  • 当使用Scavenge算法开始进行垃圾回收前,To空间的情况如下所示:

image.png

  • 开始进行垃圾回收后:

    1.将根对象能到达的A、B复制到To,并后移allocationPtr
    image.png 2.查看scanPtr指向的A对象,由于A没有指向其他对象,所以将scanPtr后移 image.png 3.查看scanPtr指向的B对象,发现B能够访问到D、E,将D和E依次复制到To空间,并移动allocationPtr和scanPtr
    image.png
    4.查看scanPtr指向的D对象,发现D能够访问到F、G,将F和G依次复制到To空间,并移动allocationPtr和scanPtr
    image.png 5.查看scanPtr指向的E对象,发现其没有指向其他对象,所以将scanPtr后移 image.png 6.依次查看F对象和G对象,发现其都没有指向其他对象,继续将scanPtr后移
    image.png 7.scanPtr和allocationPtr相等时,说明可访问的对象都已经被处理完成,From空间中剩余的C变量将被释放

Scavenge算法的优缺点:

  • 优点:只复制活动对象,生命周期短的场景种活动对象只占很少一部分,所以执行效率很高;复制过程中就能完成内存整理,避免产生内存碎片
  • 缺点:浪费一半新生代的空间

5.2 晋升策略

在一定的条件下,需要把存活周期长的对象移动到老生代中,也就是完成了对象的晋升。在从From空间复制到To空间前,会进行下面的步骤:

  1. 检查对象的内存地址来判断这个对象是否已经经历过一次Scavenge回收,如果是,则复制到老生代中,否则复制到To空间中
  2. 如果To空间已经被使用了超过25%,这个对象直接被复制到老生代

image.png

6. 老生代的垃圾回收

Scavenge算法会浪费一半空间,因此Scavenge算法并不适用于老生代空间,V8在老生代中的垃圾回收是采用了标记 - 清除Mark- Sweep)和标记 - 整理Mark - Compact)两种算法相结合的方式进行的。

6.1 标记 - 清除(Mark-Sweep)

顾名思义, 标记 - 清除算法分为两个过程:

  • 标记过程:从根对象触发,对所有可以访问到的对象进行标记,没有访问到的对象就是要回收的数据
  • 清除过程:直接清除掉上一步中没有被标记到的对象

image.png 由于清除阶段只是清除未被标记的对象,这部分对象在老生代中占比很小,所以标记 - 清除算法的效率较高。

6.2 标记 - 整理 (Mark-Compact)

标记 - 清除算法执行后,内存中会产生大量不连续的内存碎片,这样会导致内存中没有足够的连续内存分配给较大的对象,于是V8又引入了另外一种算法:标记 - 整理Mark - Compact)。

它的标记过程和标记 - 清除算法一致,但接下来标记 - 整理算法不是直接对未标记的对象进行清除,而是让所有标记过的对象都移向内存的一端,然后直接清理掉这一端之外的内存,起到了整理内存的作用。

image.png

6.3 结合使用两种算法

由于标记 - 整理算法需要移动对象,因此它的速度不会很快,V8结合了标记 - 清除和标记 - 整理算法,主要采用标记 - 清除算法,如果空间不足的时候,才使用标记整理。

7. V8垃圾回收的优化策略

接下来学习V8是如何优化垃圾回收的执行效率的。

7.1 全停顿(Stop-The-World)

最初,为了避免js逻辑和垃圾回收器看到的情况不一致的问题,V8采用了垃圾回收时将js执行暂停下来的方式,等待垃圾回收结束后才恢复js的执行,这种行为被成为全停顿Stop-The-World)。

image.png

这种方式的劣势明显,它会阻塞js的执行,如果垃圾回收占用的时间较长,就会造成页面明显的卡顿。为了解决全停顿的问题,V8添加并行、增量、并发等技术对垃圾回收机制进行了优化。

接下来分别针对这三种优化方式做出解释。

7.2 并行回收(Parallel)

并行方式是主线程在执行垃圾回收的任务的同时,使用多个辅助线程来并行处理,这样就会加快垃圾回收的执行速度。

image.png

新生代中副垃圾回收器采用的就是并行方式,它在主线程执行垃圾回收的过程中,启动了多个辅助线程来负责垃圾清理操作,这些辅助线程同时将From空间中的数据移动到To区域。但本质上并行方式还是一种"全停顿",因此还不能满足对性能要求更高的老生代垃圾回收。

7.3 增量回收 (Incremental)

2011年,V8 从又引入了增量回收Incremental)的方式。垃圾回收器不需要一次执行完整的垃圾回收过程,每次只执行整个垃圾回收过程中的一小部分工作,比如每次标记一部分数据,可以参考下图:

image.png

主线程中,js和垃圾回收交替执行,可以避免单次垃圾回收时间过长造成的卡顿问题。

增量回收Incremental)会带来两个问题:

  • 垃圾回收和js切换执行,暂停垃圾回收时需要保存当时的标记结果,切换回来之后需要知道从哪个位置继续执行
  • 切换到js执行后,js代码的执行可能会修改之前已经标记好的数据,造成影响

针对上面的两个问题,V8引入了三色标记法写屏障机制Write-barrier)来解决。

7.3.1 三色标记法

为了解决增量回收中垃圾回收恢复执行时不知道从哪个位置继续开始执行的问题,V8采用黑、白、灰三色标记法

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

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

image.png

image.png

image.png

7.3.2 写屏障机制(Write-barrier)

垃圾回收器将某个节点标记为黑色后,js代码执后又为该黑色节点增加了一个节点,由于新增节点都是白色,垃圾回收器不会再次将这个白色节点进行标记了,它就会被垃圾回收器回收。

执行了:

window.a = Object()
window.a.b = Object()
window.a.b.c=Object() 

image.png

又只执行了:

window.a.b = Object() // d

image.png

这时就出现了黑色节点指向白色节点的问题,会造成新增节点的误回收。写屏障机制就是要强制保证黑色节点不能指向白色节点。

在执行object.field = value时,V8就插入写屏障代码,强制将value标记为灰色。

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}

7.4 并发回收 (Concurrent)

并发是指主线程不断执行js代码,而辅助线程则在后台完全执行垃圾回收。

image.png

并发回收主要有以下两个问题:

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

但是权衡利弊,并行回收这种方式的效率还是远高于其他方式。

7.5 三种方式组合

并行、增量、并发三种方式在V8的实际应用中不是单独存在的,V8的主垃圾回收器融合了这三种机制。

image.png

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

8. 总结

本文从V8的数据存储、内存分代、垃圾回收算法、优化策略几个方面进行了讲解,虽然内容不少,但还是忽略很多细节,V8垃圾回收机制的细节非常复杂。我们大多数开发人员在开发JavaScript时不需要需考虑垃圾回收,但是了解一些垃圾回收的内部知识可以帮助我们考虑内存使用的情况,更好的进行内存问题的分析、排查和解决,让我们在快节奏的技术迭代中把握本质。

参考资料