[译] JavaScript 如何工做:在 V8 引擎里 5 个优化代码的技巧

JavaScript 如何工做:在 V8 引擎里 5 个优化代码的技巧

  几个星期前咱们开始了一个旨在深刻挖掘 JavaScript 以及它是如何工做的系列文章。咱们经过了解它的底层构建以及它是怎么发挥做用的,能够帮助咱们写出更好的代码与应用。javascript

  第一篇文章 主要关注引擎、运行时以及调用栈的概述。第二篇文章将会深刻到 Google 的 JavaScript V8 引擎的内部。 咱们还提供了一些关于如何编写更好的 JavaScript 代码的快速技巧 —— 咱们 SessionStack 开发团队在开发产品的时候遵循的最佳实践。html

概述

  JavaScript 引擎 是执行 JavaScript 代码的程序或者说是解释器。JavaScript 引擎可以被实现成标准解释器或者是可以将 JavaScript 以某种方式编译为字节码的即时编译器。前端

  下面是一些比较火的实现 JavaScript 引擎的项目:java

  • 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 引擎是由 Google 用 C++ 开发的开源引擎,这个引擎也在 Google chrome 中使用。和其余的引擎不一样的是,V8 引擎也用于运行 Node.js。react

  V8 最初被设计出来是为了提升浏览器内部 JavaScript 的执行性能。为了获取更快的速度,V8 将 JavaScript 代码编译成了更加高效的机器码,而不是使用解释器。它就像 SpiderMonkey 或者 Rhino (Mozilla) 等许多现代JavaScript 引擎同样,经过运用即时编译器将 JavaScript 代码编译为机器码。而这之中最主要的区别就是 V8 不生成字节码或者任何中间代码。android

V8 曾经有两个编译器

  在 V8 的 v5.9 版本出来以前(今年早些时候发布的)有两个编译器:ios

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

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

  • 主线程完成你所指望的任务:获取你的代码,而后编译执行
  • 还有一个单独的线程用于编译,以便主线程能够继续执行,而前者就可以优化代码
  • 一个 Profiler (分析器) 线程,它会告诉运行时在哪些方法上咱们花了不少的时间,以便 Crankshaft 能够去优化它们
  • 还有一些线程处理垃圾回收扫描

  当第一次执行 JavaScript 代码的时候,V8 利用 full-codegen 直接将解析的 JavaScript 代码不通过任何转换翻译成机器码。这使得它能够 很是快速 的开始执行机器码,请注意,V8 不使用任何中间字节码表示,从而不须要解释器。github

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

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

代码嵌入 (Inlining)

  首次优化就是尽量的提早嵌入更多的代码。代码嵌入就是将使用函数的地方(调用函数的那一行)替换成调用函数的本体。这简单的一步就会使接下来的优化更加有用。

隐藏类 (Hidden class)

  JavaScript 是一门基于原型的语言: 没有类和对象是经过克隆来建立的。同时 JavaScript 也是一门动态语言,这意味着在实例化以后也可以方便的从对象中添加或者删除属性。

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

  运行到这里,Point 尚未定义任何的属性,因此 “C0” 是空的。

  当第一条语句 “this.x = x” 开始执行 (在 “Point” 函数中), V8 将会基于 “C0” 建立第二个隐藏类叫作 “C1”。“C1” 描述了属性值 x 在内存中的位置(相对于对象指针)。在这个例子中, “x” 被存在 偏移值 为 0 的地方, 这意味着当在内存中把 point 对象视为一段连续的 buffer 时,它的第一个偏移量对应的属性就是 “x”。V8 也会使用类转换更新 “C0”,若是一个属性 “x” 被添加到这个 point 对象中,隐藏类就会从 “C0” 切换到 “C1”。那么,如今这个point 对象的隐藏类就是 “C1” 了。

  每当一个新属性添加到对象,老的隐藏类就会经过一个转换路径更新成一个新的隐藏类。隐藏类转换很是重要,由于它们容许以相同方法建立的对象共享隐藏类。若是两个对象共享一个隐藏类,并给它们添加相同的属性,隐藏类转换可以确保这两个对象都得到新的隐藏类以及与之相关联的优化代码。

  当执行语句 “this.y = y” (一样,在 Point 函数内部,“this.x = x” 语句以后) 时,将重复此过程。

  一个新的隐藏类 “C2” 被建立了,若是属性 “y” 被添加到 Point 对象(已经包含了 “x” 属性),一样的过程,类型转换被添加到 “C1” 上,而后隐藏类开始更新成 “C2”,而且 Point 对象的隐藏类就要更新成 “C2” 了。

  隐藏类转换是根据属性被添加到对象上的顺序而发生变化。咱们看看下面这一小段代码:

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 来讲,首先分配 “b”,而后才是 “a”。所以,p1 和 p2 会以不一样的类转换路径结束,隐藏类也不一样。其实,在这两个例子中咱们能够看到,最好的方式是使用相同的顺序初始化动态属性,这样的话隐藏类就可以复用了。

