V8引擎详解(七)——垃圾回收机制

5,941 阅读12分钟

前言

本文是V8引擎详解系列的第七篇,重点内容是关于V8的垃圾回收机制,以及V8对垃圾回收的优化策略,本文首先需要对内存结构有一个初步了解,不了解的可以先看一下V8引擎详解(六)——内存结构。 文末会有已经完成的系列文章的链接,本系列文章还在不断更新欢迎持续关注。

垃圾回收

我们先简单了解一下垃圾回收的概念,比如V8引擎在执行代码的过程中遇到了一个函数,那么我们会创建一个函数执行上下文环境并添加到 调用栈 顶部,函数的作用域里面包含了函数中所有的变量信息,在执行过程中我们分配内存创建这些变量,当函数执行完毕后函数作用域会被销毁,那么这个作用域包含的变量也就失去了作用,那么销毁它们回收内存的过程,我们就叫做垃圾回收。

垃圾回收的过程是v8引擎自动帮我们执行的,在绝大部分情况下v8都能很好的完成这个过程,但是作为一段程序,能帮我们cover住的情况是有限的,所以一旦我们代码不够严谨,就会引发内存泄露。

大家都知道javascript语言的一个特点就是单线程,单线程意味着执行的代码都是按顺序执行的且同一时间也只能处理一个任务,那么V8在执行垃圾回收任务的时候,其他的任务都将处于等待状态,直到垃圾回收任务结束后才能执行其他任务,如果垃圾回收任务的执行时间过长就不可避免的对用户体验造成影响,V8为了减少这种影响也做了一系列的优化,我们一起来看一下V8到底是如何做垃圾回收的并且是如何优化的。

垃圾回收器

代际假说(The Generational Hypothesis)是垃圾回收领域中的一个重要术语, V8的垃圾回收的策略也是建立在该假说的基础之上。
代际假说也很简单,主要有两个特点:

  • 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问。
  • 不死的对象,会活的更久。

基于这个这个假说 V8 才会把堆分为新生代和老生代两个区域,同时设计了两个垃圾回收器:

  • 副垃圾回收器 负责新生代区域的垃圾回收
  • 主垃圾回收器 负责老生代区域的垃圾回收

(新生代和老生代已经在作者之前的文章介绍过,不了解的可以看之前的文章)

副垃圾回收器(Scavenging)

副垃圾回收器主要用来回收新生代的垃圾,通常我们新创建的对象都会先分配到新生代内存区中。
新生代内存区会分成两个部分(space),from spaceto space , 这两个区域本质都是一样的,都拥有两个状态 工作状态空闲状态且当一个为工作状态的时候另一个一定是空闲状态。

比如我们新创建一个对象:

  • 会向内存堆中的新生代去分配,假如此时新生代中的from spcae 是工作状态,那么对象会分配到from space 中。
  • 经过一段时间程序运行,from space的的内存即将达到存储的上限。
  • V8引擎此时执行一次垃圾清理操作,会将from space中不再使用的对象(根节点无法遍历到的对象)进行标记。
  • 会将未被标记的对象进行复制,复制到空闲状态的to space中并且有序的重新排列起来,再将from space进行清空操作,同时将from space 标记为空闲状态将to space标记为工作状态。

以上就是所谓的置换也可以说是翻转过程,因为这种复制操作需要时间成本,所以新生代的空间往往并不大,所以执行的也较为频繁。

随着程序的运行,某些对象一直在被使用会持续的积压在新生代区域,为了解决这个问题,V8采用了 晋升机制 将满足条件的对象放到老生代内存区中存储,释放新生代内存区域的空间。

晋升机制的条件:

  • 经历过一次Scavenging算法,且并未被标记清除的,也就是过一次翻转置换操作的对象。
  • 在进行翻转置换时,被复制的对象大于to space空间的25%。(from spaceto space 一定是一样大的)

晋升后的对象分配到老生代内存区,便由老生代内存区来管理。

