V8引擎简介

1,758 阅读12分钟

上一篇(JS引擎、运行时与调用栈概述)主要讲了JS引擎、运行时与调用栈的概述。本篇文章将会深入到谷歌V8 JavaScript引擎的内核部分。我们也会提供一些怎样写出更好的JavaScript代码的建议。

概览

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

以下是一个比较流行的实现JavaScript引擎的项目列表:

  • V8--开源,由谷歌开发,用C++编写
  • Rhino--由Mozilla Foundation管理,开源,完全用Java开发
  • SpiderMonkey--第一个JavaScript引擎,以前用于Netscape浏览器,现在用于FireFox浏览器
  • JavaScriptCore--开源,作为Nitro销售,由Apple为Safari开发
  • KJS--KDE的引擎,最初由Harri Porten为KDE项目的Konqueror web浏览器开发
  • Chakra(JScript9)--Internet Explorer
  • Chakra(JavaScript)--Microsoft Edge
  • Nashorn--作为OpenJDK的一部分开源,由Oracle Java Languages and Tool Group 编写
  • JerryScript--一个物联网轻量引擎

为什么要创建V8引擎?

由谷歌构建的V8引擎是用C++编写的开源项目,用于谷歌Chrome内部。然而不像其他引擎,V8也被用于流行的Node.js运行时。

V8最开始是为了提高运行在浏览器内部的JavaScript运行性能而设计的。为了提高速度,V8将JavaScript代码转换成更有效率的机器码,而不是使用一个解释器。就像其他一些JavaScript引擎比如SpiderMonkey或Rhino (Mozilla)所做的一样,V8实现了一个即时(JIT)编译器在代码执行时将JavaScript代码编译成机器码。这里最主要的区别是V8不生成字节码或其他中间代码。

V8以前有两种编译器

在V8 5.9版本出来之前,引擎使用了两种编译器:

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

V8引擎内部也使用了几个线程:

  • 主线程所做的事情就是你所期待的那样:获取你的代码,编译它然后执行它
  • 也存在另一个线程用来编译,那样当前面正在优化代码的时候,主线程可以继续执行
  • 性能分析线程告诉运行时哪些方法消耗了很多时间,那样Crankshaft就能去优化它们了
  • 一些处理垃圾回收的线程

最初执行JavaScript代码的时候,V8使用full-codegen直接将JavaScript转换成机器码而没做任何转化,这让引擎能很快开始执行代码。值得注意的是V8不使用中间字节码,那就不需要一个解释器了。

当你的代码运行一段时间后,性能分析线程收集到了足够的数据来告知哪些方法需要优化。

接下来,Crankshaft开始在另一个线程优化了。它将JavaScript抽象语法树转换成一个叫做Hydrogen的高阶静态单赋值形式并试图优化这个Hydrogen图,大多数优化都是在这个阶段进行的。

内联

第一步优化是提前内联尽可能多的代码啊。内联是将调用地址(函数被调用的代码行)替换成被调用的函数体。这一简单的步骤是接下来的优化更有意义。

隐藏的类(Hidden class)

JavaScript是一门基于原型的语言:没有类,对象是使用克隆来构造的。JavaScript是一门动态编程语言,意味着属性可以在对象初始化完成后很容易的被添加或已移除。

大多数JavaScript解释器使用类字典结构(基于哈希函数)在内存中存储对象属性值的位置。这个结构使得在JavaScript中检索一个属性的值比在其他非静态语言如Java或C#需要更多的计算。在Java中,所有的对象属性在编译前就已经被一个固定的对象决定了,而且不能在运行时被动态添加或删除(好吧,C#有一个动态类型,而这是另外一个话题了)。结果是,属性值(或者指向那些属性的指针)可以存储在内存中一个连续的缓冲区中,彼此之间偏移量是固定的。这个偏移的长度根据属性的类型可以很容易的确定,然而在属性类型在运行时可以改变的JavaScript中是不可能的。

因为使用字典在内存中查找对象属性的位置是很低效的,V8使用一个不同的方法来代替:隐藏类(hidden classes)。隐藏类很像在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就会创建第二个叫做“C1”的基于“C0”的隐藏类。“C1”描述属性x在内存中的位置(关联对象指针)。在这里,“x”存储在偏移量为0的位置上,这意味着当把point对象在内存中看作连续的缓冲区,第一个偏移量指向的是属性“x”。V8也会用“类转换”来更新“C0”,表明如果属性“x”被加入到point对象中,隐藏类就会从 “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利用另一项叫做内联缓存的技术来优化动态类型语言。内联缓存依赖于发生在相同对象类型上的相同方法的重复调用。一个内联缓存的深度解释可以在这里找到。

我们将会说一下内联缓存的通用概念(以防你没有时间去看上面的深度解释)。

内联缓存是如何工作的呢?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 and TurboFan

在2017年发布的V8 5.9版本,引进了一个新的执行管道。这个新的管道在真实的JavaScript项目中取得了更加大的性能改善和显著的内存节省。

这个新的管道构建在Ignition, V8的解释器和TurboFan,V8最新的优化编译器的顶上。

你可以在这里看到V8团队关于这个主题的博客。

自从V8的5.9版本发布依赖,full-codegen和Crankshaft(自从2010年就开始服务于V8的技术)就不再被V8作为JavaScript的执行所使用,因为V8团队需要尽力跟上新的JavaScript语言特性和满足这些特性的优化需要。

这意味着总体上V8在未来将会拥有简单的多和可维护度更高的体系结构。

在Web和node.js基准线上的改善

这些改善只是开始。新的Ignition和TurboFan管道为将来的优化铺好了道路,这样会大幅度提高JavaScript的性能,使V8在接下来的许多年在Chrome和Node.js上踩下更坚实的足迹。

最后,这里有一些怎样写出良好优化的更好的JavaScript的一些建议和技巧。你可以很容易根据上面的内容得出这些结论,这里只是为了你的方便,总结一下:

怎样写出性能优化的代码

  1. 对象属性的顺序:总是以相同的顺序实例化对象属性,那样隐藏类和接下来的优化代码能够被共享。
  2. 动态属性:在实例化后添加属性到一个对象中将会强制改变隐藏类,拖慢之前为隐藏类优化过的任何方法。取而代之,在构造函数中为对象所有的属性赋值。
  3. 方法:重复运行相同方法的代码会比每次运行不同的方法快一些(因为内联缓存)
  4. 数组:避免使用key值不是递增数字的稀疏数组。不是每个元素都在内部的稀疏数组是一个哈希表。这种数组的元素需要消耗更多资源才能访问到。也要避免提前分配大数组,最好是用到才分配。最后,不要删除数组的元素,这样会让key变得稀疏。
  5. 标签值:V8用32位来表示对象和数字。它用一位来表示是一个对象(flag = 1)或一个数字(flag = 0),被称作是SMI(SMall Integer),因为它的31位。然后,如果一个数字值比31为要大,V8将会对number进行装箱,将它转换成一个double类型,创建一个新对象来把它放进去。在任何时候试着使用31位有符号数字来避免很昂贵的进入一个JS对象的装箱操作。

本文翻译自:blog.sessionstack.com/how-javascr…