深入 V8 引擎 + 5 个代码组织建议
console.info
本文翻译自 sessionstack.com,该系列共 19 篇文章,解释了 JavaScript 是如何在浏览器中工作,深入到了 JavaScript 解释器的工作原理,以及 JavaScript 技术实现细节。
原文链接: How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code
前言
几周前,我们开始了系列:深入研究 JavaScript 及其工作原理。我们认为:通过了解 JavaScript 的组成以及这些组成如何一起发挥作用,就将能够编写更好的代码和应用。
该系列的第一篇文章着重介绍了 JavaScript 的引擎、运行时和调用堆栈。这篇文章将深入探讨 Google V8 JavaScript 引擎的内部实现。同时,我们还将介绍一些 JavaScript 代码的编写技巧和准则(我们的开发团队就是基于这些准则开发产品)。
总览
JavaScript 引擎是执行 JavaScript 代码所必须的解释器。JavaScript 引擎可以实现为标准解释器(全文解释后执行),或以某种形式将 JavaScript 编译成字节码的即时编译器(变解释变执行)。
下方列出了目前比较出名的 JavaScript 引擎:
- V8 - 开源项目,Google 开发,使用 C++ 编写。
- Rhino - 开源项目,由 Mozilla 基金会开发,使用 Java 编写
- SpiderMonkey - 第一个 JavaScript 引擎,最开始是 Netscape Navigator 的 JavaScript 引擎,目前使用在 FireFox 上。
- JavaScriptCore - 开源项目,Apple 开发,使用在 Safari 浏览器上。
- KJS - KDE 引擎最初是由 Harri Porten 为 KDE 项目的 Konqueror web 浏览器开发。
- Chakra (JScript9) - IE 浏览器
- Chakra (JavaScript) - Edge 浏览器(目前 Edge 浏览器已经投靠 Chrome 使用 V8 引擎)
- Nashorn - 开源项目 OpenJDK 的部分内容,由 Oracle Java Languages and Tool Group 编写。
- JerryScript - 物联网下的一个轻量级引擎。
为什么需要 V8 ?
开源的 V8 引擎由 Google 使用 C++ 编写,使用在 Google 的 Chrome 上。但不同于其他的 JavaScript 引擎,V8 引擎还被用在目前非常流行的 Node.js 上。
image
V8 引擎设计的初衷是为了提升 JavaScript 在浏览器中的执行速度。为了提升 JavaScript 的执行速度,它将 JavaScript 代码转换成更高效的机器码,而不使用解释器。与许多现代 JavaScript 引擎(例如 SpiderMonkey 或 Rhino )类似,通过实现 JIT(Just-In-Time 实时)编译器将 JavaScript 代码实时的编译为机器码。但不同于其他 JavaScript 引擎,V8 不会生成字节码或任何中间代码。
注:使用解释器就代表有一个中间步骤,一般会生成字节码,然后在生成机器码。
V8 曾有两个编译器
在 5.9 版本之前,V8 采用了两种编译器:
- full-codegen:一个简单且高效的编译器,将代码转换成速度相对缓慢的机器码。
- Crankshaft:一个复杂的编译器(Just-In-Time 实时),高度优化代码。
注:这里的相对只是相对于未优化前。
V8 引擎内部主要有以下几个线程:
- 主线程:获取代码,编译并执行。
- 另外还有一个单独的编译线程,以便主线程可以在该线程优化代码时,继续执行。
- Profiler 线程:它将告诉运行时,在哪些方法上花费了大量的时间,以便 Crankshaft 编译器优化它们。
- 还有一些线程用于垃圾回收器(GC)。
当第一次执行 JavaScript 代码时,V8 利用 full-codegen 编译器,直接将 JavaScript 转换成机器码,不进行任何转换。这使得它可以非常快速地开始执行机器码。注意 V8 没有生成字节码的过程,因此不需要解释器。
当代码执行一段时间后,Profiler 线程就能收集到足够的信息,并能判断出哪些函数需要优化。
接下来,Crankshaft 编译器将会开启一个线程。 将 JavaScript 抽象语法树转换成一个被称为 Hydrogen 图的高度优化 static single-assignment (SSA 静态单赋值)的表示形式,并尝试优化该 Hydrogen 图。次过程会优化大量代码。
内联代码
第一个优化项:提前内联尽可能多的代码。所谓内联就是将函数调用(调用函数的代码行)替换为被调用函数的主体的过程。这个简单的过程使得之后的优化显得更有意义。替换过程如下所示:
image
隐藏类
JavaScript 是一种基于原型的语言,在执行过程中,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 的隐藏类:
image
没有任何的属性被定义,因此 C0 是一个空的类。
当执行了第一个语句 this.x = x 时(Point 函数的第一行),V8 将会创建以 C0 为基础的第二个隐藏类:C1。C1 描述了可以在内存中找到属性 x 的位置(相对于对象指针)。在这个案例中,x 的偏移量为 0,这意味着:如果将 Point 对象的属性视为一个数据块时,x 将会被存储在这数据块中的第一个位置。同时 V8 会更新 C0 类,这意味着:当属性 x 被添加到对象时,这个对象的隐藏类应该由 C0 转换到 C1,同时 C0 关联到 C1,形成转换链,如下图所示:
image
每当对象添加一条属性时,旧的隐藏类将会被更新为新的隐藏类。隐藏类的转换非常重要,因为 V8 允许不同对象关联相同的隐藏类(只要对象以相同的方式创建)。如果两个对象拥有相同的隐藏类,当相同的属性添加到该对象上时,V8 会确保这两个对象仍拥有相同的隐藏类,所有这个隐藏类上的优化都会相同。
当执行语句 this.y = y(Point 函数的第二行)时,将重复此过程。
当 y 属性被添加时,一个新的隐藏类 C2 被创建,C1 也关联上了 C2,同时该对象的隐藏类也更新到了 C2,如下图所示:
image
隐藏类转换的过程基于属性的添加顺序,下面的代码片段可以说明:
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 拥有相同的隐藏类,但是实现上,由于 a b 属性添加顺序的不同,p1 p2 拥有不同的隐藏类。因此这有个代码优化建议:以相同的顺序添加对象的属性,那么这些对象将拥有相同的隐藏类,这将会减少隐藏类的创建。
内联缓存
内联缓存:V8 中另一种优化动态类型语言的技术。内联缓存基于这样一个事实:相同类型的对象倾向于重复调用相同的方法。可以在这里找到内联缓存的详细说明。
这里将涉及到内联缓存的一般概念(以防你没有时间来详细了解上面的内容)。
那么,它是如何工作的呢?
V8 保存了最近一次方法调用中参数(引用类型的参数)的类型,并使用这些类型来假设将来函数调用时传入参数的类型。如果 V8 能准确的猜测函数参数的类型,那么它就可以通过之前对象的隐藏类来跳过访问对象属性的过程。
那么,如何关联隐藏类和内联缓存呢?
每当在特定对象上调用函数(将对象作为参数传入函数中)时,V8 引擎会找到该对象的隐藏类,用于确定某个属性的偏移量。当使用相同的隐藏类成功调用两次相同的函数之后,V8 将会省略隐藏类的查找,直接将属性的偏移量添加到对象上。对于之后该方法的调用,V8 引擎都会假定隐藏类未更改,并使用之前保存的偏移量直接跳到属性的内存地址。 这大大提高了执行速度。
内联缓存也是同类型对象共享隐藏类的重要原因。如果两个同类型的对象拥有不同隐藏类(正如我们前面的例子),V8 就不会使用内联缓存,因为即使两个同类型的对象,其不同隐藏类中属性可能会有不同的偏移量,如下图所示:
![两个对象类型相同,但隐藏类不同,导致属性的偏移量不同]image
编译为机器码
一旦 Hydrogen 图优化完成,Crankshaft 将其降低到一个称为 Lithium 的较低级表示形式。多数 Lithium 基于特定架构。寄存器分配在这个级别进行。
最后,Lithium 被编译成机器码。接着就是 OSR(on-stack replacement 堆叠更换)。在 V8 完成编译和优化这些耗时方法前,OSR 就可能已经在运行了,V8 不会移除它已执行的优化,相反的,它将转换我们拥有的所有上下文(堆栈、寄存器),方便我们在执行过程中直接切换到优化后的版本。这是一项非常复杂的任务,考虑到其他优化,V8 一开是就已经内联了代码。V8 并不是唯一这么做的的引擎。
还有一种称为反优化的保护措施,可以进行相反的转换,并在假设引擎不再适用的情况下去除优化还原为未优化的代码。
垃圾回收
V8 使用传统的 mark-and-sweep 来清理变量。标记阶段会阻塞 JavaScript 的执行。为了控制 GC 成本并使执行更稳定,V8 使用增量标记:它不遍历整个堆,而是尝试标记每个可能会被调用的对象,它只遍历堆的一部分,然后恢复正常执行。下一次 GC 运行时它将从上一次停止的地方继续。这会在正常执行期间有非常短的暂停。扫描阶段由单独的线程处理。
Ignition and TurboFan
随着 2017 年初 V8@5.9 版本的发布,V8 引入了新的执行流程。新的执行流程在 JavaScript 应用程序中实现了更大的性能改进并显着节省了内存。
新的执行流程建立在 Ignition( V8 的解释器)和 TurboFan( V8 的最新优化编译器)之上。
你可以在这里查看 V8 团队关于这个主题的博文。
自从 V8 的 5.9 版本发布以来,由于 V8 团队为了努力跟上新的 JavaScript 语言特性和这些特性所需要的优化,full-codegen 和 Crankshaft(2010年以来一直为V8服务的技术)已经不再被 V8 用于 JavaScript 的执行。
这意味着 V8 将会拥有更简单,更易于维护的体系结构。
image
这些改进仅仅是个开始。新的 Ignition 和 TurboFan 流程为进一步的优化铺平了道路,这将提高 JavaScript 性能,并在未来几年缩小 V8 在 Chrome 和 Node.js 中的占用的内存空间。
最后,这里有一些关于如何编写优化的、更好的 JavaScript 的技巧。你可以很容易地从上面的内容理解这些技巧所带来的性能提升。
如何组织 JavaScript 代码
- 组织对象属性的顺序:始终以相同的顺序实例化对象的属性,以便共享隐藏的类以及随后的优化代码。
- 动态属性:对象实例化后继续添加属性将修改隐藏类,并减慢上一个隐藏的类优化的所有方法,因此对象的属性最好全部在构造函数中声明。
- 方法:重复执行的方法将比仅执行一次的方法运行得更快(由于内联缓存)。
- 数组:避免稀疏数组,由于稀疏数组中的键值不是自增的数字,因此稀疏数组使用哈希表进行存储。这种数组中的元素访问开销较高;另外,尽量避免预分配大数组,而是是按需增长;最后,不要删除数组中的元素,这会使键值变得稀疏。
- 标记值:V8 使用 32 位表示对象和数字。它使用一位来区别对象(标志= 1)和 SMI(小整数)(标志= 0),因此可使用 31 位。如果数值大于 31 位,那么 V8 会将数字封装,将其变成双精度(double)并创建一个新对象用于数字的保存。因此尽可能使用 31 位带符号的数字,避免产生封装。