解读JavaScript之V8引擎及优化代码的5个技巧
几个星期前,我们开始了一系列旨在深入研究 JavaScript 及其实际工作方式的系列文章:我们认为通过了解 JavaScript 的构建块以及它们如何一起协作的,你将能够编写更好的代码和应用程序。
本系列的第一篇文章重点介绍了引擎,运行时和调用堆栈的概述。第二篇文章将深入到 Google V8 JavaScript 引擎的内部。我们还将提供一些关于如何编写更好的 JavaScript 代码的快速技巧 - 我们的 SessionStack 开发团队在构建产品时所遵循的最佳实践。
概览
JavaScript 引擎是执行 JavaScript 代码的程序或解释器。 JavaScript 引擎可以作为标准解释器或即时编译器,它以某种形式将 JavaScript 编译为字节码。
下面是一个实现了 JavaScript 引擎的流行项目列表:
- V8 — 开源,由 Google 开发,用 C ++ 编写
- Rhino — 由 Mozilla 基金会管理,开源,完全用 Java 开发
- SpiderMonkey — 是第一个支持 Netscape Navigator 的 JavaScript 引擎,目前正供 Firefox 使用
- JavaScriptCore — 开源,由苹果公司为 Safari 开发
- KJS — KDE 的引擎,最初由 Harri Porten 为 KDE 项目中的 Konqueror 网页浏览器开发
- Chakra (JScript9) — Internet Explorer
- Chakra (JavaScript) — Microsoft Edge
- Nashorn, 作为 OpenJDK 的一部分,由 Oracle Java 语言和工具组编写
- JerryScript — 物联网的轻量级引擎
为什么创建 V8 引擎?
由 Google 构建的 V8 引擎是开源的,用 C ++ 编写。 此引擎被用在 Google Chrome 中。 与其他引擎不同的是,V8 也被用于流行的 Node.js 中。
V8 最初是被设计用来提高网页浏览器内部 JavaScript 执行的性能。为了获得更快的速度,V8 将 JavaScript 代码翻译成更高效的机器代码,而不是使用解释器来翻译代码。它通过使用 JIT(Just-In-Time)编译器(如 SpiderMonkey 或 Rhino(Mozilla)等许多现代 JavaScript 引擎)来将 JavaScript 代码编译为机器代码。 这里的主要区别在于 V8 不生成字节码或任何中间代码。
V8 曾有两个编译器
在 V8 的 5.9 版本出来之前(今年早些时候发布),引擎使用了两个编译器:
- full-codegen - 一个简单而且速度非常快的编译器,可以生成简单且相对较慢的机器代码。
- Crankshaft - 一种更复杂(Just-In-Time)的优化编译器,生成高度优化的代码。
V8 引擎也在内部使用多个线程:
- 主线程完成您期望的任务:获取代码,编译并执行它
- 还有一个单独的线程用于编译,以便主线程可以继续执行,而前者正在优化代码
- 一个 Profiler 线程,它会告诉运行时我们花了很多时间,让 Crankshaft 可以优化它们
- 一些线程处理垃圾收集器
当第一次执行 JavaScript 代码时,V8 利用 full-codegen 编译器,直接将解析的 JavaScript 翻译成机器代码而不进行任何转换。这使得它可以非常快速地开始执行机器代码。请注意,V8 不使用中间字节码,从而不需要解释器。
当你的代码运行了一段时间,分析器线程已经收集了足够的数据来判断哪个方法应该被优化。
接下来,Crankshaft 从另一个线程开始优化。它将 JavaScript 抽象语法树转换为被称为 Hydrogen 的高级静态单分配(SSA)表示,并尝试优化 Hydrogen 图。大多数优化都是在这个级别完成的。
内联代码
第一个优化是提前尽可能多地内联代码。内联是将被调用函数的主体替换为调用站点(调用函数的代码行)的过程。这个简单的步骤使得下面的优化更有意义。
隐藏类
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” 的隐藏类。
尚未为 Point 定义属性,因此“C0”为空。
一旦第一个语句 “this.x = x” 被执行(在 “Point” 函数内部),V8 将创建第二个隐藏的类,名为“C1”,它基于“C0”。 “C1”描述了可以找到属性x的在内存中的位置(相对于对象指针)。在这种情况下,“x”被存储在0处,这意味着当在内存中将点对象看作一段连续存储空间时,第一个地址将对应于属性“x”。 V8 也会用“class transition”来更新“C0”,如果一个属性“x”被添加到一个点对象时,隐藏类应该从“C0”切换到“C1”。下面的点对象的隐藏类现在是“C1”。
每当一个新的属性被添加到一个对象时,旧的隐藏类将被更新为到新的隐藏类的转换路径。隐藏的类转换非常重要,因为它们允许隐藏的类在以相同方式创建的对象之间共享。如果两个对象共享一个隐藏类,并将相同的属性添加到这两个对象,则转换将确保两个对象接收相同的新隐藏类和所有优化代码。
当语句 “this.y = y” 被执行时,会重复同样的过程(在 “Point” 函数内部,“this.x = x”语句之后)。
一个名为“C2”的新隐藏类会被创建,如果将一个属性 “y” 添加到一个 Point 对象(已经包含属性“x”),一个类转换会添加到“C1”,则隐藏类应该更改为“C2”,点对象的隐藏类更新为“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;
现在,假设对于p1和p2,将使用相同的隐藏类和转换。那么,对于“p1”,首先添加属性“a”,然后添加属性“b”。然而,“p2”首先分配“b”,然后是“a”。因此,由于不同的转换路径,“p1”和“p2”以不同的隐藏类别结束。在这种情况下,以相同的顺序初始化动态属性好得多,以便隐藏的类可以被重用。
内联缓存
V8 利用另一种被称为内联缓存的技术来优化动态类型语言。内联缓存依赖于发生在相同类型的对象上的相同方法的重复调用的观察上。内嵌缓存的更多解释可以在这里找到。
接下来将讨论内联缓存的一般概念(如果您没有时间通过上面的深入了解)。
它是怎样工作的? V8 维护一个在最近的方法调用中作为参数传递的对象类型的缓存,并使用这些信息来预测将来作为参数传递的对象的类型。如果V8能够很好地假定传递给方法的对象类型,那么它可以绕过如何访问对象的属性的过程,而是将之前查找到的信息用于对象的隐藏类。
那么隐藏类和内联缓存的概念如何相关呢?无论何时在特定对象上调用方法时,V8 引擎都必须执行对该对象的隐藏类的查找,以确定访问特定属性的偏移量。在同一个隐藏类的两次成功的调用之后,V8 省略了隐藏类的查找,并简单地将该属性的偏移量添加到对象指针本身。对于该方法的所有下一次调用,V8 引擎都假定隐藏的类没有更改,并使用从以前的查找存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。
内联缓存也是为什么相同类型的对象可以共享隐藏类非常重要的原因。如果你创建了两个相同类型的对象和不同的隐藏类(就像我们之前的例子中那样),V8 将不能使用内联缓存,因为即使两个对象是相同的类型,它们相应的隐藏类为其属性分配不同的偏移量。
这两个对象基本相同,但“a”和“b”属性的创建顺序不同。
编译成机器码
一旦 Hydrogen 图被优化,Crankshaft 将其降低到称为 Lithium 的较低级表示。大部分的 Lithium 实现都是特定于架构的。寄存器分配往往发生在这个级别。
最后,Lithium 被编译成机器码。然后就是 OSR :on-stack replacement(堆栈替换)。在我们开始编译和优化一个明确的长期运行的方法之前,我们可能会运行堆栈替换。 V8 不只是缓慢执行堆栈替换,并再次开始优化。相反,它会转换我们拥有的所有上下文(堆栈,寄存器),以便在执行过程中切换到优化版本上。这是一个非常复杂的任务,考虑到除了其他优化之外,V8 最初还将代码内联。 V8 不是唯一能够做到的引擎。
有一种叫做去优化的保护措施来做出相反的变换,并且在假设引擎优化无效的情况下,还原回非优化的代码。
垃圾收集
对于垃圾收集,V8 采用了传统的分代式扫描方式来清理老一代。标记阶段应该停止 JavaScript 的执行。为了控制 GC 成本并使执行更加稳定,V8 使用了渐进式标记:而不是走遍整个堆内容,试图标记每一个可能的对象。它只走一部分堆内容,然后恢复正常执行。下一个 GC 将从先前堆走过的地方继续执行。这允许在正常执行期间非常短的暂停。如前所述,扫描阶段由不同的线程处理。
Ignition 和 TurboFan
随着 2017 年早些时候 V8 5.9 的发布,一个新的执行管道被引入。这个新的管道在实际的 JavaScript 应用程序中实现了更大的性能改进和显著的内存节省。
新的执行流程是建立在 Ignition( V8 的解释器)和 TurboFan( V8 的最新优化编译器)之上的。
你可以查看 V8 团队关于这个话题的博客文章。
自从 V8 5.9 版本问世以来,由于 V8 团队一直努力跟上新的 JavaScript 语言特性以及这些特性所需要的优化,V8 团队已经不再使用 full-codegen 和 Crankshaft(自 2010 年以来为 V8 技术所服务)。
这意味着 V8 整体上将有更简单和更易维护的架构。
在 Web 和 Node.js 性能上的提升
这些改进仅仅是一个开始。新的 Ignition 和 TurboFan 管道为进一步的优化铺平了道路,这将在未来几年提高 JavaScript 性能,缩小 V8 在 Chrome 和 Node.js 中的占用空间。
最后,这里有一些关于如何编写优化的、更好的 JavaScript 的技巧。你可以很容易地从上面的内容中得到这些,不过,这里有一个为你提供便利的总结:
如何编写优化的 JavaScript
- 对象属性的顺序:始终以相同的顺序实例化对象属性,以便共享的隐藏类和随后优化的代码可以共享之。
- 动态属性:在实例化之后向对象添加属性将强制执行隐藏的类更改,并降低之前隐藏类所优化的所有方法的执行速度。相反,在其构造函数中分配所有对象的属性。
- 方法:重复执行相同方法的代码将比仅执行一次的多个不同方法(由于内联缓存)的代码运行得更快。
- 数组:避免稀疏数组,其中键值不是自增的数字。并没有存储所有元素的稀疏数组是哈希表。这种数组中的元素访问开销较高。另外,尽量避免预分配大数组。最好是按需增长。最后,不要删除数组中的元素。这会使键值变得稀疏。
- 标记值:V8 使用 32 位表示对象和数值。由于数值是 31 位的,它使用了一位来区分它是一个对象(flag = 1)还是一个称为 SMI(SMall Integer)整数(flag = 0)。那么,如果一个数值大于 31 位,V8会将该数字装箱,把它变成一个双精度数,并创建一个新的对象来存放该数字。尽可能使用 31 位有符号数字,以避免对 JS 对象的高开销的装箱操作。
我们在 SessionStack 中试图编写高度优化的 JavaScript 代码时遵循这些最佳实践。 原因是,一旦将 SessionStack 集成到你的产品级的 Web 应用程序中,它就会开始记录所有的东西:所有的 DOM 更改、用户交互、JavaScript 异常、堆栈跟踪、网络请求失败、调试消息等。
通过 SessionStack ,你可以以视频的方式重现问题,并查看发生在用户身上的所有事情。所有这些都必须在对你的网络应用程序的性能没有任何影响的情况下进行的。