几个星期前,我们开始了一系列旨在深入挖掘 JavaScript 及其工作原理的系列:通过了解JavaScript的构建模块以及它们如何共同发挥作用,你将能够编写更好的代码和应用程序。

本系列的第一篇文章重点介绍了引擎,运行时和调用堆栈的概述。 第二篇文章将深入探讨谷歌V8 JavaScript引擎的内部原理。 我们还将提供一些关于如何编写更好的JavaScript代码的快速提示: 我们的SessionStack开发团队在构建产品时所遵循的最佳实践。

概览

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

下面是一个JavaScript引擎实现的热门项目列表:

  • V8 - 由Google开发的开源软件,用C ++编写
  • Rhino  - 由Mozilla Foundation管理,开源,完全用Java开发
  • SpiderMonkey  - 第一个JavaScript引擎,最早作为Netscape Navigator的一部分,现在被内置到Firefox中
  • JavaScriptCore  - 开源,以名为Nitro的产品销售,由Apple为Safari开发
  • KJS - KDE的引擎,最初由Harri Porten为KDE项目的Konqueror Web浏览器开发
  • Chakra (JScript9)  - Internet Explorer
  • Chakra (JavaScript)  - Microsoft Edge
  • Nashorn,作为OpenJDK的一部分的开源,由Oracle Java语言和工具组编写
  • JerryScript  - 用于物联网的轻量级引擎。

为什么要开发V8引擎?

由谷歌开发的V8引擎是用C ++编写开源软件。 此引擎在Google Chrome中使用。 但是,与其他引擎不同的是,流行的Node.js也把V8也作为运行时环境使用。

img

V8最初是为了提高Web浏览器中 JavaScript 执行的性能。 为了提高运行速度,V8 将 JavaScript 代码转换为更高效的机器代码,而不是使用解释器运行。 它通过实现JIT(即时)编译器将 JavaScript 代码编译成机器代码,这一点与许多现代 JavaScript 引擎一样,如 SpiderMonkey 或 Rhino(Mozilla)。 不过主要区别是V8不产生字节码或任何中间代码。

V8 曾经有两个编译器

在 V8 的 5.9 版本出现之前(2017年上半年发布),该引擎使用了两个编译器:

  • full-codegen:一个简单而快速的编译器,可以生成简单且相对较慢的机器代码。
  • Crankshaft:一种更复杂的(即时)优化编译器,可生成高度优化的代码。

V8引擎还在内部使用多个线程:

  • 主线程完成你的期望:获取代码,编译代码然后执行它
  • 另有一个单独的线程用于编译,因此主线程可以继续执行,同时前者优化代码
  • 一个 分析器线程,它将告诉运行时需要消耗大量时间的操作,以便 Crankshaft 可以优化它们
  • 一些线程来处理垃圾收集器的清理工作

当首次执行 JavaScript 代码时,V8 会用 full-codegen 直接将解析后的 JavaScript 代码转换为机器代码而无需其它转换。这使得它可以马上开始执行机器代码。 请注意:V8不使用中间字节码表示,因此无需解释器。

当代码运行一段时间之后,分析器线程已经收集到了足够的数据,知道了应该优化哪个方法。

接下来,Crankshaft 优化从另一个线程开始。 它将 JavaScript 抽象语法树转换成名为 Hydrogen 的高级静态单分配(SSA:static single-assignment)表示,并尝试优化 Hydrogen graph。 大多数优化都是在这个级别完成的。

内联

第一个优化是提前内联尽可能多的代码。 内联是用被调函数的函数体替换调用点(调用函数的代码行)的过程。 这个简单的步骤使后面的优化更有意义。

img

隐藏类

JavaScript是一种基于原型的语言:没有类,使用克隆过程创建对象。 JavaScript也是一种动态编程语言,这意味着可以在实例化后可以轻松地在对象中添加或删除属性。

大多数JavaScript解释器使用类似字典的结构(基于散列函数)在内存中存储对象属性值。 这种结构使得在JavaScript中检索属性值的计算成本比在 Java 或 C# 等非动态编程语言中更高。 在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的隐藏类。

