你绕不过的知识点:V8引擎原理

2,054 阅读10分钟

JavaScript:解释型语言

这可能是大家在初学 JavaScript 时就了解的一个概念,MDN 对它的定义是这样的:

JavaScript® (通常简写为JS)是一种轻量的、解释性的、面向对象的头等函数语言,其最广为人知的应用是作为网页的脚本语言,但同时它也在很多非浏览器环境下使用。JS是一种动态的基于原型和多范式的脚本语言,支持面向对象、命令式和函数式的编程风格。

但这么多年过去了,JavaScript 早已不是原来青涩的样子,它还是一门解释型语言吗?V8 作为众多 JavaScript 引擎的代表,它的工作机制是怎样?

《编译原理》对于编译器和解释器的定义:

编译器:它可以阅读以某一种语言(源语言)编写的程序,并把该程序翻译成为一个等价的、用另一种语言(目标语言)编写的程序。
解释器:它并不通过翻译的方式生成目标程序,从用户的角度看,解释器直接利用用户提供的输入执行源程序中指定的操作。

如果按照上面的定义往 JavaScript 上套,我们会发现它结合了编译和解释两种方式,有点类似于 Java 这样的混合型语言。对外界而言,JavaScript 引擎是一个解释器,而它内部又要经历整个编译过程,把源代码编译为机器语言再执行。

如今各种编程语言层出不穷,按照早期的定义已经很难区分编译型和解释型了。目前互联网上普遍认为,编译型需要提前把源代码编译成计算机可执行的文件(机器语言),而解释型则是在源代码被执行时即时编译(JIT)为机器语言。从这个角度来说,Java 和 JavaScript 都属于解释型语言。一般情况下,相对来说编译型语言执行起来速度更快些。

通常情况下高级语言的编译流程

根据《编译原理》的解释,一般的编译过程都要经历以下几个步骤:

通常编译流程.png

其中符号表管理和错误处理是所有阶段都要涉及的两项活动。

V8 引擎的工作流程

V8 的工作流程整体上与上面并无二致。这里先放张图,直观地感受下。图中红、绿线的部分后面会再做解释。

1_ZIH_wjqDfZn6NRKsDi9mvA (1).png

解析

解析过程包括了词法分析语法分析两个阶段,将源代码加以解析,最终产出 AST 抽象语法树。AST 是一种树形数据结构,以结构化的方式呈现源代码,而且在语义分析中也起着至关重要的作用,在语义分析中,编译器验证程序和语言元素的正确性。在后面,AST 将用于生成实际的字节码或机器码。

利用 AST 能做什么:

  • 语法检查、代码风格检查、格式化代码、语法高亮、错误提示、自动补全等
  • 代码混淆压缩
  • 优化变更代码,改变代码结构等

因为即时编译的特点,解析过程将用到两种解析器:pre-parser 和 parser,为了加快初始加载速度,引擎使用启发式方法(判断语法)来确定某段代码是立即执行还是在将来推迟一段时间执行(惰性)。对 V8 来说,实际上网站提供了很多不被调用的函数(至少是启动时不调用),因此有必要把这些函数延迟解析。

Parser 负责解析当前立即需要的代码,它主要做三件事:构建 AST、构建作用域层次结构和查找所有语法错误。

PreParser 负责解析稍后可能需要的代码,它不会构建 AST,也不会发现所有语法错误,它只构建作用域层次结构但不会在作用域内放置变量引用或变量声明。与 Parser 相比,节省了大约一半的时间。

以下面两段代码为例,看看它们的区别在哪:

var foo = function foo(x) {
    return x * 10;
};
var foo = (function foo(x) {
    return x * 10;
});

它们唯一的区别就在于下面这段代码的函数体包了一层括号。第一段代码,声明了一个函数,并把它赋值给 foo ,但函数体没有立即被调用,解析器将延后解析函数体代码。而通过给函数体加上括号这样一个小细节,解析器在 function 关键字之前看到左括号时,它会立即进行解析函数体。

编译流水线

Full-codegen 作为 V8 早期的基线编译器,可以快速生成未优化的机器代码,编译后的代码在运行时进行分析,并可选择使用更高级的优化编译器(Crankshaft 或 TurboFan)进行动态重新编译,以获得最佳性能。但 Full-codegen 的缺点在于生成的机器代码消耗了太多内存,尤其是在低端手机上。为了减轻这种开销,V8 团队构建了一个新的 JavaScript 解释器,称为 Ignition,它可以替代 V8 的基线编译器,以更少的内存开销执行代码。出于以上的考虑, V8 引擎把编译流水线方案由 Full-codegen + Crankshaft 逐渐切换到 Ignition + TurboFan

  • Ignition:V8 具有一个名为 Ignition 的解释器,Ignition 是一个使用 TurboFan 后端编写的基于寄存器的快速低级解释器。
  • TurboFan:TurboFan 是 V8 的优化编译器之一,它利用了一种称为“节点海”的概念。

目前这个流水线看起来是这样的:

image.png

基线编译器

