事件循环和垃圾回收

我们在使用 JavaScript 时,经常要用到大量的回调函数,比如在浏览器中可以使用 setTimeout 来设置定时器,使用 XMLHTTPRequest 来异步下载资源文件,在 Node 中可以使用 readFile 来读取文件,这些操作都有一个共同的特点,那就是需要给调用 API 传入回调函数,然后浏览器或者 Node 会将执行处理的结果通过回调函数来触发。

从内部了解回调函数,可以帮助我们梳理清楚很多问题:

  • 有助于我们理解浏览器中的 Web API 到底是怎么工作的;
  • 有助于我们理解宏任务和微任务到底有哪些区别;
  • 理解回调函数,是理解异步编程模型 async/await 的基础。

这些内容在我们实际的项目中都会频繁使用到,所以理解 V8 是怎么实现回调函数的就显得至关重要了。

V8 是怎么实现回调函数的?

回调函数有两种类型:同步回调和异步回调,同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。

关于异步回调,这里也有两种不同的类型,其典型代表是 setTimeout 和 XMLHttpRequest。

setTimeout 的执行流程其实是比较简单的,在 setTimeout 函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程从消息队列中取出回调事件,并执行回调函数。

XMLHttpRequest 稍微复杂一点,因为下载过程需要放到单独的一个线程中去执行,所以执行 XMLHttpRequest.send 的时候,宿主会将实际请求转发给网络线程,然后 send 函数退出,主线程继续执行下面的任务。网络线程在执行下载的过程中,会将一些中间信息和回调函数封装成新的消息,并将其添加进消息队列中,然后主线程从消息队列中取出回调事件,并执行回调函数。

V8 是如何实现微任务的?

调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。主线在执行任务的过程中,如果函数的调用层次过深,可能造成栈溢出的错误,我们可以使用 setTimeout 来解决栈溢出的问题。

setTimeout 的本质是将同步函数调用改成异步函数调用,这里的异步调用是将回调函数封装成宏任务,并将其添加进消息队列中,然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。

消息队列中事件又被称为宏任务,不过,宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而微任务可以在实时性和效率之间做有效的权衡。

微任务之所以能实现这样的效果,主要取决于微任务的执行时机,微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

因为微任务依然是在当前的任务中执行的,所以如果在微任务中循环触发新的微任务,那么将导致消息队列中的其他任务没有机会被执行。

Async/Await

V8 的两个垃圾回收器是如何工作的?

什么是垃圾数据?

从 GC Roots 对象出发,遍历 GC Root 中的所有对象,如果通过 GC Roots 没有遍历到的对象,则这些对象便是垃圾数据。V8 会有专门的垃圾回收器来回收这些垃圾数据。

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

  • 全局的 window 对象(位于每个 iframe 中);
  • 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;
  • 存放栈上变量。

两个垃圾回收器

V8 依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。

新生代和老生代区域

为了提升垃圾回收的效率,V8 设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器。主垃圾回收器负责收集老生代中的垃圾数据,副垃圾回收器负责收集新生代中的垃圾数据。

副垃圾回收器采用了 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换。

新生代和老生代区域

新生代和老生代区域

主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。

标记清除过程

标记整理过程

思考题

观察下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function strToArray(str) {
let i = 0;
const len = str.length;
let arr = new Uint16Array(str.length);
for (; i < len; ++i) {
arr[i] = str.charCodeAt(i);
}
return arr;
}

function foo() {
let i = 0;
let str = "test V8 GC";
while (i++ < 1e5) {
strToArray(str);
}
}

foo();

请你想一想,V8 执行这段代码的过程中,产生了哪些垃圾数据,以及 V8 又是如何回收这些垃圾的数据的, 最后站在内存空间和主线程资源的角度来分析,如何优化这段代码。

  1. 从优化栈调用的角度,可以内联 str2Array 的逻辑。
  2. 从优化对空间的角度,可以复用 new Object()出来的对象。

V8 是如何优化垃圾回收器执行效率的?

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

一次完整的垃圾回收分为标记和清理两个阶段,垃圾数据标记之后,V8 会继续执行清理和整理操作,虽然主垃圾回收器和副垃圾回收器的处理方式稍微有些不同,但它们都是主线程上执行的,执行垃圾回收过程中,会暂停主线程上的其他任务,具体全停顿的执行效果如下图所示:

执行垃圾回收过程

可以看到,执行垃圾回收时会占用主线程的时间,如果在执行垃圾回收的过程中,垃圾回收器占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如,页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行,造成页面的卡顿 (Jank),用户体验不佳。

为了解决全停顿而造成的用户体验的问题,V8 团队经过了很多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,并且也已经取得了一些成效。这些技术主要是从两方面来解决垃圾回收效率问题的:

第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;

第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。

并行回收

既然执行一次完整的垃圾回收过程比较耗时,那么解决效率问题,第一个思路就是主线程在执行垃圾回收的任务时,引入多个辅助线程来并行处理,这样就会加速垃圾回收的执行速度

并行回收

采用并行回收时,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。

V8 的副垃圾回收器所采用的就是并行策略

增量回收

虽然并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿的垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。比如老生代存放的都是一些大的对象,如 window、DOM 这种,完整执行老生代的垃圾回收,时间依然会很久。

这些大的对象都是主垃圾回收器的,所以在 2011 年,V8 又引入了增量标记的方式,我们把这种垃圾回收的方式称之为增量式垃圾回收。

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

增量标记

增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为增量回收是并发的(concurrent),要实现增量执行,需要满足两点要求:

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

上述两个问题最终通过三色标记法解决

并发回收

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

所谓并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。并发标记的流程大致如下图所示:

并发回收

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

总结

V8 最开始的垃圾回收器有两个特点,第一个是垃圾回收在主线程上执行,第二个特点是一次执行一个完整的垃圾回收流程。

由于这两个原因,很容易造成主线程卡顿,所以 V8 采用了很多优化执行效率的方案。

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

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

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

几种常见内存问题的解决策略