img

此时尚未为Point定义任何属性,因此C0为空。

一旦第一个语句this.x = x被执行(在Point函数内),V8将创建一个名为C1的第二个隐藏类,它基于C0C1描述了可以找到属性 x 的存储器中的位置(相对于对象指针)。 在这种情况下,x 存储在偏移0处,这意味着当将存储器中的点对象视为连续缓冲区时,第一偏移将对应于属性x。 V8还将使用“类转换”更新C0,该类转换指出如果将属性 x添加到点对象,则隐藏类应从C0切换到 C1 。 下面的点对象的隐藏类现在是 C1

每次将新属性添加到对象时,旧的隐藏类都会更新为指向新隐藏类的转换路径。 隐藏类转换非常重要,因为它们允许在以相同方式创建的对象之间共享隐藏类。 如果两个对象共享一个隐藏类,并且同一属性被添加到它们之中,那么转换将确保两个对象都能够接收到相同的新隐藏类和随之附带的所有优化代码。

每次将新属性添加到对象时,旧的隐藏类都会更新为指向新隐藏类的转换路径。 隐藏类转换非常重要,因为它们允许在以相同方式创建的对象之间共享隐藏类。 如果两个对象共享一个隐藏类,并且同一属性被添加到它们之中,那么转换将确保两个对象都能够接收到相同的新隐藏类和随之附带的所有优化代码。

在执行语句this.y = y时重复此过程(再一次,在Point函数内,在this.x = x语句之后)。

创建一个名为C2的新隐藏类,将类转换添加到C1,声明如果将属性y添加到Point对象(已包含属性x),则隐藏类应更改为C2,点对象的隐藏类更新为C2

img

隐藏类的转换取决于属性添加到对象的顺序。 看下面的代码片段:

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;

看到上面的代码,你会认为对于p1和p2,将使用相同的隐藏类和转换。 实际上不是这样的。 对于p1,首先添加属性a,然后添加属性b。 但是,对于p2,首先分配b,然后是a。 因此,作为不同转换路径的结果,p1p2以不同的隐藏类结束。 在这种情况下,以相同的顺序初始化动态属性要好得多,因为可以重用隐藏的类。

内联缓存

V8 还使用了另一种技术来优化动态类型语言,被称为内联缓存。 内联缓存依赖于观察到的一种现象,那就是相同方法总是会被同一类型的对象的重复调用。 可以在这里找到对内联缓存的深入解释

下面我们将讨论内联缓存的一般概念(如果你没有时间仔细阅读上面的深入解释的话)。

那么它是怎样工作的呢? V8 维护一个在最近的方法调用中作为参数传递的对象类型的缓存,并以此信息来推测将来作为参数传递的对象类型。如果V8能够正确的推测出对传递给方法的对象类型,那么它就可以跳过确定如何访问对象属性的这一个步骤,这样就可以使用之前查找过的信息确定对象的隐藏类。

那么隐藏类和内联缓存这两个概念的关联是什么呢?每当在特定对象上调用方法时,V8 引擎必须找到该对象的隐藏类,才能确定访问特定属性的偏移量。当同一方法两次成功调用到同一个隐藏类之后,V8会省略对隐藏类的查找,直接将属性的偏移量添加到对象指针本身。对于该方法的所有将来的调用,V8引擎假设隐藏类并未更改,并且使用之前查找到并存储的偏移量直接跳转到特定属性的内存地址。这就大大提高了执行速度。

内联缓存也是相同类型的对象共享隐藏类的重要原因。如果你要创建两个类型相同但是隐藏类不同的对象(正如我们之前的例子中所做的那样)的话,V8将无法使用内联缓存,因为即使这两个对象属于同一类型,但是它们相对应的隐藏类为其属性分配的偏移量很有可能是不同的。

这两个对象基本相同,但`a`和`b`两个属性是按照不同顺序创建的。

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

编译为机器代码

