Jason Pan

JavaScript 工作原理 —— V8引擎透视 + 5个编写优化代码的技巧

潘忠显 / 2021-04-02


“JavaScript 工作原理”系列文章是翻译和整理自 SessionStack 网站的 How JavaScript works。因为博文发表于2017年,部分技术或信息可能已经过时。本文英文原文链接,作者 Alexander Zlatkov,翻译 潘忠显

笔者认为,通过了解 JavaScript 的基本组成以及它们工作原理,开发者可以编写出更好的代码和应用。

JavaScript 工作原理 —— 引擎、运行时和调用栈概述》重点介绍了引擎、运行时和调用堆栈的概述。本文作为第二篇文章,将深入探讨Google V8 引擎内部的组成部分。

概述

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

以下是热门的 JavaScript 引擎项目列表:

为什么研发V8引擎

Google 的开源项目 V8 引擎是以 C++ 编写的,该引擎在Google Chrome内部使用。同时,V8 还被用作时下流行的 Node.js 的运行时。

img

V8 最初旨在提高 Web 浏览器中 JavaScript 执行的性能。为了提高运行速度,V8 将 JavaScript 代码转换为更高效的机器代码,而不是使用解释器。它通过实现一个即时编译器 (JIT compiler),可以在执行时,将JavaScript代码编译为机器码,其他许多现代JavaScript引擎也是这么做的,如SpiderMonkey或Rhino(Mozilla)等。他们的主要区别是:V8不会产生字节码或任何中间码。

V8 曾经的两个编译器

在v5.9版本之前,V8 使用两个编译器:

V8引擎内部会使用几个线程:

首次执行 JavaScript 代码时,V8使用 full-codegen,它直接将已解析的 JavaScript 转换为机器代码,而无需任何中间转换,这使它可以“非常快地”开始执行机器代码。V8 没有使用中间字节码表示,因而消除了对解释器的需求。

代码运行了一段时间后,profiler 线程收集到了足够的数据,可以判断对哪些方法应该进行优化。

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

内联 (Inlining)

第一个优化是预先内联尽可能多的代码。内联是将调用站点(调用函数的代码行)替换为被调用函数体的过程。(译注:类似于C语言中的include展开)。这个简单步骤,使得后续优化更有意义。

img

隐藏类 (Hidden class)

JavaScript 是一种基于原型的 (prototype-based) 语言:没有类,创建对象依靠克隆过程(a cloning process)。JavaScript 还是一种动态编程语言,这意味着,可以在对象实例化之后,轻松地添加或删除属性。

大多数 JavaScript 解释器都使用类似于字典的结构(基于哈希函数),将对象属性值的位置存储在内存中。相对于非动态编程语言(比如Java),JavaScript的这种存储结构,导致它在检索属性的值需花费更多的计算资源。原因在于:在Java中,所有对象属性均由编译前的固定对象布局确定,并且无法在运行时动态添加或删除;因为属性固定,可以将属性的值(或指向这些属性的指针)作为连续缓冲区存储在内存中,并且在每个缓冲区之间具有固定的偏移量,可以轻松确定不同属性偏移量。而在 JavaScript 中,在运行时属性类型可以更改的情况下,就无法通过偏移的方式来快速访问某一属性。

由于使用字典来查找对象属性在内存中的位置非常低效,因此 V8 使用了一种技巧:隐藏类。隐藏类的工作方式类似于 Java 之类的语言中使用的固定对象布局(类),不同之处在于隐藏类是在运行时创建的。现在,让我们以以下代码为例进行解释(创建一个Point对象 p1):

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,V8 将创建一个基于 C0 的第二个隐藏类 C1C1 描述了x 位于内存中的、相对于对象指针的偏移位置,即在什么地址可以找到属性 x 。在这种情况下,x 存储在 offset 0 处。这意味着:若要在内存中查看作为连续缓冲区的Point对象时,首个偏移将对应于属性 x(译注:指针指向x)。V8 还将用类转换更新 C0,让其表达:如果一个属性 x 被添加到Point对象,隐藏类应当从 C0 切换到 C1。现在,表示Point对象的隐藏类是现在的C1

img

每次给对象添加新属性时,都会使用到新隐藏类的过渡路径 (transition path) 来更新旧的隐藏类。在以相同方式创建的对象之间,可以通过隐藏类来共享,因此隐藏类转换非常重要。如果两个对象共享一个隐藏类,并且它们两个都添加了相同的属性,则过渡将确保两个对象都接收到相同的新隐藏类,以及随之而来的所有优化代码。

当执行语句 this.y = y 将会重复上边 this.x = x 这一类转换的过程。创建了一个名为 C2 的新隐藏类,将一个类转换添加到 C1,表明如果将属性 y 添加到Point对象(C1已经包含属性 x),则隐藏类应变更成为 C2,并且Point对象的隐藏类更新为 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;

你可能觉着 p1p2 使用相同的隐藏类和过渡。其实不然!对于 p1 而言,首先添加属性 a,然后再是属性 b。而对 于p1 而言,首先添加属性 b,然后再是属性 a。因此两个对象是使用不同的过渡产生的不同隐藏类。这类例子中,最好保持相同的动态属性初始化顺序,以便能复用隐藏类。

