Javascript是如何工作的(02) - 在V8引擎中,优化代码性能的五个小诀窍

第一篇文章重点介绍了引擎相关的内容,运行时(RunTime)和调用栈(Call Stack). 本篇文章主要探索V8引擎内部结构。 当然,本文也会给大家提供几个优化代码的小提示。

简介

JavaScript引擎是一个程序,或者称之为解析器,用于执行JavaScript代码。JS引擎可以独立的当做工具使用,也可以在一些情况下当做实时编译器,将JS代码编译成字节码(bytecode)。

下面介绍几个比较流行的项目,用于实现JS引擎:

  • V8 -- Google用C++开发的开源项目
  • Rhino -- Mozilla维护的开源项目,完全用JAVA编写
  • SpiderMonkey -- 第一个JavaScript引擎, 著名的Netscape Navigator提供的,现在由Firefox提供支持。
  • JavaScriptCore -- 由苹果开发的开源项目,用于safari浏览器
  • KJS -- 最早由 Harri Porten开发,用于KDE项目中Konqueror浏览器。
  • Chakra(JScript9) -- Internet Explorer浏览器使用
  • Chakra(JavaScript)-- Microsoft Edge
  • Nashorn -- OpenJDK项目的一部分
  • JerryScript -- 一个用于互联网项目的轻量级引擎

V8引擎为何而生?

就像上面提到的,V8引擎是由Google开发的开源项目,它是用C++写的。 V8用于Chrome浏览器中。和其他引擎不一样的是,V8引擎也用于Nodejs的运行时。

 

V8最早的设计初衷是提升浏览器中JS代码的执行效率。为了获得更好的速度,V8会直接将JS代码编译成机器码,从而提升效率。V8引擎编译好的机器码可以直接被JIT工具执行,类似于其他流行引擎一样(SpiderMonkey or Rhino). 最大的不同是,V8不需要中间的字节码,也不需要任何的中间代码。

V8过去有两个编译器

在5.9版本以前,V8引擎包含两个编译器:

  • full-codegen -- 一颗快速轻量的编译器用于生产简单但是低效的机器码
  • Grankshaft -- 一个比较复杂的,经过优化的编译器,用于生产高效的机器码

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

  • 主线程完成你想做的事情:获取代码,编译,执行
  • 还有一个分支线程用于编译,所以主线程在分支线程优化代码的同时可以继续运行。
  • 一个用于分析的线程将会告诉运行时哪些方法占用了很多时间,这样Crankshaft可以去优化他们。
  • 还有一些线程用于垃圾回收和清扫。

当一开始执行JS代码的时候,V8利用full-codegen吧JS代码直接转换成机器码,但是不做其他操作。 这样可以保证可以快速的执行代码。注意:V8不会使用中间的字节码,也不需要中间的解释器。

当你的代码运行了一段时间后, 分析线程已经收集了足够的数据,它可以知道哪些方法需要进行优化。

最后,Crankshaft优化程序在另一个线程开始。 它将会将JS抽象语法树(abstract syntax tree)编译成一个高级的静态单赋值形式SSA。

Next, Crankshaft optimizations begin in another thread. It translates the JavaScript abstract syntax tree to a high-level static single-assignment (SSA) representation called Hydrogen and tries to optimize that Hydrogen graph. Most optimizations are done at this level.

 

嵌入

优化的第一步就是尽量多的嵌入代码。嵌入是指替换调用地址(function被调用的位置)为函数体的本身。这一步将会使接下来的优化更有意义。

 

隐藏类(Hidden class)

JS是一门基于原型的语言:并不会通过克隆程序创建类和对象。 JS也是一门动态规划语言,也就是说其属性可以再实例化之后很容易的被增加和删除。

大部分JS解析器使用类字典(通常是通过hash function)的数据结构来在内存中存储对象的属性值。这使得JS在获取属性值时的计算成本比起非动态规划语言来将异常的昂贵。在JAVA中,所有的对象属性在编译以前就已经被固定,并且在运行时不可以增加和删除。从而属性值可以再内存中使用连续的空间来是存储。可以通过偏移量很容易的判断属性类型,然而,在JS中这是不可能是想的,因为JS的属性类型在运行时是可以被改变的。

由于使用字典的方式来在内存中寻找对象属性是非常低效的,从而V8使用了不同的方法:隐藏类(hidden class). 隐藏类和类的那种固定结构非常相似,和JAVA差不多,不一样的是这些来是在运行时创建的,而不是将编译之前。 让我们看看它实际上是什么样的:

function Point(x, y) {

      this.x = x;

      this.y = y;

}

var p1 = new Point(1, 2);

一旦 “new Point(1,2)”被调用,V8将会创建一个隐藏来,名为“C0”.

 

此时没有属性被定义,因此 “C0”是空的。

当第一个声明“this.x = x”被执行,V8将会基于“C0”创建另一个隐藏类,我们称之为“C1”。 “C1”描述了属性x的内存地址。在这个例子中,x基于C0的偏移量是0. 也就是说,当把一个Point对象看做在内存中是一组连续缓存的话,偏移量0的位置就是属性“x”。当属性x被添加到对象时,V8将会通过“类过渡”更新“C0”到“C1”。如此这个Point对象的隐藏类变为“C1”。

每当给对象增加一个属性的时候,旧的隐藏类会被过渡到新的隐藏类。隐藏类的过渡相当重要,因为他们允许对象之间使用相同的隐藏类。如果两个对象公钥了一个隐藏类,并且被添加了相同属性, 过渡器就会全然这两个对象接受了相同的新增隐藏类,所有的优化代码将会适用在他们身上。

当“this.y = y”被执行时,这个流程将会被重复。

