JavaScript工作机制:V8 引擎内部机制及如何编写优化代码的5个诀窍

527 阅读13分钟
原文链接: www.zcfy.cc

几周前,我们开始写旨在深入挖掘JavaScript及其工作机制的一系列文章:我们认为,通过了解JavaScript的构造单元以及这些构造单元如何组织在一起,您就能够编写更好的代码和应用程​​序。

该系列的第一篇文章重点是提供一个对引擎、运行时和调用栈的概述。这第二篇文章将会深入Google V8 JavaScript引擎的内部。我们还将提供如何编写更佳 JavaScript 代码的一些小技巧 - 这也是我们 SessionStack 开发团队在构建产品时遵循的最佳实践。

概述

JavaScript引擎是一个执行JavaScript代码的程序或解释器。JavaScript引擎可以被实现为标准解释器,或者实现为以某种形式将JavaScript编译为字节码的即时编译器。

下面是实现了JavaScript引擎的一个热门项目列表:

  • V8 — 开源,由Google开发,用C++编写的
  • Rhino — 由Mozilla基金所管理,开源,完全用Java开发
  • SpiderMonkey —第一个JavaScript引擎,最早用在Netscape Navigator上,现在用在Firefox上。
  • JavaScriptCore — 开源,以Nitro销售,由苹果公司为Safari开发
  • KJS —KDE的引擎最初由Harri Porten开发,用于KDE项目的Konqueror浏览器
  • Chakra (JScript9) — Internet Explorer
  • Chakra (JavaScript) — Microsoft Edge
  • Nashorn— 开源为OpenJDK的一部分,由Oracle的Java语言和工具组开发
  • JerryScript —  是用于物联网的轻量级引擎

创建V8引擎的由来

Google构建的V8引擎是开源的,用C++编写的。该引擎被用在Google Chrome中。不过,与其他引擎不同的是,V8还被用作很受欢迎的Node.js的运行时。

V8最初是设计用来提升Web浏览器中JavaScript执行的性能。为了获得速度,V8将JavaScript代码转换为更高效的机器码,而不是使用解释器。它通过实现像很多现代JavaScript引擎(比如SpiderMonkey或Rhino)所用的JIT(即时)编译器,从而将JavaScript代码编译成机器码。这里主要区别在于V8不会产生字节码或任何中间代码。

V8曾经有两个编译器

在V8 的5.9版(今年早些时候发布)出现之前,V8引擎用了两个编译器:

  • full-codegen - 一个简单而超快的编译器,可以生成简单而相对较慢的机器码。

  • Crankshaft - 一个更复杂(即时)的优化的编译器,可以生成高度优化的代码。

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

  • 主线程执行我们想让它干的活:获取代码,编译然后执行它

  • 还有一个单独的线程用于编译,这样在主线程继续执行的同时,单独的线程能同时在优化代码

  • 一个Profiler线程,用于让运行时知道哪些方法花了大量时间,这样Crankshaft就可以对它们进行优化

  • 几个线程用于处理垃圾收集器清扫

第一次执行JavaScript代码时,V8会利用full-codegen直接将解析的JavaScript翻译为机器码,而无需任何转换。这就让它能非常快地开始执行机器码。请注意,由于V8不会使用中间字节码表示,这样就无需解释器。

代码运行了一段时间后,Profiler线程已经收集了足够的数据来判断应该优化哪个方法。

接下来,Crankshaft优化从另一个线程中开始。它将JavaScript抽象语法树翻译为称为Hydrogen的高级静态单赋值(SSA)表示,并尝试优化Hydrogen图。大多数优化都是在这一级完成的。

内联

第一个优化是提前内联尽可能多的代码。内联是用被调用的函数的函数体替换调用位置(调用函数所在的代码行)的过程。这个简单的步骤让以下优化变得更有意义。

隐藏类

JavaScript是一种基于原型的语言:它没有类,对象是用一种克隆过程创建的。JavaScript也是一种动态编程语言,就是说在对象实例化之后,可以随意给对象添加或删除属性。

大多数JavaScript解释器都使用类似字典的结构(基于哈希函数),将对象属性值的位置存储在内存中。这种结构使得在JavaScript中获取属性的值比在Java或C#这样的非动态编程语言中更昂贵。在Java中,所有对象属性都是由编译前的固定对象布局确定的,并且不能在运行时动态添加或删除(C#有动态类型,这是另一个话题了)。因此,属性的值(或指向这些属性的指针)可以在内存中存为连续缓冲区,每个缓冲区之间有固定偏移量。偏移量的长度可以很容易根据属性类型来确定。而在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的第二个隐藏类C1C1描述了内存中的位置(相对于对象指针),属性x在这个位置可以找到。此时,x存储在偏移地址0处,就是说,当将内存中的point对象作为连续缓冲器来查看时,第一个偏移地址就对应于属性x。V8也会用“类转换”来更新C0,指出如果将一个属性x添加到点对象,那么隐藏类应该从C0切换到C1。下面的point对象的隐藏类现在是C1

每当向对象添加一个新属性时,旧的隐藏类就被用一个转换路径更新为新的隐藏类。隐藏类转换很重要,因为它们可以让隐藏类在以相同方式创建的对象之间共享。如果两个对象共享一个隐藏类,并且将相同的属性添加到这两个对象中,那么转换会确保两个对象都接收到相同的新隐藏类和它附带的所有优化过的代码。

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

这时,又创建一个名为C2的新隐藏类,类转换被添加到C1,表示如果将属性y添加到Point对象(已包含属性x),那么隐藏类应更改为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;

