V8 引擎的工作原理


# V8 引擎的工作原理

V8 是用 C++ 编写的 Google 开源高性能 JavaScript 和 WebAssembly 引擎,它用于 Chrome 和 Node.js 等,既可以独立运行,也可以嵌入到任何 C++ 应用程序中。

V8 引擎本身的源码非常复杂,大概有超过 100w 行 C++ 代码,作为前端开发者我暂时没有对它进行深入研究。只是通过了解它的架构,了解它是如何对 JavaScript 执行的。

# 核心概念

要深入理解 V8 的工作原理,就需要先搞清楚一些概念和原理,比如编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)等概念。

  • 编译器:编译型语言在经过编译器的编译之后,会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。常见的编译型语言有 C/C++、GO 等。
  • 解释器:由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。常见的解释型语言有 Python、JavaScript 等。
  • 抽象语法树:抽象语法树(AST) 是一种特殊的数据结构,它是我们所编写代码的结构化表示。无论是解释型语言还是编译型语言,只有将代码转换成 AST 之后,编译器或者解释器才能理解我们写的代码。
  • 字节码:字节码(Bytecode)是介于 AST 和机器码之间的一种代码。在解释型语言的解释过程中,解释器将源代码转换成抽象语法树(AST),会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。
  • 即时编译器:直接编译成机器码的执行效率高,但机器码所占用的空间远远超过了字节码。所以就有了字节码配合解释器和编译器的技术,称为即时编译(JIT)。
    • 具体到 V8,就是指解释器在解释执行字节码的同时,收集代码信息,如果发现有热点代码(一段代码被重复执行多次),后台的编译器就会把该段热点的字节码编译为高效的机器码,并保存起来,以备下次使用。
    • 另外比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,

概括一下 V8 是如何执行一段 JavaScript 代码的:V8 依据 JavaScript 代码生成 AST 和执行上下文,再基于 AST 生成字节码,然后通过解释器执行字节码,通过编译器来优化编译字节码

# 执行流程

V8 执行一段 JavaScript 代码的流程如下图所示:

V8 引擎的原理

(V8 引擎的原理,图修改自网络)

由图可图,主要有三个模块参与工作:

  • Parse 模块会将 JavaScript 代码转换成 AST(抽象语法树),这是因为解释器并不直接认识 JavaScript 代码。
    • 如果函数没有被调用,那么是不会被转换成 AST 的。
  • Ignition 是一个解释器,会将 AST 转换成 ByteCode(字节码)
    • 同时会收集 TurboFan 优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算)。
    • 如果函数只调用一次,Ignition 会执行解释执行 ByteCode。
  • TurboFan 是一个编译器,可以将字节码编译为 CPU 可以直接执行的机器码。
    • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过 TurboFan 转换成优化的机器码,提高代码的执行性能
    • 但是,机器码实际上也会被还原为 ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如 sum 函数原来执行的是 number 类型,后来执行变成了 string 类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换(Deoptimization)成字节码。

总结整个过程:在形成抽象语法树之后,解释器会翻译成字节码。等到真正运行的时候,再将字节码转换成汇编代码,并执行 CPU 可以理解的机器码。

转成字节码的优点:字节码可以跨平台,在需要的时候转换成对应平台的机器指令就能运行。

转成字节码的缺点:每次「字节码 --> 汇编指令 --> 执行机器指令」这样的过程比较耗费性能。

鉴于这个缺点,V8 就考虑把热点函数的字节码直接转换成对应平台的机器码存储下来,等真正运行的时候直接执行机器码就可以了。对于只执行一次的函数,就保持原来的逻辑以节省空间。

这么做性能问题解决了,但又引入了新的麻烦:因为 JavaScript 是动态语言,不会对类型做检测。像 sum 函数这种,传入的参数类型不一样,其内部执行的逻辑是不一样的(数字是相加,字符串是拼接)。此时 V8 就引入了 Deoptimization 操作,一旦发现执行操作不一样,就把机器码反向转成字节码。

根据这个底层原理可知,出于优化考虑,我们在调用某个函数的时候应该尽量传相同类型的参数。所以从某种程度上来说,TypeScript 编译出来的最终 JS 代码,会比我们平时直接写的 JS 代码运行效率高一些。

# Parse 过程

下面这张图来自于 V8 官方文档 (opens new window),它描述了 V8 执行的细节 —— JavaScript 源码是如何被解析(Parse 过程)的:

V8 引擎的解析图

(V8 引擎的解析图,图片来源于官方文档)

  • Blink 将源码交给 V8 引擎,Stream 获取到源码并且进行编码转换。
  • Scanner 会进行词法分析(lexical analysis),词法分析会将代码转换成 tokens。
  • 接下来 tokens 会被转换成 AST 树,经过 Parser 和 PreParser:
    • Parser 就是直接将 tokens 转成 AST 树架构。
    • PreParser 称之为预解析,为什么需要预解析呢?
      • 这是因为并不是所有的 JavaScript 代码,在一开始时就会被执行。那么对所有的 JavaScript 代码进行解析,必然会影响网页的运行效率;
      • 所以 V8 引擎就实现了 Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容(比如知道内部函数的函数名叫 inner),而对函数的全量解析是在函数被调用时才会进行;
      • 比如我们在一个函数 outer 内部定义了另外一个函数 inner,那么 inner 函数就会进行预解析。
  • 生成 AST 树后,会被 Ignition 转成字节码(ByteCode),之后的过程就是代码的执行过程了。

小工具

通过 AST Explorer (opens new window) 在线小工具,可以观察 JS 语法经过转换后的 AST 是长什么样的。

(完)