[译文] JavaScript工作原理:V8引擎内部+5条优化代码的窍门

1,032 阅读12分钟

原文 How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code

几周前我们开始了一个系列博文旨在深入挖掘 JavaScript 并弄清楚它的工作原理:我们认为通过了解 JavaScript 的构建单元并熟悉它们是怎样结合起来的,有助于写出更好的代码和应用。

这个系列的第一篇文章聚焦于提供一个关于引擎、运行时和调用栈的概述。本文将会深入分析 GoogleV8 引擎的内部实现。我们也会提供一些编写更优质 JavaScript 代码的小技巧——我们的团队在构建 SessionStack 应用时遵循的最佳实践。

概述

JavaScript 引擎是执行 JavaScript 代码的程序或解释器。 JavaScript 引擎可以实现为标准的解释器,或即时编译器,以某种形式将 JavaScript 编译成字节码。

以下是一些流行的 JavaScript 引擎项目:

  • V8 —— 开源,Google 开发,C++ 编写
  • Rhino  —— Mozilla 基金会管理,开源,完全使用 Java 开发
  • SpiderMonkey —— 第一个 JavaScript 引擎,以前由 Netscape Navigator 维护,现在由 Firefox 维护
  • JavaScriptCore —— 开源,以 Nitro 的名义销售,由 Apple 公司为 Safari 浏览器开发
  • KJS  —— KDE 的引擎,最初由 Harri PortenKDE 项目的 Konqueror 浏览器开发
  • Chakra (JScript9)  —— IE 浏览器
  • Chakra (JavaScript)  —— Edge 浏览器
  • Nashorn —— OpenJDK 开源项目的一部分,由 Oracle Java 和其工具集开发
  • JerryScript  —— 一个轻量级的物联网引擎

为什么要创建V8引擎?

谷歌公司研发的 V8 引擎是由 C++ 编写的开源引擎。该引擎使用在谷歌浏览器内部。但与其他引擎不同的是,V8 也应用于 Node.js 这一流行的运行时当中。

2-1 V8

V8 最初是为了提高浏览器中 JavaScript 执行的性能而设计的。为了获得速度,V8JavaScript 代码转换成更高效的机器编码而不是使用解释器。同其他现代 JavaScript 引擎如 SpiderMonkeyRhinoMozilla)所做的一样,V8 通过实现即时编译器在执行时将 JavaScript 代码编译成机器代码。其中最主要的区别是 V8 不生成字节码或任何中间代码。

V8曾有两个编译器

V8 5.9版本发布之前(2017年初发布),该引擎使用两个编译器:

  • full-codegen —— 简单、非常快的编译器,生成简单和相对较慢的机器代码
  • Crankshaft  —— 更加复杂的(即时)优化编译器,生成高度优化的代码

同时 V8 内部使用了多条线程:

  • 主线程的工作正如你所预期:获取代码、编译然后执行代码
  • 另有一条独立线程负责编译,这样主线程可以在前者优化代码时继续执行
  • 一条分析器线程会告诉运行时,哪些方法会耗费大量时间以便 Crankshaft 编译器优化代码
  • 还有几条线程处理垃圾回收清理

首次执行 JavaScript 代码时,V8 利用 full-codegen 无过渡地直接将解析后的 JavaScript 转换成机器代码。这使得它可以非常快速地开始执行机器代码。注意 V8 不使用中间代码表示,因此摆脱了对解释器的需要。

在你的代码运行了一定时间后,分析线程就能收集到足够的数据判断哪些方法需要优化。

接着,Crankshaft 优化在另一线程开始。它将 JavaScript 抽象语法树转换成高级静态单赋值(SSA)表示,称为 Hydrogen(注:氮),并尝试优化氮图。大多数优化都在这个级别完成。

内联

优化的第一步是先内联尽可能多的代码。内联是一个将调用引用(函数调用的那行代码)替换成所调用的函数体的过程。这个简单的步骤使接下来的优化过程更有意义:

2-2 Inlining

隐藏类

JavaScript 是基于原型的语言:没有,使用克隆的方式创建对象。JavaScript 还是一个动态编程语言,这意味着当对象被初始化之后还可以轻易地增删其属性。

