从高层次来看,V8 JavaScript 引擎的执行由 5 个步骤组成。
- 初始化宿主环境
- 编译 JavaScript 代码
- 生成字节码
- 解释并执行字节码
- 优化一些字节码以获得更好的性能
初始化环境
从技术上讲,这不是 V8 工作的一部分。它是浏览器的渲染器进程初始化以下两项:
- 宿主环境
- V8 引擎
浏览器有多个渲染器进程。通常,每个浏览器选项卡都有一个渲染器进程并初始化一个 V8 实例。
宿主环境是什么?在我们的上下文中,它是浏览器。因此,我们将在本文中互换使用“浏览器”和“宿主环境”。但请记住,浏览器只是 JavaScript 的宿主环境之一,另一个著名的是 Node。
宿主环境中有什么?
宿主环境提供了 JavaScript 引擎所依赖的一切,包括:
- 调用栈
- 堆
- 回调队列
- 事件循环
- Web API 和 Web DOM
网页上的用户交互会触发一系列事件。浏览器将它们与关联的回调函数一起添加到回调队列中。事件循环像无限 while 循环一样工作,不断从队列中获取回调。然后回调中的JavaScript被编译并执行。一些中间数据存储在调用栈中。有些保存在堆中,例如数组或对象。
为什么浏览器将数据存储在两个不同的地方?
- 以空间换速度:调用栈需要内存中的连续空间,以使其处理速度更快。然而,连续的空间在内存中很少见。为了解决这个问题,浏览器设计者通过最大尺寸来限制它。这就是堆栈溢出错误的来源。通常,浏览器在调用栈中保存有限大小的数据,例如整数和其他主要数据类型。
- 以时间换空间:堆不像对象那样需要连续的空间来保存大量数据。代价是堆处理数据的速度相对较慢。
在我看来,调用栈和事件循环是理解 JavaScript 如何工作的两个关键机制,这超出了本文的范围。
V8 引擎依赖并赋能宿主环境
V8 的宿主环境就像计算机的操作系统与软件一样。软件依赖于操作系统来运行。同时,它们使您的系统能够执行许多高级任务。
以 Photoshop 为例。它需要在 Windows 或 macOS 上运行。同时,你的操作系统无法为你制作一张漂亮的海报,但 Photoshop 可以。
与 V8 引擎相同,它在宿主环境之上提供了附加功能:
- 基于 ECMAScript 标准的 JavaScript 核心功能,例如对象和函数的创建
- 垃圾收集机制
- 协程
- …
当宿主环境和V8引擎准备就绪时,V8引擎开始下一步。
编译JavaScript代码
在这一步,V8引擎将 JavaScript 代码转换为抽象语法树 ( AST ) 并生成作用域。
V8 引擎不能直接理解 JavaScript 语言。在处理之前需要对脚本进行结构化。
AST 是一个树形结构,很容易被 V8 理解。
同时,在这一步生成了作用域,包括全局作用域和存储在宿主环境调用栈顶部的更多作用域。作用域本身值得另一篇文章来解释。您可以在这里安全地跳过它。
AST 是什么样子的?
让我们通过以 AST 格式显示以下 JavaScript 来检查一个简单的示例。
const medium = 'good ideas';
每行 JavaScript 代码都将转换为 AST,如本步骤中的示例一样。
生成字节码
在此步骤中,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 行字节码供解释器执行。
解释并执行字节码
字节码是指令的集合。这一步,解释器会从上到下执行每一行字节码。
在前面的示例中,我们看到以下 4 个字节码。
LdaConstant [0]
StaCurrentContextSlot [2]
LdaUndefined
Return
字节码的每一行就像一个乐高积木。无论您的代码多么花哨,都是在幕后使用这些基本块构建的。
每个字节码的详细信息超出了本文的范围。如果您对此感兴趣,这里是 V8 字节码的完整列表。
编译并执行机器码
此步骤与上一步并行。在执行字节码时,V8 不断监控代码并寻找优化它们的机会。
当检测到一些经常使用的字节码时,V8 将它们标记为 hot,然后将其转换为高效的机器码并由 CPU 执行。
如果优化失败怎么办?编译器对代码进行去优化,让解释器执行原始字节码。
字节码与机器码
但为什么 V8 不直接使用更快的机器码呢?引入中间字节码不会减慢整个过程吗?
理论上是的,但这还不是故事的全部。
有趣的是,这正是 V8 团队最初设计 JavaScript 引擎的方式。在V8的早期阶段,步骤如下:
- V8 将脚本编译为 AST 和作用域。
- 编译器将 AST 和作用域编译为机器码。
- V8 检测一些常用的机器码并将它们标记为 hot。
- 另一个编译器将 hot 代码优化为优化的机器码。
- 如果优化失败,编译器将运行反优化过程。
虽然今天的V8结构更加复杂,但基本思想是一样的。
然而,V8 团队在引擎发展时引入了字节码。为什么?因为使用机器码会带来一些麻烦。
1. 机器码需要大量内存
V8 引擎将编译后的机器码存储在内存中,以便在页面加载时重用它们。
当编译为机器码时,10K JavaScript 可以膨胀为 20M 机器码。这大约是内存空间的 2,000 倍。
相同情况下字节码的大小如何?大约是80K。字节码仍然比原始 JavaScript 的字节码更大,但它比相应的机器码小得多。
如今,超过 1M 的 JavaScript 文件很常见。机器码消耗 2G 内存并不是一个好主意。
由于大小的减小,浏览器可以缓存所有编译的字节码,跳过前面的所有步骤,并直接执行它们。
2. 机器码并不总是比字节码快
机器码的编译时间较长,但执行速度却快如闪电。
字节码需要更短的编译时间,但代价是执行步骤更慢。解释器需要在执行字节码之前对其进行解释。
当我们从头到尾衡量这两个选项时,哪一个更快?
这看情况。
技术是在这两个选项之间找到平衡,同时为字节码开发强大的解释器和智能优化编译器。
V8 使用的解释器 Ignition 是市场上最快的解释器。
V8中的优化编译器是著名的TurboFan,从字节码编译出高度优化的机器码。
3. 机器码增加了开发的复杂性
不同的CPU可以有不同的结构。每个人只能理解一种机器码。市场上有很多处理器结构设计。仅举几例:
- ARM
- ARM64
- X64
- S397
- …
如果浏览器只使用机器码,它需要分别处理这么多情况。作为一名开发人员,我们直观地知道这不是一个好的做法。
我们需要一层抽象。
字节码是 JavaScript 和 CPU 之间的抽象。通过引入中间字节码,V8团队减少了编译机器码的工作量。同时,它帮助 V8 轻松迁移到新平台。
要点
将所有内容放在一起,现在我们可以从高级视图中看到 Chrome V8 如何工作的完整版本。