【译】V8 引擎如何工作

188 阅读8分钟

从高层次来看,V8 JavaScript 引擎的执行由 5 个步骤组成。

  1. 初始化宿主环境
  2. 编译 JavaScript 代码
  3. 生成字节码
  4. 解释并执行字节码
  5. 优化一些字节码以获得更好的性能

初始化环境

从技术上讲,这不是 V8 工作的一部分。它是浏览器的渲染器进程初始化以下两项:

  • 宿主环境
  • V8 引擎

image.png 浏览器有多个渲染器进程。通常,每个浏览器选项卡都有一个渲染器进程并初始化一个 V8 实例。

宿主环境是什么?在我们的上下文中,它是浏览器。因此,我们将在本文中互换使用“浏览器”和“宿主环境”。但请记住,浏览器只是 JavaScript 的宿主环境之一,另一个著名的是 Node。

宿主环境中有什么?

image.png 宿主环境提供了 JavaScript 引擎所依赖的一切,包括:

  1. 调用栈
  2. 回调队列
  3. 事件循环
  4. Web API 和 Web DOM

网页上的用户交互会触发一系列事件。浏览器将它们与关联的回调函数一起添加到回调队列中。事件循环像无限 while 循环一样工作,不断从队列中获取回调。然后回调中的JavaScript被编译并执行。一些中间数据存储在调用栈中。有些保存在堆中,例如数组或对象。

为什么浏览器将数据存储在两个不同的地方?

  • 以空间换速度:调用栈需要内存中的连续空间,以使其处理速度更快。然而,连续的空间在内存中很少见。为了解决这个问题,浏览器设计者通过最大尺寸来限制它。这就是堆栈溢出错误的来源。通常,浏览器在调用栈中保存有限大小的数据,例如整数和其他主要数据类型。
  • 以时间换空间:堆不像对象那样需要连续的空间来保存大量数据。代价是堆处理数据的速度相对较慢。

在我看来,调用栈和事件循环是理解 JavaScript 如何工作的两个关键机制,这超出了本文的范围。

V8 引擎依赖并赋能宿主环境

V8 的宿主环境就像计算机的操作系统与软件一样。软件依赖于操作系统来运行。同时,它们使您的系统能够执行许多高级任务。

以 Photoshop 为例。它需要在 Windows 或 macOS 上运行。同时,你的操作系统无法为你制作一张漂亮的海报,但 Photoshop 可以。 image.png 与 V8 引擎相同,它在宿主环境之上提供了附加功能:

  • 基于 ECMAScript 标准的 JavaScript 核心功能,例如对象和函数的创建
  • 垃圾收集机制
  • 协程

当宿主环境和V8引擎准备就绪时,V8引擎开始下一步。

编译JavaScript代码

image.png 在这一步,V8引擎将 JavaScript 代码转换为抽象语法树 ( AST ) 并生成作用域。

V8 引擎不能直接理解 JavaScript 语言。在处理之前需要对脚本进行结构化。

AST 是一个树形结构,很容易被 V8 理解。

同时,在这一步生成了作用域,包括全局作用域和存储在宿主环境调用栈顶部的更多作用域。作用域本身值得另一篇文章来解释。您可以在这里安全地跳过它。

AST 是什么样子的?

让我们通过以 AST 格式显示以下 JavaScript 来检查一个简单的示例。

const medium = 'good ideas';

image.png 每行 JavaScript 代码都将转换为 AST,如本步骤中的示例一样。

生成字节码

image.png 在此步骤中,V8 引擎获取 AST 和作用域并输出字节码。

字节码是什么样的?

这次我们使用相同的示例,即 Chrome V8 的开发者工具 D8。要在 macOS 中安装 D8,请在终端中运行以下命令。

brew install v8

将示例代码保存在 javascript 文件 v8.js 中,然后在终端中运行以下命令。

d8 --print-bytecode v8.js

D8 打印根据上一步的 AST 和作用域生成的字节码。