Ignition 内部包括 字节码生成器(Bytecode Generater) 和 字节码解释器(Bytecode Interpreter) 。 字节码生成器先遍历 AST 并生成简洁的字节码(Bytecode),字节码解释器获取字节码,再将其发送到一组字节码处理程序来解释并执行它。

字节码是机器码的抽象表示,其大小是等效机器码的 50% 到 25%。然后这个字节码由一个高性能解释器执行,它在实际网站上的执行速度接近 V8 原有基线编译器生成的机器代码。

字节码不能太大,不然占用过多的内存空间。下面的图片直观地展现了高级代码、字节码、机器码(在 V8 中的汇编代码)的形态:

1_aal_1sevnb-4UaX8AvUQCg.png

字节码处理程序是字节码解释器的一部分,为了利用 Turbofan 的优势,V8 团队使用 TurboFan 的一部分来生成了字节码处理程序。

Ignition 解释器使用 TurboFan 的低级、独立于体系结构的宏汇编指令来为每个操作码生成字节码处理程序。TurboFan 将这些指令编译到目标架构中,在此过程中进行低级指令选择和机器寄存器分配。这导致高度优化的解释器代码可以执行字节码指令并以低开销的方式与 V8 虚拟机的其余部分交互,同时将最少数量的新机器添加到代码库中。

隐藏类和内联缓存

在执行期间,Ignition 会收集一些反馈信息,用来加速字节码的后续解释。

JavaScript 是一种动态编程语言,这意味着可以在实例化后轻松添加或删除对象的属性。大多数 JavaScript 解释器使用类似字典的结构来存储对象属性值在内存中的位置,这种使得在 JavaScript 中检索属性的值比在非动态编程语言(如 Java 或 C#)中检索的计算成本更高。Java 对象的属性值(或指向这些属性的指针)可以作为连续缓冲区存储在内存中,每个值之间具有固定的偏移量。可以根据属性类型轻松确定偏移量的长度,而这在 JavaScript 中是不可能的,因为属性类型可以在运行时更改。

V8 使用了内联缓存的方式来优化属性值的检索,在初始化对象o时创建对应的隐藏类,保存o的属性间的偏移量。每当访问o上的属性时,V8 引擎都必须查找该对象的隐藏类,以确定访问特定属性的偏移量。在对同一个隐藏类两次成功调用后,V8 省略了隐藏类查找,只是简单地将属性的偏移量添加到对象指针本身。对来对于该属性调用,V8 假定隐藏类没有改变,并使用先前查找存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。

function(x,y){ 
    this.x = x; 
    this.y = y; 
}
var p1 = new Point(1, 2); 
p1.a = 5; 
p1.b = 6;

如果你创建了两个不同类型的对象,或者两个相同类型的对象却对应不同的隐藏类,内联缓存将失效:

function(x,y){ 
    this.x = x; 
    this.y = y; 
}

// 创建两个不同类型的对象
var p1 = new Point(1, 2); 
var p2 = new Point(1, '2'); 

// 没有以相同顺序设置对象属性,导致创建两个不同的隐藏类
p1.a = 5; 
p1.b = 6;
p2.b = 7;
p2.a = 8;

优化编译器

对于某些程序片段,V8希望以更快的速度执行它们,这时候就轮到 TurboFan 上场了。TurboFan 会使用 Ignition 收集的反馈信息,把这些代码编译为高度优化的机器代码,以获得最佳的执行性能。

哪些程序片段是值得优化的?

确定变量的类型非常重要,这是 TurboFan 执行优化的必要条件。由于 JavaScript 的动态特性,我们通常直到运行时才知道值的精确类型,仅通过查看源代码通常不可能得知操作输入的可能值。因此 TurboFan 将利用反馈信息来推测值的类型,并假定将来该值都是同一类型。

假设我们有以下这段频繁执行的函数:

function add(x, y) {
  return x + y;
}

经过一段时间的信息收集,TurboFan 会检查这段函数的参数数量、类型、+ 的结果值、函数返回值类型,如果都保持不变,例如每次xy都是数字类型,计算结果自然也是数字类型,TurboFan 将把这段代码进行优化。如果某一时刻发现输入的类型变了,例如y变为字符类型,TurboFan 将执行逆向优化,即把机器代码退回为字节码。当下次函数变“热”时,TurboFan 最终会再次优化它。

image.png

因此,在日常编码中,尽量保持函数参数类型的一致(侧面印证了TS的重要性)。

参考文档

Is JavaScript really interpreted or compiled language?
How JavaScript works: Parsing, Abstract Syntax Trees (ASTs) + 5 tips on how to minimize parse time
How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code
How JavaScript works: A comparison with WebAssembly + why in certain cases it’s better to use it over JavaScript
JavaScript深入浅出第4课:V8引擎是如何工作的?
Ignition
Turbofan
What does V8's ignition really do?
Marja Hölttä: Parsing JavaScript - better lazy than eager? | JSConf EU 2017
Franziska Hinkelmann: JavaScript engines - how do they even? | JSConf EU
Benedikt Meurer: A Tale of TurboFan: Four years that changed V8 forever
An Introduction to Speculative Optimization in V8