现在,你可能会认为p1p2会使用相同的隐藏类和转换。嗯,这是错的。对于p1,首先是添加属性a,然后是属性b。不过,对于p2,先是给b赋值,然后才是a。因此,由于转换路径不同,p1p2最终会有不同的隐藏类。在这种情况下,以相同的顺序初始化动态属性要更好,这样隐藏类才可以被重用。

内联缓存

V8利用另一种称为内联缓存(inline caching)的技术来优化动态类型语言。内联缓存来自于观察的结果:对同一方法的重复调用往往发生在同一类型的对象上。关于内联缓存的深入解释可以在这里找到。

下面我们打算谈谈内联缓存的一般概念(如果您没有时间阅读上面的深入解释的话)。

那么它是如何工作的呢?V8维护在最近的方法调用中作为参数传递的对象类型的缓存,并使用该信息对将来作为参数传递的对象类型做出假设。如果V8能够对传递给方法的对象类型做出一个很好的假设,那么它可以绕过算出如何访问对象的属性的过程,转而使用先前查找对象的隐藏类时所存储的信息。

那么隐藏类和内联缓存的概念是如何关联的呢?无论何时在特定对象上调用方法,V8引擎必须对该对象的隐藏类执行查找,以确定访问特定属性的偏移地址。在对同一个隐藏类的同一方法进行了两次成功的调用之后,V8就省掉了隐藏类查找,只将属性的偏移地址添加到对象指针本身上。对于所有将来对该方法的调用,V8引擎都会假定隐藏类没有改变,并使用先前查找中存储的偏移地址直接跳转到特定属性的内存地址。这会大大提高执行速度。

内联缓存也是为什么同一类型的对象共享隐藏类非常重要的原因。如果您创建相同类型的两个对象,但是用的是不同的隐藏类(如前面的示例),那么V8将无法使用内联缓存,因为即使两个对象的类型相同,但是它们的对应隐藏类也会为其属性分配不同的偏移地址。

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

编译到机器码

一旦Hydrogen图被优化,Crankshaft将其降低到一个称为Lithium的较低级别表示。大多数Lithium实现都是针对架构的。寄存器分配发生在这一级。

最后,Lithium被编译成机器码。然后其他事情,也就是OSR(当前栈替换,on-stack replacement),发生了。在我们开始编译和优化一个明显要长期运行的方法之前,我们可能会运行它。V8不会蠢到忘记它刚刚慢慢执行的代码,所以它不会再用优化版本又执行一遍,而是将转换所有已有的上下文(栈、寄存器),以便我们可以在执行过程中间就切换到优化版本。这是一个非常复杂的任务,请记住,除了其他优化之外,V8最开始时已经内联了代码。V8并非唯一能够做到这一点的引擎。

有一种称为去优化的保护措施,会作出相反的转换,并恢复为非优化代码,以防引擎的假设不再成立。

垃圾回收

对于垃圾回收来说,V8采用的是标记、清扫这种传统分代方式来清除旧一代。标记阶段应该停止执行JavaScript。为了控制GC成本,并使执行更加稳定,V8使用增量式标记:不是遍历整个堆,尝试标记每一个可能的对象,而是只遍历一部分堆,然后恢复正常执行。下一个GC停止会从之前的堆遍历停止的地方继续。这就允许在正常执行期间有非常短的暂停。如前所述,清扫阶段是由单独的线程处理。

Ignition 和 TurboFan

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

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

您可以在这里查看V8团队关于这个主题的博文。

自从5.9版本发布以来,V8不再用full-codeget 和 Crankshaft(自2010年以来V8所用的技术)执行JavaScript,因为V8团队一直在努力跟上新的JavaScript语言特性,而这些特性需要优化。

这意味着V8整体下一步会有更简单和更易维护的架构。

在Web和Node.js基准测试上的提升

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

最后,这里有一些关于如何编写良好优化、更佳的JavaScript的诀窍。当然,从上面的内容不难得到这些诀窍,不过,为了方便起见,这里还是给出一个摘要:

如何编写优化的JavaScript

  1. 对象属性的顺序:始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码。
  2. 动态属性:在实例化后向对象添加属性会强制修改隐藏类,减慢为之前的隐藏类优化了的方法。所以应该在构造函数中指定对象的所有属性。
  3. 方法:重复执行相同方法的代码将比只执行一次的代码(由于内联缓存)运行得快。
  4. 数组:避免键不是增量数字的稀疏数组。元素不全的稀疏数组是一个哈希表,而访问这种数组中的元素更昂贵。另外,尽量避免预分配大数组。最好随着发展而增长。最后,不要删除数组中的元素。它会让键变得稀疏。
  5. 标记值:V8用32位表示对象和数字。它用一位来判断是对象(flag = 1)还是整数(flag=0)(这个整数称为SMI(SMall Integer,小整数),因为它是31位)。然后,如果一个数值大于31位,V8将会对数字装箱,将其转化为 double,并创建一个新对象将该数字放在里面。所以要尽可能使用31位有符号数字,从而避免昂贵的转换为JS对象的装箱操作。

我们在SessionStack中试图在编写高度优化的JavaScript代码中遵循这些最佳实践。原因是一旦将SessionStack集成到产品web应用程序中,它就开始记录所有内容:所有DOM更改、用户交互、JavaScript异常、栈跟踪、失败的网络请求和调试消息。用SessionStack,您可以将Web应用中的问题重放为视频,并查看用户发生的一切。而所有这些都是在对您的web应用程序的性能不会产生影响的情况下发生的。

资源