[generated bytecode for function:  (0x0ee70820ffed <SharedFunctionInfo>)]
Parameter count 1
Register count 1
Frame size 8
  0xee708210076 @    0 : 12 00             LdaConstant [0]
  0xee708210078 @    2 : 1d 02             StaCurrentContextSlot [2]
  0xee70821007a @    4 : 0d                LdaUndefined
  0xee70821007b @    5 : aa                Return
Constant pool (size = 1)
Handler Table (size = 0)
Source Position Table (size = 0)

Parameter count 1 表示有一个参数,在我们的例子中是 medium。然后,有 4 行字节码供解释器执行。

解释并执行字节码

image.png 字节码是指令的集合。这一步,解释器会从上到下执行每一行字节码。

在前面的示例中,我们看到以下 4 个字节码。

LdaConstant [0]
StaCurrentContextSlot [2]
LdaUndefined
Return

字节码的每一行就像一个乐高积木。无论您的代码多么花哨,都是在幕后使用这些基本块构建的。

每个字节码的详细信息超出了本文的范围。如果您对此感兴趣,这里是 V8 字节码的完整列表

编译并执行机器码

image.png 此步骤与上一步并行。在执行字节码时,V8 不断监控代码并寻找优化它们的机会。

当检测到一些经常使用的字节码时,V8 将它们标记为 hot,然后将其转换为高效的机器码并由 CPU 执行。

如果优化失败怎么办?编译器对代码进行去优化,让解释器执行原始字节码。

字节码与机器码

image.png 但为什么 V8 不直接使用更快的机器码呢?引入中间字节码不会减慢整个过程吗?

理论上是的,但这还不是故事的全部。

有趣的是,这正是 V8 团队最初设计 JavaScript 引擎的方式。在V8的早期阶段,步骤如下:

  1. V8 将脚本编译为 AST 和作用域。
  2. 编译器将 AST 和作用域编译为机器码。
  3. V8 检测一些常用的机器码并将它们标记为 hot。
  4. 另一个编译器将 hot 代码优化为优化的机器码。
  5. 如果优化失败,编译器将运行反优化过程。

虽然今天的V8结构更加复杂,但基本思想是一样的。

然而,V8 团队在引擎发展时引入了字节码。为什么?因为使用机器码会带来一些麻烦。

1. 机器码需要大量内存

image.png V8 引擎将编译后的机器码存储在内存中,以便在页面加载时重用它们。

当编译为机器码时,10K JavaScript 可以膨胀为 20M 机器码。这大约是内存空间的 2,000 倍。

相同情况下字节码的大小如何?大约是80K。字节码仍然比原始 JavaScript 的字节码更大,但它比相应的机器码小得多。

如今,超过 1M 的 JavaScript 文件很常见。机器码消耗 2G 内存并不是一个好主意。

由于大小的减小,浏览器可以缓存所有编译的字节码,跳过前面的所有步骤,并直接执行它们。

2. 机器码并不总是比字节码快

image.png 机器码的编译时间较长,但执行速度却快如闪电。

字节码需要更短的编译时间,但代价是执行步骤更慢。解释器需要在执行字节码之前对其进行解释。

当我们从头到尾衡量这两个选项时,哪一个更快?

这看情况。

技术是在这两个选项之间找到平衡,同时为字节码开发强大的解释器和智能优化编译器。

V8 使用的解释器 Ignition 是市场上最快的解释器。

V8中的优化编译器是著名的TurboFan,从字节码编译出高度优化的机器码。

3. 机器码增加了开发的复杂性

不同的CPU可以有不同的结构。每个人只能理解一种机器码。市场上有很多处理器结构设计。仅举几例:

  • ARM
  • ARM64
  • X64
  • S397

如果浏览器只使用机器码,它需要分别处理这么多情况。作为一名开发人员,我们直观地知道这不是一个好的做法。

我们需要一层抽象。

字节码是 JavaScript 和 CPU 之间的抽象。通过引入中间字节码,V8团队减少了编译机器码的工作量。同时,它帮助 V8 轻松迁移到新平台。

要点

将所有内容放在一起,现在我们可以从高级视图中看到 Chrome V8 如何工作的完整版本。 image.png

参考