V8 入门记录一:初识

关于 V8

我想前端从业人员或多或少会听说过这个词,但是他具体是什么, 怎么入门, 怎么学习是一个较高的门槛,本文就 V8 入门,来做一个记录,也方便大家的学习。

V8 是 Google 用 C++ 编写的开源高性能 JavaScript 和 WebAssembly 引擎。它被用于 Chrome 浏览器和 Node.js 等。它实现了 ECMAScript 和 WebAssembly,可在 Windows 7 或更高版本、macOS 10.12+ 和使用 x64、IA-32、ARM 或 MIPS 处理器的 Linux 系统上运行。V8 可独立运行,也可嵌入到任何 C++ 应用程序中。

js 引擎

JavaScript 本质上是一种解释型语言,与编译型语言不同的是它需要一遍执行一边解析,而编译型语言在执行时已经完成编译,可直接执行,有更快的执行速度(如上图所示)。

为了提高性能,引入了 Java 虚拟机和 C++ 编译器中的众多技术。

现在 JavaScript 引擎的执行过程大致是:
源代码-→抽象语法树-→字节码-→JIT-→本地代码

V8更加直接的将抽象语法树通过JIT技术转换成本地代码,放弃了在字节码阶段可以进行的一些性能优化,但保证了执行速度。在V8生成本地代码后,也会通过 Profiler 采集一些信息,来优化本地代码。

这便是现在的执行过程:

源代码-→抽象语法树-→JIT-→本地代码

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

  • full-codegen:一个简单快速的编译器,用于生成简单但相对较慢的机器代码
  • Crankshaft:一个复杂的 (JIT) 优化的编译器,用于生成高度优化的代码

执行

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

  • 主线程完成用户期望的工作:获取代码,编译,执行
  • 单独的编译线程:在主线程执行的同时,进行代码优化
  • 单独的profiler线程:提供给 Crankshaft 判断哪些方法更耗时以进行优化
  • 垃圾收集器(Garbage Collector)进行垃圾清理的额外一些线程

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

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

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

v8的具体优化方案

隐藏类 (Hidden class)

对于动态类型语言来说,由于类型的不确定性,在方法调用过程中,语言引擎每次都需要进行动态查询,这就造成大量的性能消耗,从而降低程序运行的速度。而在静态语言中,可以直接通过偏移量查询来查询对象的属性值。

既然静态语言的查询效率这么高,那么是否能将这种静态的特性引入到 V8 中呢?

而V8引擎就引入了隐藏类(Hidden Class)的机制,起到给对象分组的作用。

在初始化对象的时候,V8引擎会创建一个隐藏类,随后在程序运行过程中每次增减属性,就会创建一个新的隐藏类或者查找之前已经创建好的隐藏类。每个隐藏类都会记录对应属性在内存中的偏移量,从而在后续再次调用的时候能更快地定位到其位置。

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);

调用 new Point(1,2) 时,V8引擎将创建一个名为 C0 的隐藏类。

image

Point 还没有定义任何属性,因此 C0 是空的。

一旦执行第一条语句 this.x = x(在 Point 函数内部),V8引擎将在 C0 的基础上创建第二个名为 C1 的隐藏类。

C1 描述了属性 x 在内存中的位置(相对于对象指针)。 在本例中,x 存储在偏移 0 处,这意味着当以连续缓冲区的形式查看内存中的 Point 对象时,第一个偏移将对应于属性 x

V8引擎还将通过 "类转换" 更新 C0,即如果在 Point 对象中添加了属性 x,则隐藏类将从 "C0 "转换为 "C1"。

下面这个 Point 对象的隐藏类现在是 C1

image

每当一个对象添加一个新属性时,旧的隐藏类就会更新为新的隐藏类。 隐藏类转换之所以重要,是因为它允许以相同方式创建的对象共享隐藏类

如果两个对象共享一个隐藏类,并且两个对象都添加了相同的属性,那么转换将确保两个对象都获得相同的新隐藏类以及随之而来的所有优化代码。

当执行语句 this.y = y 时(同样是在 Point 函数内部,this.x = x 语句之后),这一过程将重复执行。

创建了一个名为 C2 的新隐藏类,并在 C1 中添加了一个类转换。
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;

现在,你会认为 p1p2 都会使用相同的隐藏类和转换吗?

其实不然。对于 p1,首先要添加属性 a,然后再添加属性 b。而对于 p2,首先会分配 b,然后是 a
因此,由于过渡路径不同,p1p2 最终会拥有不同的隐藏类。
在这种情况下,最好以相同的顺序初始化动态属性,这样隐藏类就可以重复使用。

想要知道更多隐藏类的信息可查看文章:
https://juejin.cn/post/7064390052519870501

内联缓存

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

那么它是如何工作的呢?

V8 会对近期方法调用中作为参数传递的对象类型进行缓存,并利用这些信息对未来作为参数传递的对象类型做出假设。如果 V8 能够很好地推测出传递给方法的对象类型,那么它就可以绕过计算如何访问对象属性的过程,转而使用以前查询到的存储信息来访问对象的隐藏类。