内联缓存 (inline caching)

V8 利用了另一种用于优化动态类型语言的技术,称为内联缓存。内联缓存依赖于对作用在相同类型对象的、重复调用相同方法的趋势观察。更深入的讨论内联缓存说明,可以点击这里查看。如果您没时间详细阅读深入说明,我们这里也将介绍一些内联缓存的一般概念

V8 维护着一个对象类型缓存,缓存的是最近被作为参数传递到调用方法中对象,并假设这些对象类型将来也会被作为参数传递。如果V8能较准确的预测被传递到方法中的对象类型,那么他就可以绕过寻找如何访问对象属性的过程,使用以前查找到对象的存储信息作为替代。

隐藏类和内联缓存两个概念有什么联系?每当在特定对象上调用方法时,V8 引擎都必须对该对象的隐藏类执行查找,以确定用于访问特定属性的偏移量。在对相同隐藏类的相同方法成功调用两次之后,V8 会忽略隐藏类的查找,仅简单地将属性的偏移量添加到对象指针上。对于之后该方法的所有调用,V8 引擎假定隐藏类未更改,并使用先前查找中存储的偏移量直接跳到特定属性的内存地址。这大大提高了执行速度。

内联缓存也是同类型对象共享隐藏类之所以如此重要的原因。如果创建两个具有相同类型但具有不同隐藏类的对象(前边示例中的 p1p2),V8 将无法使用内联缓存,因为这两个对象虽属于同一类型,但它们各自的隐藏类为它们的属性分配不同的偏移量。

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

编译机器码

当 Hydrogen graph 被优化时,Crankshaft 会将其转成为更低级别的表达形式(称之为Lithium)。大多数 Lithium 实现都是特定于计算机架构,寄存器分配也在此级别进行。

Lithium 最终会被编译为机器码。接下来是被称为 OSR 的堆栈替换。在我们开始编译和优化明显会长时间运行的方法之前,我们可能已经在运行它了。V8 不是先记录执行缓慢的方法,然后再次启动的时候使用优化版本。相反,他会将所有上下文(栈、寄存器)进行转换,以便于我们能够在执行过程中切换到优化后的代码。再加上其他优化,V8 初始化时的内联代码是一项很复杂的任务。不过,V8 引擎也不是唯一能做到这点引擎。

有一些称为反优化 (deoptimization) 的保护措施,在假设引擎不使用的场景下,可以反向转换成未优化的代码。

垃圾收集 (Garbage collection)

关于垃圾收集,V8 使用传统的方法,通过标记清除 (mark-and-sweep) 的方式来清理垃圾 (old generation)。

标记阶段 (marking phase) 需要停止 JavaScript 执行。为了控制垃圾回收成本并使执行更加稳定,V8使用了增量标记:不是遍历整个堆(尝试对每个可能的对象进行标记),而是只遍历堆的一部分,然后恢复正常执行。下一次垃圾收集,会从上一次堆遍历停止的位置继续进行。这种方式允许在正常执行期间非常短的暂停。如前所述,清除阶段 (sweep phase) 由单独的线程处理。

Ignition and TurboFan

2017年初5.9版本的V8发布,引入了新的执行管道 (execution pipeline)。该管道实现了更大的性能改进,并在现实 JavaScript 应用显著的节省了内存。新的执行管道基于 V8 的解释器 Ignition 和 V8 最新的优化编译器 TurboFan

自5.9版本问世以来,V8 团队一直在努力追求 JavaScript 语言新特性,因此 full-codegen 和Crankshaft(自2010年起为V8服务的技术)不再被 V8 用于 JavaScript 执行,这些功能需要针对新特性进行优化。这意味着整个V8将来将具有更简单、更可维护的体系结构。

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

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

最后,您可以从上面的内容中轻松得出如何编写良好优化的、更好的 JavaScript 的提示和技巧。但是,为方便起见,做简单的摘要如下:

编写优化 JavaScript 的5个技巧

  1. 对象属性的顺序:始终以相同的顺序实例化对象属性,以便可以共享隐藏的类以及随后优化的代码。
  2. 动态属性:实例化后向对象添加属性将强制更改隐藏类,已经为上一个隐藏类优化过的方法都会因此而变慢。替代措施是在构造函数中分配对象的所有属性。
  3. 方法:由于内联缓存 (inline caching) 的原因,重复执行相同方法将比仅执行一次许多不同方法的代码运行更快。
  4. 数组:避免键不是递增数字的稀疏数组。里面没有所有元素的稀疏数组是一个“哈希表”。访问这样数组中的元素耗费更大。另外,避免预先分配大数组,在使用中逐渐增长更好。最后,不要删除数组中的元素,它使键更稀疏。
  5. 标记值 (tagged values):V8 使用 32 位表示对象和数字。它使用 32 bit 中的一个 bit 来标记变量是对象(flag = 1),还是的整数(flag = 0),这个整数被称为”小整数(SMI, SMall Integer)“,因为它只有31 bit。如果数值大于31位,则V8会将数字装箱 (box),将其变成 double 并创建一个新对象以将数字放入其中。尽可能使用 31 bit 带符号的数字,以避免对 JS 对象进行昂贵的装箱操作。

参考资料