【翻译】JavaScript如何工作二:在V8引擎中的五个代码优化技巧

Clloz · · 2,090次浏览 ·

几个星期前我们开始了旨在深度挖掘 JavaScript 以及它是如何工作的系列文章,我们相信通过了解 JavaScript 的底层构建模块以及它们是如何工作的能够帮助我们写出更好的代码和应用。

第一篇文章主要关注的是引擎,运行时 runtime 和调用栈的概述。第二篇文章将深入剖析谷歌 V8 引擎的内部,我们还提供了一些关于如何编写更好的 JavaScript 代码的快速技巧 —— 我们 SessionStack 开发团队在开发产品的时候遵循的最佳实践。

概述

所谓 JavaScript 引擎就是一个用来执行 JavaScript 代码的程序或者解释器。一个 JavaScript 引擎可以被实现成一个标准解释器或者将 JavaScript 以某种形式编译成字节码的即使编译器。

下面是一些流行的实现 JavaScript 引擎的项目:

  • V8 — 由 Google 开发,使用 C++ 编写的开源引擎
  • Rhino — 由 Mozilla 基金会管理,完全使用 Java 开发的开源引擎
  • SpiderMonkey — 第一个 JavaScript 引擎,在当时支持了 Netscape Navigator,现在是 Firefox 的引擎
  • JavaScriptCore — 由苹果公司为 Safari 浏览器开发,并以 Nitro 的名字推广的开源引擎。
  • KJS — KDE 的引擎,最初是由 Harri Porten 为 KDE 项目的 Konqueror 网络浏览器开发
  • Chakra (JScript9) — IE 引擎
  • Chakra (JavaScript) — 微软 Edge 的引擎
  • Nashorn — 开源引擎,由 Oracle 的 Java 语言工具组开发,是 OpenJDK 的一部分
  • JerryScript — 这是物联网的一个轻量级引擎

为什么要创建 V8 引擎

由谷歌创建的 V8 引擎是一款用 C++ 开发的开源引擎,这款引擎被用在了谷歌的 Chrome 浏览器中。但是不同于其他引擎的是,V8 引擎同样被用到了非常流行的 Node.js 中。

v8

最穿创建 V8 引擎是为了提高浏览器执行 JavaScript 的性能。为了获得更快的速度,V8 并没有使用解释器而是将 JavaScript 代码编译成了更有效率的机器码。它就像 SpiderMonkey 或者 Rhino (Mozilla) 等许多现代 JavaScript 引擎一样,通过运用即时编译器( Just-In-Time Complier )将 JavaScript 代码编译为机器码。而这之中最主要的区别就是 V8 不生成字节码或者任何中间代码。

V8 曾经有两个编译器

V8 引擎的 5.9 版本之前,有两个编译器:

  • full-codegen  —— 一个简单快速的编译器,可以生成简单但是相对比较慢的机器码。
  • Crankshaft —— 一个更加复杂的(即时)优化编译器,可以产生高度优化的代码。

V8 引擎内部也使用了多个线程:

  • 主线程完成你所期望的任务:编译并执行你的代码。
  • 有一个独立的线程用来编译,当主线程执行的时候,前者可以优化代码。
  • 分析器( profiler )线程可以告诉运行时 runtime 哪些方法会花费大量时间,以便 Crankshaft 可以优化他们。
  • 其他的一些线程用来处理垃圾回收扫描

当第一次执行 JavaScript 代码的时候,V8 利用 full-codegen 直接将解析过的 JavaScript 代码不经过任何转换地翻译成机器码。这使它可以非常快速地开始执行机器码。需要注意的是,V8 不实用任何中间字节码表示,所以不需要解释器。

当你的代码已经运行了一段时间,分析器线程已经收集到足够多的数据来告诉运行时 runtime 哪些方法应该被优化。

接下来,Crankshaft 在另一个线程开始优化,它将 JavaScript 抽象语法树转换成一个叫 Hydrogen 的高级静态单元分配表示( SSA ),并且尝试去优化这个 Hydrogen 图。大多数优化都是在这个层级完成。

代码嵌入 Inlining

