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

概述

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

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

  • V8 — 开源,由Google开发,用C++编写的
  • Rhin o — 由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的运行时。github

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

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 的第二个隐藏类 C1 。 C1 描述了内存中的位置(相对于对象指针),属性 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;

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

内联缓存

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应用程序的性能不会产生影响的状况下发生的。

相关文章
相关标签/搜索