var arr = [1, 2, 3, 4];
arr.forEach((item) => console.log(item.toString());

像上面这段代码,数字1在第一次 toString() 方法时会发起一次动态查询,并记录查询结果。当后续再调用 toString 方法时,引擎就能根据上次的记录直接获知调用点,不再进行动态查询操作。

var arr = [1, '2', 3, '4'];
arr.forEach((item) => console.log(item.toString());

可以看到,调用 toString 方法的对象类型经常发生改变,这就会导致缓存失效。为了防止这种情况发生,V8引擎采用了 polymorphic inline cache (PIC) 技术, 该技术不仅仅只缓存最后一次查询结果,还会缓存多次的查询结果(取决于记录上限)。

关于 PIC 可查看此处

那么,隐藏类和内联缓存的概念有什么关系呢?

每当调用特定对象的方法时,V8 引擎都要对该对象的隐藏类进行查找,以确定访问特定属性的偏移量。在对同一隐藏类成功调用两次同一方法后,V8 会省略隐藏类查找,直接将属性偏移量添加到对象指针本身。在以后对该方法的所有调用中,V8 引擎都会假定隐藏类没有改变,并使用以前查找时存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。

如果你创建了两个类型相同但隐藏类不同的对象(就像我们在前面的例子中所做的),V8 将无法使用内联缓存,因为即使这两个对象的类型相同,它们对应的隐藏类分配给它们的属性的偏移量也是不同的。

image

小结

  1. 使用字面量初始化对象时,要保证属性的顺序是一致的。
  2. 尽量在构造函数中使用字面量一次性初始化完整对象属性。
  3. 重复执行相同方法的代码会比只执行一次许多不同方法的代码运行得更快。

编译为机器码

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

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

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

垃圾回收

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

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

IgnitionTurboFan的引入

2017 年早些时候发布的 V8 5.9 引入了新的执行管道。在实际的 JavaScript 应用程序中,新管道实现了更大的性能提升和显著的内存节省。

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

您可以点击这里查看 V8 团队的相关博文。

自 V8 5.9 版本发布以来,由于 V8 团队一直在努力跟上 JavaScript 语言的新特性以及这些特性所需的优化,V8 不再使用 full-codegenCrankshaft(自 2010 年以来一直为 V8 服务的技术)来执行 JavaScript。

这意味着今后 V8 的整体架构将更加简单,可维护性更高。

image

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

介绍

现在,V8 的每个组件都有一个特定的名称,具体如下:

  • Ignition: V8 基于寄存器的快速低级解释器,用于生成字节码。
  • SparkPlug: V8 新的非优化 JavaScript 编译器,通过迭代字节码并在访问每个字节码时为其输出机器码,从而根据字节码进行编译。
  • TurboFan:V8 的优化编译器,可将字节码转换为机器码,并进行更多和更复杂的代码优化。它还包括 JIT(即时编译)。

综上所述,V8 的编译流程概览如下:
image

TurboFan 如何发挥作用

识别代码中的热点函数后,Ignition 会将数据发送给 TurboFan 进行优化。TurboFan 会接收这些代码,并开始运行一些神奇的优化,因为它已经拥有了来自 Ignition 的假设数据。然后,它会用新的优化字节码替换原来的字节码,这个过程会在我们程序的整个生命周期中不断重复。

这里举个例子:

function add(x, y) {
    return x + y;
}

add(1, 2);
add(12, 42);
add(17, 25);
add(451, 342);
add(8, 45);

将代码转换为字节码并运行后,Ignition 将执行以下冗长的加法过程:

image

现在,当我们多次调用这个带有整数参数的函数时,Ignition 会将其归类为热函数,并将收集到的信息发送给 TurboFanTurboFan 会针对整数优化这个函数,生成字节码并将其替换为原始字节码。现在,当下次调用 add(21, 45) 函数时,所有这些冗长的步骤都将被省略,并能更快地得到结果。

后备机制

如果我们调用带有字符串参数的 add 函数呢?为了处理这种情况,TurboFan 会检查传递的参数类型。如果类型与数字不同,它就会返回到点火器生成的原始字节码,并再次执行这一漫长的过程。这个过程被称为去优化(Deoptimization)。

如果我们多次调用带有字符串参数的 add 函数,Ignition 会将其视为热函数,并将其发送给 TurboFan,同时收集相关信息。TurboFan 还将针对字符串参数优化 add 函数,下次调用 add 函数时,将运行优化后的字节码,从而提高性能。

建议将 JavaScript 变量视为静态类型变量,这样才能保证代码的性能。这不仅适用于原始类型,也适用于对象。

如何编写最优化的 JavaScript

  1. 对象属性的顺序:始终按照相同的顺序实例化对象属性,以便共享隐藏类和随后的优化代码。
  2. 动态属性:在实例化后向对象添加属性会强制改变隐藏类,并减慢为前一个隐藏类优化的任何方法的运行速度。因此,应在构造函数中分配对象的所有属性。
  3. 方法:重复执行相同方法的代码会比只执行一次不同方法的代码运行得更快(由于内联缓存)。
  4. 数组:避免使用键不是递增数字的稀疏数组。稀疏数组中的每个元素都不在数组内,这就是哈希表。此类数组中的元素访问成本较高。此外,尽量避免预先分配大型数组。最好是边使用边增长。最后,不要删除数组中的元素。这会使键变得稀疏。
  5. 标记值: V8 用 32 位表示对象和数字。它使用一个位来识别是对象(标记 = 1)还是整数(标记 = 0),因为它有 31 位,所以称为 SMI(SMall Integer)。然后,如果数值大于 31 位,V8 将对该数字进行框选,将其转化为 double,并创建一个新对象将该数字放入其中。请尽可能使用 31 位有符号数字,以避免将其装入 JS 对象的昂贵装箱操作。

结语

本文主要是 V8 相关概念的讲述,并附上一些实例便于理解, 同时给出了从 V8 角度下代码的最优编写规则。

最后附上 JSIL 中看到的一句:

If your goal is to write the fastest possible JavaScript for your libraries or applications, you may need to go to those same lengths. For many applications, it's not worth the trouble - so resist the temptation to go nuts micro-optimizing something that really isn't particularly slow to begin with 😃

引用

posted @ 2023-12-13 01:52  Grewer  阅读(160)  评论(0编辑  收藏  举报