本文基于 V8 v9.5.172 版本进行测试。
距离我的上一篇 V8 文章已经过去了近两年的时间。在这段时间里,我很高兴看到中文社区中有越来越多讨论 V8 的内容。美中不足的是,一些已经存在很久的、新的内容仍然没有出现在中文社区。本文基于 2019 年的文章进行内容大规模的重构和更新,旨在为各位读者带来尽可能新的、全面的内容。祝阅读愉快~
1. 阅读前准备
阅读之前,推荐大家准备好 d8
,它是 V8 的开发者命令行工具,文中有基于 d8
的一些实验。
推荐阅读《Building V8 with GN》,下载源码并编译构建 V8(注意构建时需要指定为 debug 模式),可完成本文所有实验。
gm x64.debug # 指定构建为 debug 模式
你也可以通过直接下载的方式,使用开箱即用的 d8
,也可完成本文绝大部分实验。
2. 关于 V8
V8 是使用 C++ 编写的高性能 JavaScript
和 WebAssembly
引擎,支持包括我们熟悉的 ia32、x64、arm 在内的十种处理器架构。在浏览器端,它支撑着 Chrome 以及众多 Chromium 内核的浏览器运行。在服务端,它是 Node.js 及 Deno 的执行环境。
发布周期
- 大约每隔四周,就会有一个新的 V8 版本推出(V8 v9.5 及之后为四周,在这之前为六周)
- V8 版本与 Chrome 版本对应,如 V8 v9.5 对应 Chrome 95
独立的核心模块
- Ignition(基线编译器)
- SparkPlug(比 Ignition 更快的非优化编译器)
- TurboFan(优化编译器)
- Liftoff(WebAssembly 基线编译器)
- Orinoco(垃圾回收器)
其它 JavaScript 引擎
- Chakra(前 Edge JavaScript 引擎)
- JavaScript Core(Safari)
- SpiderMonkey(Firefox)
下图为 V8 和其它 JavaScript 引擎在编译器管道(Compiler pipeline)上的对比。 图源 - Javascript Engines: The Good Parts
JavaScript 在不同引擎中的编译器管道大同小异。在 V8 中,解析器会将 JavaScript 源码转换成 AST,基线编译器将 AST 编译为字节码。之后,在满足一定条件时,字节码将被优化编译器编译生成优化的机器码。
3. 执行管道全貌
JavaScript 的执行过程可以简化理解为 “编译-执行-垃圾回收” 。在本文中,我们将聚焦于编译与垃圾回收,其中较多的篇幅将讨论编译器(管道)的内容。
下图为 V8 编译器管道的架构演进图。 图源 - TurboFan: A new code generation architecture for V8
早期 V8 仅有一个 Codegen 编译器,负责将解析器生成的 AST 直接编译成机器码,虽然执行速度快,但是优化有限。
两年之后,由基线编译器 Full-Codegen 和优化编译器 Crankshaft 构成的新编译器管道出现。基线编译器更注重编译速度,而优化编译器更注重编译后代码的执行速度。综合使用基线编译器和优化编译器,使 JavaScript 代码拥有更快的冷启动速度,在优化后拥有更快的执行速度。
尽管此时 V8 已有基线和优化编译器之分,但这个架构仍存在诸多问题。例如,Crankshaft 只能优化 JavaScript 的一个子集;编译管道中层与层之间缺乏隔离,在某些情况下甚至需要同时为多个处理器架构编写汇编代码等等。
于是,V8 在 Crankshaft 的基础上,引入了另一个优化编译器 TurboFan。TurboFan 通过引入了分层的设计,降低了处理器架构的适配成本,也能优化 ES6 并向前兼容更多的未来特性。
但此时,基线编译器 Full-Codegen 生成的是未优化的机器码,占用了 V8 中大约 30% 的堆空间,即使这些代码只执行一次也不会释放,对于内存的消耗较大。
因此,V8 引入了字节码,并开发了相应的基线编译器 Ignition。相比机器码,字节码由于更加简洁、紧凑,内存的消耗更小,约为等效基线机器代码的 50% 到 25%。同时,Igintion 的字节码可以直接被 TurboFan 用于生成优化的机器码,反优化的机制也得到简化,整体的架构更加清晰可维护。由于字节码生成速度比优化的机器码更快,因而 Ignition 还可以缩短脚本的冷启动时间,进而提高网页加载速度。
2017 年 5 月,V8 v5.9 正式默认开启了基线编译器 Ignition 和优化编译器 TurboFan 构成的 JavaScript 编译器管道,并移除了之前 Crankshaft 和 Full-Codegen 编译器。
2018 年 8 月,V8 v6.9 版本推出了 Liftoff,标志着 V8 开始同时支持 JavaScript 和 WebAssembly。
2021 年 5 月,V8 v9.1 推出了非优化编译器 Sparkplug,用于在优化编译器生成优化代码前,获得更快的执行速度。
下面我们将围绕一段代码,分析 JavaScript 在 V8 中是如何进行处理的。
function addTwo(a, b) {
return a + b
}
4. 解析器与 AST
当 V8 拿到 JavaScript 代码后,首先需要进行代码解析,流程如下图所示。 图源 - Blazingly fast parsing, part 1: optimizing the scanner
Token 由 Scanner(扫描器)进行分词并生成,被 Parser(解析器)进行消费,处理成为 AST(Abstract Syntax tree,抽象语法树)。AST 用于描述程序的结构,被基线编译器 Ignition 消费并生成字节码。
由于解析代码需要时间,所以 JavaScript 引擎都会尽可能避免完全解析源代码。另一方面,在一次用户访问中,页面中会有很多代码不会被执行到,比如,通过用户交互行为触发的动作。
为了节省不必要的 CPU 和内存开销,所有主流浏览器都实现了惰性解析(Lazy Parsing)。解析器不必为每个函数生成 AST,而是可以决定 “预解析”(Pre Parsing)或“完全解析”它所遇到的函数。
预解析会检查源代码的语法并抛出语法错误,但不会解析函数中变量的作用域或生成 AST。完全解析则将分析函数体并生成源代码对应的 AST 数据结构。
对于惰性解析感兴趣的同学,可以自行阅读 V8 相关文章。
我们可以通过 d8 查看代码的 AST 信息。(注:此处只能使用 debug 模式的 d8,release 模式不支持 --print-ast 参数)
// example.js
function addTwo(a, b) {
return a + b
}
d8 --print-ast example.js
输出的信息如下图所示。可以看到,生成的信息中没有包含函数 addTwo
的 AST 结构,这是由于代码命中了惰性解析。
我们将代码修改为如下形式,增加了一行函数调用,再次执行 d8 命令。
// example.js
function addTwo(a, b) {
return a + b
}
addTwo(1,2)
输出的信息如下图所示。
根据 addTwo
的 AST 信息,我们可以绘制如下的树状图。其中一个子树用于参数声明,另一个子树用于实际的函数体。
由于变量提升、eval
等原因,解析过程中无法知道哪些名称对应于程序中的哪些变量,解析器最初会创建 VAR PROXY
节点,后续作用域解析步骤会将这些 VAR PROXY
节点连接到声明的 VAR
节点,或者将它们标记为全局查找或动态查找,这取决于解析器是否在周围的某个作用域中看到了 eval
表达式。
值得注意的是,V8 生成的 AST 与 AST Explorer 生成的 eslint/babel/ts 的 AST 有些差异,我在上一版的文章中也有相关的说明。
5. 基线编译器 Ignition
V8 引入 JIT(Just In Time,即时编译)技术,通过 Ignition 基线编译器快速生成字节码进行执行。
字节码是机器码的抽象,与系统架构无关。V8 的字节码可以看做是小的构建块(building blocks),这些块通过组合实现任意的 JavaScript 功能。V8 通过引入字节码,减少了内存的使用和解析的开销,同时降低编译的复杂度。
在 V8 中,由 AST 生成字节码的过程通过 Ignition 进行实现。Igntion 是具有累加器的、基于寄存器的解释器。这里的寄存器不同于物理寄存器,是一种虚拟的实现。
图源 - Ignition: Jump-starting an Interpreter for V8
Ignition 字节码流水线如上图所示。JavaScript 代码会通过 BytecodeGenerator 转换成为字节码,BytecodeGenerator 是一个 AST 遍历器,针对不同的 AST 节点类型,实现了不同的字节码转换规则,如下图所示。
在这之后,Ignition 会对字节码进行一系列的优化。其中,Register Optimizer(寄存器优化器)用于优化不必要的寄存器加载和存储操作;Peephole Optimizer(窥孔优化器)用于将一组指令优化为性能更好的等效指令;Dead-code Elimination(死代码消除)用于移除无法执行到的代码。
通过 d8 命令,我们可以获得函数的字节码,如下图所示。
d8 --print-bytecode example.js
当我们调用 addTwo(1,2)
时,a0、a1 寄存器中已经通过 LdaSmi
分别加载了小整数 1 和 2。通过调用 Ldar a1
,将 2 加载到累加器中;再调用 Add a0, [0]
,把累加器中的值和 a0 中的值相加,累加器中得到新的值 —— 3;最后执行 Return
将累加器中的值返回。
值得注意的是 Add a0, [0]
,这里的 [0]
是反馈向量的索引。字节码解释执行过程中的分析信息都保存在反馈向量中,反馈向量将为后续优化编译器 TurboFan 提供优化信息。
Ignition 常用的字节码如下图所示,所有的字节码可以在 V8 源码 中找到,感兴趣的同学可以自行查看。
图源 - Ignition: Jump-starting an Interpreter for V8
在实际使用的过程中,V8 团队发现很多函数只在应用初始化时运行,但函数的字节码会一直存在于 V8 的堆空间中。为了减少 V8 内存开销,V8 v7.4 版本引入了 Bytecode flushing 技术。V8 会对函数的使用情况进行追踪,每次垃圾回收时都会增加函数的计数,并在函数执行时将计数值置零,当计数值超过阈值时会对内存进行回收处理。
6. 优化编译器 TurboFan
泛化性越强的代码性能越差,反之,编译器需要考虑的函数类型变化越少,生成的代码就越小、越快。
众所周知,JavaScript 是弱类型语言。ECMAScript 标准中有大量的多义性和类型判断,通过基线编译器 Ignition 生成的代码执行效率不够高。
举个例子,+
运算符的操作数就可能是整数、浮点数、字符串、布尔值以及其它的引用类型,它们之间更是可以形成不同的排列组合。
function addTwo(a, b) {
return a + b;
}
addTwo(2, 3); // 3
addTwo(8.6, 2.2); // 10.8
addTwo("hello ", "world"); // "hello world"
addTwo("true or ", false); // "true or false"
// 还有很多组合...
但这并不意味着 JavaScript 代码没有办法被优化。对于特定的程序逻辑,其接收的参数类型往往是固定的。因此,V8 的优化编译器 TurboFan 会通过内联缓存(Inline Cache)在运行时收集类型反馈(Type Feedback),将热点代码优化编译成执行效率更高的机器码。
由于篇幅问题,内联缓存在此不做展开。友情推荐知乎 @hijiangtao 的译作 JavaScript 引擎基础:Shapes 和 Inline Caches,感兴趣的话也可以阅读我的另一篇文章 V8 是怎么跑起来的 —— V8 中的对象表示。
为了验证代码的优化过程,我们将测试代码修改为:
// example.js
function addTwo (a, b) {
return a + b;
}
for (let j = 0; j < 100000; j++) {
if (j < 80000) {
addTwo(10, 10);
} else {
addTwo('hello', 'world');
}
}
TurboFan 的执行流程如上图所示,生成代码的过程依赖于一种基于图的 IR(Intermediate representation,中间表示),叫做 “Sea of nodes”,它是一种结合控制流和数据流的图。
d8 中也提供了相应的工具查看 Sea of nodes
的图。我们首先需要使用 --trace-turbo
参数运行我们的脚本文件。
d8 --trace-turbo example.js
运行过后,当前目录下会生成 turbo.cfg
和 turbo-xxx-xx.json
文件。此时,我们就可以通过 V8 的线上工具服务,可视化地查看 Sea of nodes
的图。
打开上述链接,并找到 V8 v9.5 版本的 Turbolizer 工具,之后将上一步生成的 json
文件导入,就可以在浏览器中看到 Sea of nodes
图。
字节码通过 TurboFan 的编译器前端转换为中间代码,经过优化、指令选择、指令调度、寄存器分配、汇编和反汇编等步骤,在编译器后端生成最终的机器码。在寄存器分配上,TurboFan 选择的是比图着色法性能更好的线性扫描算法。
下面,我们针对 TurboFan 的 IR 和编译器的代码优化措施、热点代码的优化与反优化进一步展开进行讨论。
TurboFan IR
TurboFan 中引入了分层编译器的设计,通过 JavaScript 层、Intermediate 层(V8 部分文档中也叫 Simple 层)和 Machine 层的分层 IR 设计,在高级和低级编译器优化之间实现了清晰地分离。
在 TurboFan 中,与体系结构相关的是 IR 中的 Machine 层,对应于 TurboFan 的后端。体系结构相关的代码只需编写一次,有效地提升了系统可扩展性,降低了关联模块的耦合度及系统的复杂度。
分层的示意图如下:
举个例子,有 A、B、C 三个特性需要迁移到两个处理器平台。在引入 IR 之前,需要有 3 * 2 = 6 种代码实现,在引入 IR 之后,需要 3 + 2 = 5 种代码实现。可以看出,一个是乘法的关系,一个是加法的关系。当需要实现很多特性并适配多种处理器架构时,引入 IR 的优势便大大增加了。
编译器的代码优化措施
内联(Inlining)
内联就是将小规模的函数在调用的位置展开,节省函数调用的开销,尤其是针对频繁调用的函数。
// https://docs.google.com/presentation/d/1UXR1H2elTdAYJJ0Eed7lUctCVUserav9sAYSidxp8YE/edit#slide=id.g284582328f_0_43
function add(x, y) {
return x + y;
}
function three() {
return add(1, 2);
}
例如,上面的函数经过内联后,可以得到以下的函数:
function three_add_inlined() {
var x = 1;
var y = 2;
var add_return_value = x + y;
return add_return_value;
}
内联不仅可以减少函数的开销,也让更多优化过程变得高效,如常数折叠(Constant folding)、强度消减(Strength reduction)、冗余消除(Redundancy elimination)、逃逸分析(Escape analysis)与标量替换(Scalar Replacement)。
比如,上述的函数通过常数折叠,可以进一步优化为:
function three_add_const_folder() {
return 3;
}
逃逸分析与标量替换
逃逸分析用于确定一个对象的生命周期是否仅限于当前函数,可以用于判断能否进行标量替换。
// https://docs.google.com/presentation/d/1UXR1H2elTdAYJJ0Eed7lUctCVUserav9sAYSidxp8YE/edit#slide=id.g2957a3ab8f_0_292
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(that) {
return Math.abs(this.x - that.x) + Math.abs(this.y - that.y);
}
}
function manhattan(x1, y1, x2, y2) {
const a = new Point(x1, y1);
const b = new Point(x2, y2);
return a.distance(b);
}
上述的函数通过内联,可以转换为:
function manhattan_inl(x1, y1, x2, y2) {
const a = {x: x1, y: y1};
const b = {x: x2, y: y2};
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}
通过逃逸分析,可以发现 a、b 的生命周期仅存在于 manhattan_inl
,因此函数可以通过标量替换优化为:
function manhattan_ea(x1, y1, x2, y2) {
var a_x = x1;
var a_y = y1;
var b_x = x2;
var b_y = y2;
return Math.abs(a_x - b_x) + Math.abs(a_y - b_y)
}
通过标量替换,可以减少不必要的对象属性访问开销,将属性访问转换为开销更小的普通变量访问。在提高执行速度的同时,也可以减少垃圾回收的压力。
热点代码的优化与反优化
注:该小节涉及的优化与上一小节的优化没有直接关系,这里指的是热点代码是否会被整体优化,而不是某个具体的实现如何被优化。
对于重复执行的代码,如果多次执行都传入类型相同的参数,那么 V8 会假设之后每一次执行的参数类型也是相同的,并对代码进行优化。优化后的代码会保留基本的类型检查,如果之后的每次执行参数类型未改变,V8 将一直执行优化过的代码。
如果优化后的代码不满足假设性条件,则优化的代码无法运行,V8 将会“撤销”之前的优化操作,这一步称为“反优化”(Deoptimization),下次类型反馈时会参考这个意料之外的结果,使用更通用的数据类型来描述。
在 d8 中,优化和反优化过程的输出信息可以分别通过 --trace-opt
和 --trace-deopt
参数控制。
// example.js
function addTwo (a, b) {
return a + b;
}
for (let j = 0; j < 100000; j++) {
if (j < 80000) {
addTwo(10, 10);
} else {
addTwo('hello', 'world');
}
}
d8 --trace-opt --trace-deopt example.js
[marking 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> for optimized recompilation, reason: hot and stable]
[compiling method 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) using TurboFan OSR]
[optimizing 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) - took 5.114, 11.420, 0.371 ms]
[bailout (kind: deopt-soft, reason: Insufficient type feedback for call): begin. deoptimizing 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)>, opt id 0, node id 66, bytecode offset 65, deopt exit 2, FP to SP delta 96, caller SP 0x7ffeec6d2528, pc 0x0d4e0090536e]
[marking 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> for optimized recompilation, reason: small function]
[compiling method 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> (target TURBOFAN) using TurboFan]
[optimizing 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> (target TURBOFAN) - took 1.320, 3.947, 0.207 ms]
[completed optimizing 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> (target TURBOFAN)]
[marking 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> for optimized recompilation, reason: hot and stable]
[compiling method 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) using TurboFan OSR]
[optimizing 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) - took 4.136, 12.252, 0.400 ms]
在这段代码中,我们执行了 100,000 次 +
操作,其中前 80,000 次是两个整数相加,后 20,000 次是两个字符串相加。
通过跟踪 V8 的优化记录,我们可以可以看到输出的第 4 行,代码第 10 行(第 80,001 次执行时)由于参数类型由整数变为字符串,触发了反优化操作。而当新的代码再次变为热点代码后,代码又将重新被优化。
反优化时,V8 会迭代所有优化过的 JavaScript 函数,解除函数指向优化和反优化代码对象的链接。规模较大、优化较多的 JavaScript 函数将成为性能瓶颈。尽管 V8 对部分反优化步骤进行惰性处理(Lazy deoptimization),但反优化的开销还是比较昂贵的,在实际编写函数时要尽量避免触发反优化。
优化之痛
TurboFan 的优化对性能带来了很大的收益,但实际优化过程依赖类型反馈,逻辑十分复杂也不受控,因此非常容易产生各种漏洞和 Bug。
在实际生产环境中,我们团队遇到过一个问题是 —— 用户在打开网页一段时间后,服务的轮询地址变成了另外的值。团队内 LeuisKen 同学排查发现,在 v8 v6.7 版本可以稳定复现这个 Bug。
一个可复现的代码如下(已进行脱敏处理):
var i = "abcdefg"
var n = "comments"
var t = "article"
for (let j = 0; j < 10000; j++) {
var o = "/api/v1/".concat(t, "/").concat(n, "?commentId=").concat(i);
if (j % 1000 === 0) {
console.log(o);
}
}
从执行结果可以看到,对于同样的逻辑,在程序执行重复执行的第 5001 和第 6001 次,分别输出了截然不同的内容。
除此之外,文章末尾的扩展阅读也收录了两篇深信服团队关于 TurboFan 漏洞的分析,感兴趣的同学可以看看。
7. Orinoco 与垃圾回收
当内存不再需要的时候,会被周期性运行的垃圾回收器回收。
V8 的垃圾回收主要有三个阶段
- 标记:确定存活/死亡对象
- 清除:回收死亡对象所占用的内存
- 整理:压缩、整理碎片内存
世代假说
世代假说(generational hypothesis),也称为弱分代假说(weak generational hypothesis)。这个假说表明,大多数新生的对象在分配之后就会死亡,而老的对象通常倾向于在程序运行周期中永存。
V8 的垃圾回收基于世代假说,将内存分为新生代和老生代。
图源 - Trash talk: the Orinoco garbage collector
如图所示,新生代内部进一步细分为 Nursery 和 Intermediate 子世代(划分只是逻辑上的)。新生对象会被分配到新生代的 Nursery 子世代。若对象在第一次垃圾回收中存活,它的标志位将发生改变,进入逻辑上的 Intermediate 子世代,在物理存储上仍存在于新生代中。如果该对象在下一次垃圾回收中再次存活,就会进入老生代。对象从新生代进入到老生代的过程叫做晋升(promotion)。
V8 在新生代和老生代中采用了不同的垃圾回收策略,使垃圾回收更有针对性、更加高效。同时,V8 对新生代和老生代的内存大小也进行了限制。
名称 | 主要算法 | 最大容量 |
---|---|---|
新生代 | Scavenge | 2 * 16MB(64位)/ 2 * 8MB(32位) |
老生代 | 标记清除、标记整理 | 4096MB(64位)/ 2048MB(32 位) |
需要注意的是,随着内存增大,垃圾回收的次数会减少,但每次所需的时间也会增加,将会对应用的性能和响应能力产生负面影响,因此内存并不是越大越好。
新生代
新生代使用 Scavenge 算法(一种复制算法),其核心思想是以空间换时间。
V8 将新生代拆分为大小相同的两个半空间,分别称为 Form 空间 和 To 空间。垃圾回收时,V8 会检查 From 空间中的存活对象,将这些对象复制到 To 空间。当所有存活对象都移动到 To 空间后,V8 将直接释放 From 空间。每次完成复制后,From 和 To 空间的位置将发生互换。
当一个对象经过一次复制依然存活,该对象将被移动到老生代,这个过程称为晋升。
老生代
根据世代假说,老生代的对象倾向于在程序运行的生命周期中永存,即它们很少需要被回收。这意味着,在老生代使用复制算法是低效的。V8 在老生代中使用了标记清除和标记整理算法进行垃圾回收。
标记清除(Mark-Sweep)
标记清除的原理十分简单。垃圾回收器从根节点开始,标记根直接引用的对象,然后递归标记这些对象的直接引用对象。对象的可达性将作为是否“存活”的依据。
标记清除算法所花费的时间与存活对象的数量成正比。
标记整理(Mark-Compact)
标记整理算法是复制算法和标记清除算法的结合。
当我们进行标记清除后,就可能产生内存碎片,这些碎片对我们程序进行内存分配是不利的。
举个极端的例子,在下图中,蓝色的对象是需要我们分配内存的新对象,在内存整理之前,所有的碎片空间(浅色部分)都无法容纳完整的对象。而在内存整理之后,碎片空间被合并成一个大的空间,也能容纳下这个新对象。
标记整理算法的优缺点都十分明显。它的优点是,能够让堆利用更加充分有效。它的缺点是,需要额外的扫描时间和对象移动时间,并且花费的时间与堆的大小成正比。
关于标记
本小节图源 Concurrent marking in V8
V8 采用三色标记(Tri-color marking)法来识别内存垃圾,三种颜色通过两个标志位来区分,即白色(00)、灰色(10)、黑色(11)。
最初,所有对象都是白色的。标记将从根节点出发,每遍历到一个节点,便将该节点变为灰色。
如果某个灰色节点的所有直接子节点都遍历完成,该灰色节点将变为黑色。
如果不再有新的灰色节点,则标记结束,剩余的白色节点不可访问,可以被安全回收。
出于性能优化的考虑,V8 针对垃圾回收也做了很多优化(下一小节将展开介绍),可能导致垃圾回收的同时又分配新内存的情况。为了避免内存访问冲突,V8 中实现了写屏障(Write Barrier)机制。写屏障的主要工作原理是确保黑色节点不能指向白色节点,如果在黑色节点下分配子节点,该子节点将强制从白色变为灰色。
垃圾回收过程的优化策略
执行垃圾回收时,不可避免会暂停 JavaScript 的执行。另一方面,为了页面流畅运行,我们通常希望页面能以每秒 60 帧的帧率运行,即每帧约 16ms 渲染间隔。这意味着如果在垃圾回收加上代码执行时间超过 16ms,用户将感受到卡顿的情况。
Orinoco 利用了并行、增量和并发的技术进行垃圾回收,以释放主线程的压力,使其有更多的时间用于正常的 JavaScript 代码执行。
并行是指将垃圾回收任务分配成工作量大致相等的若干任务,交给主线程和辅助线程同时执行。由于执行过程没有 JavaScript 运行,所以实现较为简单,只需确保线程之间进行同步即可。
增量是指主线程将原本大量、集中的垃圾回收任务进行拆分,少量、多次间歇性地运行。
并发是指主线程保持 JavaScript 执行不中断,辅助线程完全在后台执行垃圾回收。由于涉及主线程和辅助线程的读写竞争,是三种策略中最复杂的一种。
在新生代中,V8 采用的是并行的 Scavenge 算法。
在老生代中,V8 采用的是并发标记,并行整理,并发回收的策略。
两年前的文章里还提到了一个社区中关于最大保留空间的计算“错误”,由于目前鲜少见到相关讨论及篇幅限制,不再赘述,感兴趣的同学可以自行查阅。
8. 更快的非优化编译器 Sparkplug
在没有足够的类型反馈之前,TurboFan 无法提前进入优化;同时过早优化也可能导致优化了非热点代码,造成资源浪费。另一方面,如果一直使用 Ignition 的字节码,又意味着代码执行效率不高。为了解决这个问题。V8 在 v9.1 引入了非优化编译器 Sparkplug,它可以直接将字节码不经优化生成汇编代码。
注意这里的“优化”,围绕的点是 —— “是否通过类型反馈进行推测”。实际上,相比与 Ignition 字节码,Sparkplug 生成的汇编代码,在性能上是优化了的。
图源 - Sparkplug, the new lightning-fast V8 baseline JavaScript compiler
Sparkplug 不会像大多数编译器那样生成任何中间表示,它可以看做是一个从 Ignition 字节码到 CPU 字节码的 “转译器”。它的编译器是一个 for
循环嵌套 switch
语句,负责把每个字节码分配到与之对应的、固定的代码生成函数。
Sparkplug 维护了一个与 Ignition 解释器兼容的栈帧,每当解释器存储一个寄存器值时,Sparkplug 也会同步存储,直接反映解释器的行为。这样不仅可以简化 Sparkplug 编译,加快编译速度,同时它与系统其余部分的集成也几乎没有成本。另外,这也使得 OSR(On-Stack Replacement,栈替换,一种替换正在运行的函数栈帧的技术)的实现变得简单。
Sparkplug 在设计上尽可能多地复用现有的机制(如内置函数、宏汇编、堆栈帧),也尽可能减少体系结构相关的代码。同时,由于 Sparkplug 与上下文无关,因此代码可以被缓存,也能跨页面共享。
除此之外,由于 Sparkplug 应用了与 Ignition 相同的类型反馈策略,因此生成的 TurboFan 优化代码也是等效的。
9. WebAssembly 基线编译器 Liftoff
Liftoff 是在 V8 v6.9 中启用,在 V8 v8.5 中全平台支持的 WebAssembly(以下简称 WASM)基线编译器。
Liftoff 的目标是通过尽可能快地生成代码来减少 WASM 应用的启动时间。
对于一段 WASM 代码,Liftoff 只会迭代遍历一次代码,在解码和验证的同时立即为每个 WASM 指令生成机器代码(配合 Streaming API,WASM 代码也可以与 JavaScript 代码一样实现边下载边编译)。虽然 Lifoff 的执行速度非常快(大约每秒处理 10M),但几乎没有优化空间。
图源 - Liftoff: a new baseline compiler for WebAssembly in V8
从上图可以看到,Liftoff 代码生成无需通过生成 IR,但同时也少了优化的可能。
由于 WASM 是静态类型的,不需要通过类型反馈生成优化代码。因此,在 Liftoff 编译完成后,V8 会使用 TurboFan 重新编译所有函数,由于 TurboFan 编译时会对代码进行编译优化,应用更好的寄存器分配策略,从而可以显著加快代码执行速度。
每当一个函数在 TurboFan 中完成编译,将立即替换掉 Liftoff 编译的相同函数。之后该函数的所有调用将使用 TurboFan 编译的代码(与 Sparkplug 替换 Ignition 不同,这个过程不是 OSR)。对于大型模块来说,V8 可能需要 30 秒到 1 分钟才能将该模块完全编译。
图源 - CovalenceConf 2019: Bytecode Adventures with WebAssembly and V8
如果 WASM 模块使用 WebAssembly.compileStreaming
加载,那么 TurboFan 生成的机器码将被缓存。当使用同一个 URL 再次获取同一个 WASM 模块时(服务端需返回 304 Not Modified),该模块不会被编译,而是从缓存中加载。
顺便说一下,目前,在 V8 中,WASM 模块最多可使用 4GB 的内存。
10. 代码缓存
在 Chrome 浏览器中有很多功能都或多或少影响了 JavaScript 的执行过程,其中一个便是代码缓存(Code Caching),该功能在 V8 v6.6 版本中开启。
在用户访问相同页面,且该页面关联的脚本文件没有任何改动的情况下,代码缓存会让 JavaScript 的加载和执行变得更快。
图源:Code caching for JavaScript developers
代码缓存被分为 cold、warm、hot 三个等级,存放在内存和磁盘中。磁盘上的代码缓存由 Chrome 管理,以实现多个 V8 实例之前的缓存共享。
-
用户首次请求 JS 文件时(即 cold run),Chrome 将下载该文件并将其提供给 V8 进行编译,并将文件本身缓存到磁盘中。
-
当用户第二次请求这个文件时(即 warm run),Chrome 将从浏览器缓存中获取该文件,并将其再次交给 V8 进行编译。在 warm run 阶段编译完成后,编译的代码会被序列化,作为元数据附加到缓存的脚本文件中。
-
当用户第三次请求这个 JS 文件时(即 hot run),Chrome 从缓存中获取文件和元数据,并将两者交给 V8。V8 将跳过编译阶段,直接反序列化元数据。
相关链接
参考资料
- Blazingly fast parsing, part 1: optimizing the scanner
- An Introduction to Speculative Optimization in V8
- Understanding V8’s Bytecode
- Ignition: Jump-starting an Interpreter for V8
- Sneak peek into Javascript V8 Engine
- Launching Ignition and TurboFan
- Celebrating 10 years of V8
- TurboFan: A new code generation architecture for V8
- TurboFan JIT Design
- Introduction to TurboFan
- A Tale Of TurboFan
- Sea of Nodes
- Deoptimization in V8
- V8 引擎 TurboFan 后端代码浅析
- Trash talk: the Orinoco garbage collector
- Sparkplug — a non-optimizing JavaScript compiler
- Sparkplug
- Mid-Tier Compiler Investigation
- Sparkplug, the new lightning-fast V8 baseline JavaScript compiler
- V8 release v7.4
- Liftoff: a new baseline compiler for WebAssembly in V8
- WebAssembly compilation pipeline
- Code caching for WebAssembly developers
- V8 release v6.6
- Code caching for JavaScript developers
- Concurrent marking in V8