优化的第一步是尽可能多地提前嵌入代码。代码嵌入就是把调用函数的地方(函数被调用的行)用调用函数的函数体替换的过程。这简单的一步会使接下来的优化更有用。

inlining

隐藏类 Hidden Classes

JavaScript 是基于原型的语言:没有类或者对象是通过克隆的方法创建的。同时 JavaScript 也是一门动态的编程语言,这意味着为一个实例化的对象添加或删除属性是非常容易的。

大多数 JavaScript 解释器使用类似字典的结构 (基于散列函数) 去存储对象属性值在内存中的位置。这种结构使得在 JavaScript 中检索一个属性值比在像 Java 或者 C# 这种非动态语言中计算量大得多。在 Java 中, 编译之前所有的属性值以一种固定的对象布局确定下来了,并且在运行时不能动态的增加或者删除 (当然,C# 也有 动态类型,但这是另外一个话题了)。因此,属性值 (或者说指向这些属性的指针) 能够以连续的 buffer 存储在内存中,并且每个值之间有一个固定的偏移量。根据属性类型可以很容易地确定偏移量的长度,而在 JavaScript 中这是不可能的,因为属性类型可以在运行时更改。

因为用字典的方式在内存中查找对象属性值的方法效率非常低,所以 V8 使用了一个不同的方法:隐藏类( Hidden classes )。隐藏类的工作方式和 Java 中的固定对象布局(类)类似,除了它是在运行时 runtime 创建的。现在,让我们来看看他们实际的样子:

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

一旦 new Point(1, 2) 被调用, V8 会创建一个隐藏类叫做 C0

hidden-class

由于Point 还没有被定义任何属性,所以 C0 是空的。

只要第一条语句被执行 this.x = x(在 Point 函数内),V8 将会基于 C0 创建第二个隐藏类叫做 C1C1 描述了 x 属性在内存中的位置(相对于对象指针)。在这个例子中,x被保存在偏移量0 的位置。这意味着当把一个 point 对象看作内存中的连续 buffer 时,第一个偏移量对应的就是 x 属性。V8 也会使用类转换来更新 C0,当 x 属性被添加到一个 point 对象中的时候,隐藏类就从 C0 切换到 c1,此时 point 对象的隐藏类就是 c1 了。

hidden-class2

每当一个新属性添加到对象,老的隐藏类就会通过一个转换路径更新成一个新的隐藏类。隐藏类转换非常重要,因为它们允许以相同方法创建的对象共享隐藏类。如果两个对象共享一个隐藏类,并给它们添加相同的属性,隐藏类转换能够确保这两个对象都获得新的隐藏类以及与之相关联的优化代码。

当执行语句 this.y = y (同样,在 Point 函数内部,this.x = x 语句之后) 时,将重复此过程。一个新的隐藏类 C2 被创建了,如果属性 y 被添加到 Point 对象(已经包含了 x 属性),同样的过程,类型转换被添加到 C1 上,然后隐藏类开始更新成 C2,并且 Point 对象的隐藏类就要更新成 C2 了。

hidden-class-3

隐藏类的转换是根据属性添加到对象上的顺序来进行的,看看下面这段代码:

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 会以不同的隐藏类和转化路径来执行。从这个例子中我们可以看出,以相同的顺序来初始化动态属性是更好的,因为这样隐藏类可以复用。

这里关于隐藏类有很多疑问,比如如果两个对象初始化顺序相同,但是数据类型不同,比如上面的 ab 添加的顺序相同但是数据类型不同,比如一个是 Number 一个是 String,那么他们还能共享一个隐藏类吗,如果不能那么隐藏类的优势到底是什么呢?

内联缓存 inline caching

V8 还利用了另一种叫做内联缓存的技术来优化动态语言。内联缓存依赖于一个现象:同一个方法的重复调用是发生在相同类型的对象上的。关于内联缓存更深层次的解读请看这里

我们来大致了解一下内联缓存的基本概念 (如果你没有时间去阅读上面的深层次的解读)。