大多数 JavaScript 解释器采用类字典数据结构(基于哈希函数)来存储对象属性值在内存中的位置。这种结构使得在 JavaScript 中取回属性值的计算开销比非动态语言如 JavaC#更昂贵。在 Java 中,所有的对象属性在编译前就由固定对象布局决定了,不允许在运行时动态增加或删除(C#有动态类型,但那是另一个话题)。因此,属性值(或指向属性的指针)就可以以连续缓冲区存储在内存中,之间用固定的偏移量隔开。偏移量的长度简单地根据属性的类型确定,然而这在 JavaScript 中是不可能的,因为属性类型可以在运行时更改。

由于通过字典查找对象属性在内存中的位置非常低效,V8 采用了另一方法作为替代:隐藏类。隐藏类的原理类似于 Java 等语言中使用的固定对象布局(类),除了是在运行时创建。现在,让我们来看看它们实际是什么样的:

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

new Point(1, 2) 调用发生,V8 将创建了一个名为 C0 的隐藏类。

2-3 C0

现在 Point 还没有定义任何属性,所以 C0 是空的。

一旦第一条声明 this.x = x 开始执行(在 Point 函数内),V8 将创建第二个基于 C0 的隐藏类 C1C1 描述了在内存中(相对于 point 对象)能找到属性 x 的位置。在这个例子中,x 保存在偏移量为 0 的位置,这意味着在将内存中的对象视作一个连续缓冲区时,第一个偏移量对应着 xV8 还会通过一个“类转换”更新 C0,以表明如果一个属性 x 被添加到 point 对象中,隐藏类 C0 就会转换成 C1。下面 point 对象的隐藏类现在变成了 C1

2-4 C1

每次添加一个新属性到对象,旧隐藏类都会通过一个转换路径更新成一个新隐藏类。隐藏类转换之所以如此重要是因为它能使隐藏类在以同样方式创建的对象间共享。如果两个对象共享同一个隐藏类并向它们添加相同的属性,转换可以确保它们获得相同的隐藏类和所有与其相关的优化代码。

this.y = y 语句执行时将会重复同样的过程(同样在 Point 函数内,this.x = x 之后)。

新的隐藏类 C2 将被创建,C1 发生类转换表示如果向一个 Point 对象添加属性 y (已经包含一个属性 x),隐藏类应该更新为 C2,并且 point 对象的隐藏类更新为 C2

2-5 C2

隐藏类转换依赖向对象所添加属性的顺序。请看下面的代码片段:

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

现在你可能会假设 p1p2 使用相同的隐藏类和转换。实际则并非如此。对于 p1,先添加属性 a 然后添加属性 b。而对于 p2,先添加的属性是 b 然后才是 a。因此,由于转换路径不同, p1p2 最终将会产生不同的隐藏类。在这种情况下,最好在初始化动态属性时保持顺序一致以便复用相同的隐藏类。

内联缓存

V8 利用了另一项叫做内联缓存的技术来优化动态类型语言。内联缓存依赖于这样一种观察:同一方法的重复调用通常发生在同一类型的对象上。关于内联缓存的深入阐述在这里

我们准备介绍内联缓存的一般概念(以免你没有时间查看上述的深入阐述)。

那么它的原理是什么?V8 维护着在最近的方法调用中作为参数传入的对象类型的缓存,并利用这个信息假设未来会被当做参数的对象的类型。如果 V8 能很好地假设出将要传入方法的对象的类型,就能直接越过如何获取对象属性的计算过程,取而代之的是使用之前查找对象的隐藏类时存储的信息。

那么隐藏类是如何与内联缓存关联起来的?每当某一对象调用方法时,V8 必须执行对此对象的隐藏类的查询来确定访问某个属性的偏移量。当对同一隐藏类成功调用过两次同样的方法后,V8 将省略对隐藏类的查询而只将属性偏移量添加到对象指针本身。对于那个方法未来所有的调用,V8 都假定隐藏类不改变,并利用之前查询存储的偏移量直接跳到某一属性的内存地址。这极大地提高了执行速度。

