JavaScript 如何工做: 深刻 V8 引擎 + 编写优质代码的 5 个技巧

译者: 波比小金刚javascript

翻译水平有限,若有错误请指出。html

原文: blog.sessionstack.com/how-javascr…前端

ps: 最近开始整理全部的优质文章翻译集,固然若是你有好的文章请提 issue,我会找时间翻译出来。java


第二篇文章的重点将会深刻 V8 引擎内部,而且分享一些编写优质 JavaScript 代码的最佳实践。git

概述

JavaScrip 引擎是执行 JavaScript 代码的程序或解释器。JavaScript 引擎能够由标准的解释器实现,或者经过 JIT 编译器(以某种形式将 JavaScript 代码编译成字节码)。github

以下列表展现了流行的 JavaScript 引擎:算法

V8 的诞生

V8 是 Google 的开源项目,由 C++ 编写,除了 Chrome 使用了 V8 以外,还有大名鼎鼎的 Nodejs!后端

v8

V8 设计之初的目的是为了提高浏览器执行 JavaScript 代码的性能。为了获取速度,V8 并无采用标准的解释器,而是经过把 JavaScript 代码编译成效率更高的机器码。 V8 和不少现代 JavaScript 引擎(好比:SpiderMonkey、Rhino)同样,经过 JIT 编译器把 JavaScript 代码编译成机器码。这里的主要区别就是 V8 不会产生任何的字节码或者中间代码。数组

V8 有两个编译器

截止最近的 5.9 版本,V8 使用了两个编译器:浏览器

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

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

  • 主线程如你所想:拉取你的代码、编译、而后执行。
  • 还有一个单独的线程用于编译,所以主线程能够继续执行,而前者正在优化代码。
  • 一个 Profiler 线程将告诉 runtime 哪些方法耗时太长,以便 Crankshaft 对其进行优化。
  • 一些线程用于 GC

首次执行 JavaScript 代码的时候,full-codegen 登场,直接将解析后的 JavaScript 翻译为机器码而不须要任何的转换。这使得 V8 能够很是快速的开始执行机器代码。

注意!V8 不使用中间字节码,意味着它不须要解释器。

当代码运行一段时间后,profiler 线程也已经收集到了足够的数据以表示哪些方法须要被优化。

接下来,Crankshaft 从另外一个线程开始进行优化,它翻译 JavaScript AST,而后用更高级的 SSA来表示(V8 中叫作 Hydrogen)。 而且尝试优化 Hydrogen 图,大多数优化都是在这个级别完成的。

下面是译者的注释。

整个过程分别在两个线程执行,不阻塞主线程,一方面经过 FC 直接编译出机器码,一方面经过 Crankshaft 对热点函数进行优化。

不产生中间代码或者字节码的缘由听说多是 Google 以为经过编译前端把 AST 翻译为中间代码还不如直接让编译后端将其翻译成机器码,一步到位。

内联

第一个优化点就是提早内联尽量多的代码。内联的过程其实就是用调用函数的函数主体替换调用函数点(call site) (调用函数所在的代码行)。

正是这个简单的步骤使得以下图的优化更有意义:

step

下面是译者的注释。

简明扼要的说函数调用点(call site)其实就是一行代码的调用。

// 未优化前 2 个 call site
a = sqr(b)
c = sqr(b)

// 同一个 call site 调用 3 次,由于是动态语言,调用函数在运行时选择,因此这里函数调用进行了3次选择
for (i in 1..3) {  
    a.call(i)  
}
复制代码

这段 Groovy 代码在高版本引入 Call Site 优化以后会就同一个 Call Site 的方法选择结果缓存起来,若是下一次调用时的参数类型同样,则调用该缓存起来的方法,不然从新选择。

殊途同归,V8 中的内联缓存(下边会说)也是与 Call Site 密切相关的。

V8 的内联缓存实际上就是针对具备相同属性的 JavaScript 对象的通用属性访问优化,目的是跳过昂贵的属性信息查找(过程)。这比每次查找属性要快得多。

请务必阅读这篇文章

如今你大概能够理解 V8 在背后对上图所示过程进行的优化了。

隐藏类(Hidden Class)

JavaScript 是基于原型的语言:因此和对象不是用克隆的过程建立的,JavaScript 也是一门动态语言,意味着对象在实例化以后能够轻松的增长或者移除属性。