内联缓存是如何工作的呢?V8 维护了一个对象类型的缓存,存储的是在最近的方法调用中作为参数传递的对象类型,然后 V8 会使用这些信息去预测将来什么类型的对象会再次作为参数进行传递。如果 V8 对传递给方法的对象的类型做出了很好的预测,那么它就能够绕开获取对象属性的计算过程,取而代之的是使用先前查找这个对象的隐藏类时所存储的信息。

那么隐藏类和内联缓存的概念是如何联系到一起的呢?当一个对象的方法被调用的时候,V8 引擎会查看这个对象的隐藏类以便获取访问这个对象虽对应的偏移量。当用同一个隐藏类成功调用了两次某个方法,引擎就会跳过查找隐藏类这个步骤,而把偏移量当作属性添加到对象指针里面。以后每次调用这个方法,V8 引擎都会假定对象的隐藏类没有变化,直接用对象属性上的偏移量到内存中获取特定的属性。这将大幅提高代码执行的速度。

内联缓存也是隐藏类如此重要的原因,如果我们使用不同的隐藏类创建了两个同类型的对象(就如同我们前面做的那样),V8 就不能使用内联缓存,因为即使两个对象是相同的,但是它们对应的隐藏类对它们的属性分配了不同的偏移值。

编译成机器代码

一旦 Hydrogen 图被优化,Crankshaft 就会把这个图降低到一个比较低层次的表现形式 —— 叫做 Lithium。大多数 Lithium 实现都是面向特定的结构的。寄存器分配就发生在这一层次。
  最后,Lithium 被编译成机器码。然后,OSR 就开始了:一种运行时替换正在运行的栈帧的技术( on-stack replacement )。在我们开始编译和优化一个明显耗时的方法时,我们可能会运行它。V8 不会把它之前运行的慢的代码抛在一旁,然后再去执行优化后的代码。相反,V8 会转换这些代码的上下文(栈, 寄存器),以便在执行这些慢代码的途中转换到优化后的版本。这是一个非常复杂的任务,要知道 V8 已经在其他的优化中将代码嵌入了。当然了,V8 不是唯一能做到这一点的引擎。
  V8 还有一种保护措施叫做反优化,能够做相反的转换,将代码逆转成没有优化过的代码以防止引擎做的猜测不再正确。

垃圾回收

 对于垃圾回收,V8 使用一种传统的分代式标记清除的方式去清除老的数据。标记阶段会阻止 JavaScript 的运行。为了控制垃圾回收的成本,并且使 JavaScript 的执行更加稳定,V8 使用增量标记:与遍历整个堆去标记每一个可能的对象的不同,取而代之的是它只遍历部分堆,然后就恢复正常执行。下一次垃圾回收就会从上一次遍历停下来的地方开始,这就使得每一次正常执行之间的停顿都非常短。就像前面说的,清理的操作是由独立的线程的进行的。

如何写出优化的 JavaScript 代码

  1. 对象属性的顺序: 在实例化你的对象属性的时候一定要使用相同的顺序,这样隐藏类和随后的优化代码才能共享。
  2. 动态属性: 在对象实例化之后再添加属性会强制使得隐藏类变化,并且会减慢为旧隐藏类所优化的代码的执行。所以,要在对象的构造函数中完成所有属性的分配。
  3. 方法: 重复执行相同的方法会运行的比不同的方法只执行一次要快 (因为内联缓存)。
  4. 数组: 避免使用 keys 不是递增的数字的稀疏数组,这种 key 值不是递增数字的稀疏数组其实是一个 hash 表。在这种数组中每一个元素的获取都是昂贵的代价。同时,要避免提前申请大数组。最好的做法是随着你的需要慢慢的增大数组。最后,不要删除数组中的元素,因为这会使得 keys 变得稀疏。
  5. 标记值 (Tagged values): V832 位来表示对象的地址和数字。它使用最后一位一位来区分它是对象 ( flag = 1 ) 还是一个整型 ( flag = 0 ),也被叫做小整型( SMI ),因为它只有 31 位。然后,如果一个数值大于 31 位,V8 将会对其进行 box 操作,然后将其转换成 double 型,并且创建一个新的对象来装这个数。所以,为了避免代价很高的 box 操作,尽量使用 31 位的有符号数。

Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

 

00:00/00:00