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

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

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

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

概述

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

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

  • V8 —— 开源,Google 开发,C++ 编写
  • Rhino  —— Mozilla 基金会管理,开源,彻底使用 Java 开发
  • SpiderMonkey —— 第一个 JavaScript 引擎,之前由 Netscape Navigator 维护,如今由 Firefox 维护
  • JavaScriptCore —— 开源,以 Nitro 的名义销售,由 Apple 公司为 Safari 浏览器开发
  • KJS  ——  KDE 的引擎,最初由 Harri Porten KDE 项目的 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 对象的昂贵包装操做。
相关文章
相关标签/搜索