主垃圾回收器(Mark-Sweep & Mark-Compact)

主垃圾回收器主要用来回收老生代的垃圾,通常会有在新生代晋升后的对象以及初始占用空间就很大的对象会存储在老生代内存区。

主垃圾回收器采用的方法和次垃圾回收器的方法完全不同,主垃圾回收器会先使用标记 - 清除(Mark-Sweep)的算法进行垃圾回收。

引用一下李兵老师的描述:

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

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

(图片来源:time.geekbang.org/column/arti…)
整个标记 - 清除(Mark-Sweep)的过程相当于清理上图中红色部分区域的过程。

但是我们通过这种标记清除的方式进行内存清理会产生大量不连续的内存碎片,当我们想要存储一个大的对象的时候就可能没有足够的空间,那么除了执行 标记 - 清除(Mark-Sweep) 算法外,还通过 标记 - 整理(Mark-Compact) 算法进行垃圾回收。

标记 - 整理(Mark-Compact) 算法主要也是分两步:

  • 首先同样是标记过程。
  • 将未标记的对象(存活对象)进行左移,移动完成后清理边界外的内存。

V8通过标记 - 清除(Mark-Sweep) 以及 标记 - 整理(Mark-Compact) 两种算法对老生代内存区进行垃圾回收,这就是主垃圾回收器的主要工作。

垃圾回收优化策略(Orinoco)

上文中描述的V8的两个垃圾回收器所采用的方法其实在具有垃圾回收机制的编程语言中都是非常常见的。
评价一个垃圾回收机制好坏的一个重要标准是取决于执行垃圾回收时主线程挂起的时间,而V8为了优化这一部分体验(减少主线程挂起的时间),启动代号为Orinoco的垃圾回收器项目来专门进行垃圾回收策略的优化。

Orinoco共实现了三个优化

  • 并行垃圾回收 (parallel)
  • 增量垃圾回收 (incremental)
  • 并发垃圾回收 (concurrent)

并行垃圾回收

先说第一个优化 并行垃圾回收,我们之前提到过新生代内存区老生代内存区根据之前讲过的垃圾回收机制,我们可以确定在新生代内存区中的对象和老生代内存区中的对象是完全不同的,那么也就是说新生代在执行 标记->复制->清理 的操作和老生代执行 标记->清理->紧凑 的操作是没有任何依赖关系的。
于是Orinoco判断将没有依赖关系的垃圾清理逻辑(不止上述一种)通过并行执行的方式来优化减少执行垃圾回收占用主进程的时间。所以Orinoco只需要开启辅助几个辅助进程就可以同时完成垃圾清理的工作如下图:

(图片来源:v8.dev/blog/trash-…)

增量垃圾回收

第二个优化 增量垃圾回收, 虽然并行垃圾回收的并行机制可以有效的减少主进程的占用,但是面对一个大的对象一次执行标记也要话很长的时间,从2011年开始V8引入了增量标记机制,也就是增量垃圾回收机制

(图片来源:v8.dev/blog/trash-…)

将一次大的任务分解为更小的块,允许应用程序在块之间运行。
这种优化对于标记的实现带来了很大的挑战,如何保存当时的扫描结果?标记好的数据如果被主线程修改了,如何正确的处理?