内联缓存 (Inline caching)

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

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

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

  那么隐藏类和内联缓存的概念是怎么联系在一块儿的呢?不管何时当一个特定的对象上的方法被调用时,V8 引擎都会查找这个对象的隐藏类以便肯定获取特定属性的偏移值。当对于同一个隐藏类两次成功的调用了同一个方法时,V8 就会略过查找隐藏类,将这个属性的偏移值添加到对象自己的指针上。对于将来这个方法的全部调用,V8 引擎都会假设隐藏类没有改变,而是直接跳到特定属性在内存中的位置,这是经过以前查找时存储的偏移值作到的。这极大的提升了 V8 的执行速度。

  同时,内联缓存也是同类型对象共享隐藏类如此重要的缘由。若是咱们使用不一样的隐藏类建立了两个同类型的对象(就如同咱们前面作的那样),V8 就不能使用内联缓存,由于即便两个对象是相同的,可是它们对应的隐藏类对它们的属性分配了不一样的偏移值。

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

编译成机器代码

  一旦 Hydrogen 图被优化,Crankshaft 就会把这个图下降到一个比较低层次的表现形式 —— 叫作 Lithium。大多数 Lithium 实现都是面向特定的结构的。寄存器分配就发生在这一层次。

  最后,Lithium 被编译成机器码。而后,OSR就开始了:一种运行时替换正在运行的栈帧的技术(on-stack replacement)。在咱们开始编译和优化一个明显耗时的方法时,咱们可能会运行它。V8 不会把它以前运行的慢的代码抛在一旁,而后再去执行优化后的代码。相反,V8 会转换这些代码的上下文(栈, 寄存器),以便在执行这些慢代码的途中转换到优化后的版本。这是一个很是复杂的任务,要知道 V8 已经在其余的优化中将代码嵌入了。固然了,V8 不是惟一能作到这一点的引擎。

  V8 还有一种保护措施叫作反优化,可以作相反的转换,将代码逆转成没有优化过的代码以防止引擎作的猜想再也不正确。

垃圾回收

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

Ignition 和 TurboFan

  随着 2017 年早些时候 V8 5.9 版本的发布,一个新的执行管线被引入。这个新的执行管线在 实际的 JavaScript 应用中实现了更大的性能提高、显著的节省了内存的使用。

  这个新的执行管线构建在 V8 的解释器 Ignition 和 最新的优化编译器 TurboFan 之上。

  你能够在这里查看 V8 团队有关这个主题的全部博文。

  自从 V8 的 5.9 版本发布提来,V8 团队一直努力的跟上 JavaScript 的语言特性以及对这些特性的优化保持一致,而 full-codegen 和 Crankshaft (这两项技术从 2010 年就开始为 V8 服务) 再也不被 V8 使用来运行 JavaScript。

  这将意味着整个 V8 将拥有更简单、更易维护的架构。

  在 web 和 Node.js 上的改进

  固然这些改进仅仅是个开始。全新的 Ignition 和 TurboFan 管线为进一步的优化铺平了道路,这将在将来几年提升 JavaScript 性能以及使得 V8 在 chrome 和 Node.js 中节省更多的资源。

  最后,这里提供一些小技巧去帮助你们写出优化更好、更棒的 JavaScript。从上文中你必定能总结出这些技巧,不过我依然总结了一下提供给大家:

如何写出优化的 JavaScript

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

  咱们在 SessionStack 会尝试去遵循这些最佳实践去写出高质量、优化的代码。缘由是一旦你将 SessionStack 集成到你的 web 应用中,它就会开始记录全部东西:包括全部 DOM 的改变,用户交互,JavaScript 异常,栈追踪,网络请求失败和 debug 信息。有了 SessionStack 你就可以把你 web 应用中的问题当成视频,你能够看回放来肯定你的用户发生了什么。而这一切都不会影响到你的 web 应用的正常运行。 这儿有个免费的计划可让你 开始

更多资源


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索