Hydrogen graph优化后,Crankshaft 会将其降低到被称为 Lithium 的低级别表示。大多数 Lithium 实现都是特定于体系结构的。寄存器分配发生在这一级别。

最后,Lithium 被编译成机器代码。然后发生了一些被称为 OSR 的事:栈替换(on-stack replacement)。当一个显然会长时间运行的方法在我们开始编译和优化之前,它可能已经在运行。 V8 在重新启动优化版本之前并会任由这些代码缓慢的执行。相反,它将转换我们拥有的所有上下文(堆栈,寄存器),以便可以在执行过程中切换到优化版本。这是一项非常复杂的任务,考虑到其他优化,V8在一开始就已经内联了代码。 V8并不是唯一能够做到这一点的引擎。

有一种被称为去优化的保护措施可以进行相反的转换,如果引擎作出的假设不再成立,则恢复到非优化代码。

垃圾收集

对于垃圾收集,V8采用传统的标记和扫描方式来清理老生代。标记阶段应该停止JavaScript执行。为了控制GC成本并使执行更加稳定,V8使用了增量标记:不是遍历整个堆的同时尝试标记每个可能的对象,它只是遍历堆的一部分,然后恢复正常执行。 下一次GC将从上一次堆遍历停止的位置继续。这样会在正常执行期间只有非常短暂的暂停。 如前文所述,扫描阶段由单独的线程进行处理。

Ignition and TurboFan

2017年早些时候发布的V8 5.9,引入了新的执行管道。 事实证明,这个新的管道实现了更高的性能提升,并显著的节省了内存开销。

新的执行管道建立在 Ignition、V8的解释器和TurboFan(V8的最新优化编译器)之上。

你可以查看V8团队关于该主题的博客文章

自从V8的 5.9 版本问世以来,V8已经不再使用 full-codegen 和 Crankshaft(自2010年以来为V8提供服务的技术)用于JavaScript执行,因为V8团队一直在努力跟上新的JavaScript语言功能,并且这些功能需要优化。

这意味着整体V8将会具有更简单,更易维护的架构。

Web和Node.js基准测试的改进

Web和Node.js基准测试的改进

这些改进只是一个开始。 新的Ignition和TurboFan管道为进一步优化铺平了道路,这些优化将在未来几年内提升JavaScript性能,并减少V8在Chrome和Node.js中所占用的空间。

最后,有一些关于如何编写良好优化的JavaScript的技巧和窍门。 你可以从上面的内容轻松地推导出这些内容,下面是一个简要的总结:

如何编写优化的JavaScript代码

  1. 对象属性的顺序:始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码。
  2. 动态属性:在实例化后向对象添加属性将会强制更改隐藏类, 并且会减慢之前隐藏类优化的所有方法。应该在其构造函数中分配所有对象的属性。
  3. 方法:重复执行相同方法的代码将比只执行一次不同方法的代码运行得更快(由于内联缓存)。
  4. 数组:避免键值不是增量数的稀疏数组。访问哈希表中的元素会有更多的消耗。另外,尽量避免预先分配大型数组。最后,不要删除数组中的元素,这样会使键变得稀疏。
  5. 标记值:V8使用32个bit位表示对象和数字。它用一个bit位来表示剩下的个31个bit位是一个对象(flag = 1)还是一个名为SMI(SMall Integer)的整数(flag = 0)。如果数值大于31位,则V8将对该数字进行装箱操作,把它变为双精度并创建一个新对象以将数字放入其中。要尽可能的使用31位带符号的数字,以避免对 JS 对象进行昂贵的装箱操作。

我们在为 SessionStack 编写高度优化的 JavaScript 代码时一直遵循这些最佳实践。 原因是一旦把 SessionStack 集成到Web应用的生产环境中,它就会开始记录所有内容:所有DOM更改、用户交互、JavaScript异常、堆栈跟踪、失败的网络请求和调试消息。

通过SessionStack,你可以将网络应用中的问题重现,并查看发生的所有事情,同时对你的Web应用没有性能影响。

有一个免费的工具,不需要支付任何费用。 现在就可以试试