大多数 JavaScript 解释器用相似于字典的数据结构(基于散列函数)来存储对象的属性值在内存的位置信息。 这种结构使得在 JavaScript 中检索属性的值比起在非动态类型语言(好比 Java、C#),须要更高的计算成本! 在 Java 中,全部的对象属性都是在编译以前由固定的对象布局决定的,而且没法在运行时新增或者删除(C#具备动态类型)。 结果就是,属性值(或者指向这些属性的指针)能够做为连续的缓冲(buffer)存储在内存中,而且每一个缓冲区之间有固定的偏移量(fixed-offset)。 能够根据属性的类型轻松的肯定该偏移的长度,而在属性类型也能够在运行时改变的 JavaScript 中,这是不可能的。

译者注:这里的连续缓冲的方式,我我的以为就是指一段连续的内存空间,经过 offset 的值对应不一样的属性,那么对属性的检索就变成了相似数组中的查找(O(1)),效率就很快了。

因为使用字典(结构)在内存中寻找对象属性的位置十分低效,V8 使用了不一样的方式代替:隐藏类(Hidden Class)。 Hidden Class 的工做方式相似于上边提到的 Java 中的固定对象布局(classes),除非它们是运行时建立的,咱们来看看它们其实是什么样的:

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

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

一旦第一个语句 "this.x = x" 执行(在 Point 函数内)。V8 将会基于 "C0" 建立第二个隐藏类,叫作 "C1"。 "C1" 描述了在内存中的哪一个位置(相对于对象指针)能够找到属性 "x",在这种状况下,"x" 被存在偏移 0 的位置(offset 0),这意味着若是把内存中的一个 point 对象视为连续缓冲(buffer), 在偏移为 0 的位置就对应着属性 "x"。V8 也会经过 "class transition" 来更新 "C0",这里 "class transition" 的做用其实就是声明若是属性 "x" 加到了 point 对象上,那么隐藏类(Hidden Class)就应该切换到 "C1",因此以下图所示,隐藏类如今是 "C1":

point-x

每次将新属性添加到对象,旧的隐藏类就会经过转换路径更新为新的隐藏类。隐藏类转换很是重要,由于它们容许一样方式建立的对象之间共享隐藏类。若是两个对象共享一个隐藏类,而且相同的属性被加到它们中,那么转换(transition) 将要确保两个对象都要接收到新的、相同的隐藏类及附带的优化代码。

在执行 "this.y = y" 的时候,上述过程将会被重复(一样,在 Point 函数内,this.x = x 以后)

一个新的隐藏类 "C2" 被建立,一个 "class transition" 被添加到 "C1" 来声明若是属性 "y" 被添加到 Point 对象(此时已包含属性 "x"),那么隐藏类应该切换到 "C2"。 而且point 对象的隐藏类被更新到 "C2":

point-y

隐藏类转换(就是上边的 class transition)取决于属性添加到对象的顺序,咱们能够看看下面的代码片断:

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 则是相反的顺序。 最终会在不一样的转换路径做用下会产生不一样的隐藏类。那么这种状况下,以相同的顺序初始化对象属性就会优化不少,由于能够重用隐藏类。

内联缓存(Inline caching)

V8 中另外一种优化动态类型语言的技术叫作,内联缓存(Inline caching)。

内联函数关注的是对相同方法的调用趋向于发生在相同类型的对象上,若是想要深刻了解的话请细细品味下边的拓展阅读部分。

V8 的内联缓存实际上就是针对具备相同属性的 JavaScript 对象的通用属性访问优化,目的是跳过昂贵的属性信息查找(过程)。这比每次查找属性要快得多。

咱们这里只会讨论内联缓存的通常概念。

因此,它是怎么工做的?V8 维护着一个关于当前函数调用时做为参数传入的对象的类型的缓存,而且使用该缓存信息来预测将来可能被做为参数传入的对象的类型。 若是 V8 可以作出很好的预测,那么咱们就能够绕过昂贵的属性查找过程,而使用以前查找对象隐藏类存储的信息。

因此,隐藏类和内联缓存的概念有何关联?每当对一个特定的对象调用方法时,V8 引擎会执行一次对对象隐藏类的查找以肯定访问特定属性的偏移量(offset)。 当同一方法成功调用两次后二者拥有相同的隐藏类,V8 会忽略掉隐藏类的查找,而且只是将属性的偏移量添加到对象指针自身。 对于该方法将来全部的调用,V8 引擎会假定其隐藏类未发生改变,并使用先前查找存储的属性偏移量直接跳到内存中该特定属性的存储地址。这大大提升了执行速度。

内联缓存也是为何同类型对象要共享隐藏类是如此重要的缘由。若是你建立两个同类型对象可是拥有不一样的隐藏类(如咱们以前的例子),V8 将没法使用内联缓存进行优化,由于即便是同一类型的对象, 可是不一样的隐藏类意味着会为其对象属性分配不一样的偏移量。

inline-cache

这两个对象基本相同,但“a”和“b”属性是按不一样顺序建立的。

编译到机器码

一旦 Hydrogen 图被优化,Crankshaft 将会下降其级别,称之为 Lithium。大多数 Lithium 实现都是特定于体系结构的。寄存器分配发生在此级别。

最后,Lithium 被编译成机器码。而后,触发 OSR:堆栈替换。当咱们开始编译而且优化一个明显的耗时方法,咱们可能正在运行它,V8 会慢慢的执行它来重启一个优化的版本,V8 会切换咱们拥有的全部上下文(堆栈、寄存器),以便咱们在执行过程当中切换的优化版本。这是一项很是复杂的任务,请记住,在其它优化中,V8 已经在初始阶段内联了代码。V8 不是惟一能作到这一点的引擎。

固然,这里还有一种叫作去优化的保护机制。当 V8 不能准确预测的状况下恢复到非优化代码(优雅回退)。

GC

对于 GC,V8 使用传统的标记清除算法清理老生代内存。在标记阶段会阻塞 JavaScript 执行。 为了控制 GC 的成本并使执行更加稳定,V8 使用了增量标记的方式:不是遍历整个堆内存,只是标记部分堆内存中的可能的对象,而后恢复主线程的执行。下一次的遍历接着从上一次中止的地方继续,所谓增量便是如此。这样就能够最大限度的下降由于 GC 任务执行带来的阻塞开销。并且清理阶段也是在单独的线程执行。

Ignition and TurboFan

2017 年发布的 V8 5.9 中,引入了 pipeline,pipeline 的引入带来了对 JavaScript 应用更大的性能提高和显著的内存节省。

新引入的 pipeline 创建在 Ignition、V8 的解释器、TurboFan 之上。

你能够点击这里查看 V8 团队关于这个主题的介绍博客。

自从 V8 的 5.9 版本问世以来,full-codegen 和 Crankshaft (自2010年以来,V8采用的技术)已经废掉了。由于 V8 须要与时俱进,随着 JavaScript 语言的演进而不断的优化。

这也意味着 V8 目前拥有更简单、更易于维护的架构。

Ignition and TurboFan

这些改进只是一个开始,新的 Ignition 和 TurboFan pipeline 为进一步的优化铺平了道路,这些优化在将来几年会提高 JavaScript 性能并缩小其在 Chrome 和 Node 中所占的空间。

好,接下来是一些总结的最佳实践:

最佳实践部分

  1. 对象属性排序:始终以相同的顺序实例化对象属性,以共享隐藏类和随后的优化代码。

  2. 动态属性:在实例化以后为一个对象添加属性会强制改变隐藏类,而且减慢为以前隐藏类优化的代码执行速度,最好的方式仍是在构造函数中分配好全部的属性。

  3. 方法:相同的方法重复执行比执行一次多个不一样方法更快(由于内联缓存)

  4. 数组:避免使用 key 不是递增数字的稀疏数组。稀疏数组是 hash table 结构,这种结构中的元素访问代价更高。此外,不要提早设置大的数组,应该根据具体场景,惰性增长。也不要随意删除数组中的元素,这样容易形成稀疏。

  5. 标记值:V8 用 32bits 表示对象或者数字。它使用一个 bit 来表示它是一个对象(flag = 1)仍是一个称为 SMI(SMall Integer)的整数(flag = 0),对于剩下的 31 位。 若是数值大于 31 位,V8 将对该数字进行处理,将其变为双精度并建立一个新对象以将数字放入其中。 因此尝试尽量使用 31 位带符号的数字,以免对 JS 对象进行昂贵的装箱操做。

拓展阅读

1. 深刻浅出 JIT 编译器

2. JavaScript Just-in-time (JIT) 工做原理

3. a closer look at crankshaft, v8's optimizing compiler

4. v8 full-codegen

5. 内联缓存

6. justjavac 的专栏

7. JavaScript 引擎基础:Shapes 和 Inline Caches

8. Optimizing dynamic JavaScript with inline caches

相关文章
相关标签/搜索