一个名为“C2”的新的隐藏类被创建,当一被添加到Point对象时,一个类的过渡被添加到“C1”,然后隐藏类变为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先被添加,对于p2来讲属性b先被添加。因此p1和p2最终的隐藏类是不同的,因为他们有不同的过渡路径。类似这种情况,按照相同的顺序动态的初始化属性值,隐藏类才可以被复用。

内置高速缓存(Inline Cache)

V8采用了另一种优化动态强类型语言的特性 - 内置高速缓存(Inline Cache). 内置缓存依赖于观察在相同类型对象相同方法的重复调用。 更深入的解释可以产看这里

我们将会讨论一下内置缓存概念(以防你没有时间去深入理解内置缓存)。

ok,它到底是如何工作的呢? V8维护了对象类型的缓存,用于在当前方法中传参,并且架设将来其类型是相同不变的,以便于用于未来的方法。如果V8的对类型的假设是正确的,V8将会跳过访问对象属性,取而代之的是使用上次查找到的隐藏类的信息。

那么,隐藏类和内置缓存有什么关系呢?

当一个方法在一个特殊的对象上调用的时候,V8引擎必须在隐藏类上执行一个查找操作,通过查找,确定当前指定的属性的偏移量。经过两次相同方法的调用,并且访问的是相同的隐藏类,V8将会跳过隐藏类的查找,并将这个属性的偏移量增加到对象指针的位置。 从而在将来的调用中,V8可以直接从内存访问这个属性的内存地址。 这极大的提高了执行效率。

这也是为什么相同类型的对于下对象共享相同的隐藏类。如果你想上面例子一样创建了两个相同类型不同隐藏类的对象,V8是不能使用内置高速缓存的,因为尽管两个对象是相同的类型,但是他们的隐藏类的属性的offset不同。因此也无法将这个属性的offset添加到对象的指针位置。

这两个对象基本一致,但是属性a和属性b的位置是不同的

编译成机器码

一旦氢图(Hydrogen graph or SSA)被优化完成,Grankshaft 将其将为低等级的表示方法“锂(Lithium)”。 大多数锂的实现是面向特定系统结构的(architecture-specific)。暂存器配置出现在这一层。

最后,锂被编译成机器码。然后经过OSR:堆栈上替换(on-stack replacement)。在我们开始编译并且优化一个显而易见的长期运行的方法时,我们很可能正在运行它。 V8不会忘记这个执行起来很慢的方法,也不会重新开始优化,V8将会改变已有的内容(堆栈,寄存器),从而我们可以切换到执行到中间的优化版本。 这是一项极其复杂的任务,V8已经在最初内嵌了这些代码,V8也不是未能能干这件事情的引擎。

V8有一种安全机制用于逆优化代码,这个机制将代码恢复到未优化的状态,以防止引擎的一些假设不在正确。

垃圾回收

对于垃圾回收,V8使用了一宗传统的方法mark-and-sweep来清理旧的内容。标记的时候会阻断JS的执行。为了控制垃圾回收时的花费,并且想让代码执行起来更加的稳定,V8使用了逐步标记(incremental marking): 只检测可能会被标记的对象,而不是遍历整个堆,这样一来只遍历了堆的一部分,然后继续代码的执行。 下一个垃圾回收的开始点将会是上一个遍历堆结束的位置。这样运行在代码执行挡住只花费非常短的暂停时间。正如我们之前提到的,垃圾的清理将会在隔离的线程中处理。

点火装置 和 涡轮加速(Ignition and TurboFan)

随着V8 5.9版本的发布,一个新的执行管线被介绍出来。 这个新的管线的实现带来了极大的性能提升和内存节省。

 

这个新的管线建立在V8的解释器,点火装置 & 涡轮装置之上 :  其实就是V8最新经过优化编译器。

 

Ignition and TurboFan

自从5.9版本问世以后, V8团队为了努力紧跟新的JS语言特性以及支持这些特性的优化方案,full-codegen 和 Grankshaft(自从2010年开始就开始支撑V8)就不再被使用了。

这也就意味着,V8在未来会变得加简化,可维护。

Improvements on Web and Node.js benchmarks

这些提升仅仅是一个开端。新的基于点火装置和涡轮装置的管道工艺为将来的优化已经做好了铺垫,它将会在接下来的几年中,驱动JS性能的提升,以及缩小V8的体积,不管是在Chrome还Node.js中。

最后,让我们总结一下书写比较好被引擎优化的代码技巧。上面所讲的也很容易被理解,但是我们最后仍然做一下总结。

如何书写优化的JS代码

  1. 对象属性排序: 永远的让属性的初始化保持相同的顺序,从而隐藏类会被共享,接下来的优化代码也会被共享。
  2. 动态属性:在一个对象初始化之后增加属性,这将使引擎新创建一个隐藏类,所有的之前优化过的隐藏类将会失效,这样会使的运行速度变慢。 所以,在对象初始化的时候,定义好所有的变量。
  3. 方法:代码去执行可以重复的方法要比只去执行唯一的方法要快一些,尤其是哪些只被使用一次的方法,会变的比较慢(源于内置缓存的作用)
  4. 数组:避免使用稀疏数组(数组的键值不连续递增)。这样的数组其实是一个哈希表,所以去访问里面的元素会变的成本很大。 也不要预分配过大的数组,最好是随着使用增大, 最后,不要删除数组中的数据,这样会造成键值的稀疏。
  5. 标记值:V8用32比特位表示对象和数字。 他们会使用1比特位去了解是对象还是数字(0:整数,1:对象),这样的整数也称作小整数(SMall Interger / SMI),因为它最大是31比特位。因此如果一个数字大于31比特位,V8将会封装这个数字,把它转换成一个双浮点数,并且创建一个新对象来存储它。尽量使用31比特位以内的数字,防止复杂高成本的封装操作

 

阅读数 9290