内联缓存也是同类对象共享同一隐藏类如此重要的原因。如果你创建了拥有不同隐藏类的两个同类对象(正如前面的例子),V8 就无法使用内联缓存,因为即便这两个对象是相同的类型,但他们对应的隐藏类为属性指定了不同的偏移量。

2-6 Inline caching

这两个对象基本相同,但 ab 属性的创建顺序不同。

编译到机器代码

一旦氮图优化好后,Crankshaft 会将它降为更低水平的表示,称为 Lithium(注:锂)。大多数 Lithium 的实现依赖于特定架构。寄存器分配发生在这个级别。

最终,Lithium 被编译成机器代码。随后发生 OSR:堆栈上替换。在开始编译和优化明显长时间运行的方法前,我们可能会运行它。V8 不会在再次开始执行优化版本时忘记那些缓慢的执行。而是转换我们所有的上下文(栈,寄存器)以便能在执行中切换到优化版本。这是个非常复杂的任务,记住在其他的优化中,V8 最先做了代码内联。V8 不是唯一有这种能力的引擎。

还有种被称为反优化的安全措施能做反向转换,回退到未优化代码,以防引擎做出的假设不再成立。

垃圾回收

在垃圾回收方面,V8 采用传统分代方法标记和清扫来清理老的代。标记阶段会暂停 JavaScript 的执行。为了控制垃圾回收的开销并使执行更加稳定,V8 采用增量标记:它不遍历全部栈堆,而是尝试标记每一个可能的对象,它只遍历栈堆的一部分,然后恢复正常执行。下一次垃圾回收暂停会在之前栈堆的停止位置继续。这可使正常执行期间只发生相当短的暂停。正如之前提到的,清理阶段由单独的线程处理。

Ignition 和 TurboFan

随着2017年初 V8 5.9版本的发布,一个新的执行管道被引入。新的管道在实际的JavaScript 应用中实现了更大的性能提升和的显著的内存节省。

新的执行管道构建在 V8 的解释器 IgnitionV8 最新的优化编译器 TurboFan 之上。

你可以在这里查阅 V8 团队关于这个主题的博文。

自从 V8 5.9版本发布以来, V8 就不再在 JavaScript 执行里使用 full-codegenCrankshaft(自2010年来一直支撑着 V8 的技术),这是由于 V8 团队也在努力地跟上新的 JavaScript 语言特性的脚步和这些特性所需的优化。

这意味着将来在整体上 V8 将拥有更加简单和更易于维护的架构。

2-7 Improvements on Web and Node.js benchmarks

这些提升仅仅是个开始。新的 IgnitionTurboFan 管道铺垫了更远的优化之路,将会推进 JavaScript 的性能并在接下来的几年里缩小 V8ChromeNode.js 中的足迹。

最后,这里有几条关于如何编写更优化的、更好的 JavaScript 代码的建议和技巧。虽然你可以很容易地从上述的内容中得到这些,为了方便还是把它们做了以下的总结:

怎么编写优化的JavaScript

  1. 对象属性的顺序:始终使用相同的顺序初始化对象属性,以便共享隐藏类和随后的优化代码。
  2. 动态属性:在初始化完成之后添加对象动态属性会强制改变隐藏类并使之前的隐藏类已优化的方法变慢。相反,在对象的构造器里指定所有的属性。
  3. 方法:重复执行相同方法的代码会比仅执行一次许多不同的方法运行的更快(由于内联缓存)。
  4. 数组:避免使用键值不递增的稀疏数组。并非每个元素都存在的稀疏数组是一个哈希表。访问稀疏数组的元素将会花费更昂贵的开销。此外,避免预先分配大数组。最好是按需要增加长度。最后,不要删除数组中的元素。这会使数组变得稀疏。
  5. 带标记的值V8 用32位字节表示对象和数字。其中使用了一个位来标识是对象(标识为1)或是整数(标识为0),由于它们是31位的而被称为 SMISMall Integer)。如果一个数值大小超过了31位可以表示的数字,V8 将会包装它,将其转换为一个双字节类型值并创建一个新的对象存入其中。尽量使用31带符号的数值避免 JS 对象的昂贵包装操作。