于是V8采用了 标记位标记工作表 来实现标记。
标记位用来标记三种颜色:白色(00)灰色(10)黑色(11)

  • 最初状态所有的对象都是 白色 也就是未被根节点引用到的对象。
  • 当垃圾回收程序发现一个对象被引用会将这个对象标记为 灰色 并将其推入到 标记工作表 中。
  • 标记工作表 会访问所有存在自身的 灰色 对象,并访问该对象的所有子对象,结束后会将该对象标记为黑色
  • 标记工作表 会持续的被注入灰色的对象(每发现一个新的要标记的对象都会注入到标记工作表中
  • 如果 标记工作表 中 没有了灰色 的对象,那么代表所有的对象都是 黑色 或者 白色,之后可以放心的清理掉 白色 的对象。

整个过程如图:

从根节点开始标记

遍历处理

完成后的最终形态

这个过程是不是有点绕,那我举个例子(不知道恰不恰当哈)

比如有一个小偷团伙

  • 警察抓到了小偷团伙的A(标记为灰色),但是警察没有办法给他定罪只能交给法庭(标记工作表)。
  • 在法庭上 A 供出了团伙的成员 B ,警察将犯罪团伙的 B 抓了回来(标记为灰色)交给了法庭(标记工作表)。
  • B 说团伙还有个 C,但是 C 是冤枉的没有犯罪(默认标记白色)。
  • 至此结案,会先将 B 定罪(标记为黑色)然后将 A 定罪(标记为黑色),然后A B判刑。

那回到之前的问题,标记好的数据如果被主线程修改了,如何正确的处理? V8 使用了写屏障(write-barrier) 机制来实现,这个机制也不难理解,简单来说就是强制让黑色的对象不能直接指向白色的对象。 比如我们执行一个写入操作:

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

将新写入的对象从初始的白色直接变为灰色,那么 标记工作表 就没有空,那么就继续执行标记的过程,保证了正确的标记数据。

并发垃圾回收

并发垃圾回收和并行垃圾回收是完全不同的用一张图来表示

并行垃圾回收发生在主线程和工作线程上。应用程序在整个并行标记阶段暂停。
并发垃圾回收主要发生在工作线程上。当并发垃圾回收正在进行时,应用程序可以继续运行。

通常以上三种方式也不是单独存在的,而是聚合在一起使用具体如下图:

空闲时垃圾回收

空闲时垃圾回收并不属于Orinoco项目,是V8实现的一种优化策略。
通常调度程序通过对任务队列占用率的了解,以及和V8其他组件接收到的信号,使它可以估计V8何时处于空闲状态,以及可能保持多长时间。利用这个信息,V8可以分配一些优先级不高的垃圾回收任务在这个空闲时间去做。

比如V8会使用Chrome浏览器的task scheduler , 根据从Chrome其他各种组件接收到的信号以及旨在估算用户意图的各种启发式方法,动态地重新分配任务的优先级。例如,如果用户触摸屏幕,则调度程序将在100毫秒的时间段内优先处理屏幕渲染和输入任务,以确保用户界面在用户与网页交互时保持响应。

例如,如果以60 FPS进行渲染,则帧间间隔为16.6 ms。如果没有在屏幕上进行任何有效的更新,则task scheduler 将启动更长的空闲时间,该空闲时间持续到启动下一个待处理任务为止,且上限为50毫秒,以确保Chrome保持对意外用户输入的响应。

更细节的关于空闲任务的描述可以看 queue.acm.org/detail.cfm?… 这篇文章,本文不多赘述了。

总结

本文主要了解了V8的垃圾回收机制以及采用的一些优化方法,垃圾回收机制相对比较简单,但是Orinoco优化的方法相对比较难以理解(作者还没有完全理解并发垃圾回收到底是如何做的所以没有深入的写,后面理解清楚会更新),如果有什么错误,请在评论中和作者一起讨论,如果您觉得本文对您有帮助请帮忙点个赞,感激不尽。

参考文章

queue.acm.org/detail.cfm?…
time.geekbang.org/column/arti…
v8.dev/blog/concur…
v8.js.cn/blog/orinoc…

系列文章

V8引擎详解(一)——概述
V8引擎详解(二)——AST
V8引擎详解(三)——从字节码看V8的演变
V8引擎详解(四)——字节码是如何执行的
V8引擎详解(五)——内联缓存
V8引擎详解(六)——内存结构
V8引擎详解(七)——垃圾回收机制
V8引擎详解(八)——消息队列
V8引擎详解(九)——协程&生成器函数