kaola-fed / blog

kaola blog

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

【译】JavaScript是如何工作的:深入V8引擎 + 5个优化代码的建议

mikeCodemikeLife opened this issue · comments

JavaScript是如何工作的:深入V8引擎 + 5个优化代码的建议

两周前我们开始了理解Javascript及其如何工作的研究:我们认为如果了解了JavaScript的构造模块和这些模块是如何一起工作的,这将会帮助我们写出更好的代码和应用。

此系列的第一篇文章意在提供一个JavaScript引擎,运行时间和调用栈的预览。这篇文章将会深入Google v8引擎。我们也会提供一些关于写出更好JavaScript代码的建议 —— 来自我们开发团构建SessionStack的最佳实践。

预览

一个JavaScript引擎是执行JavaScript代码的程序或者解释器。关于JavaScript引擎的实现,你可以用一个标准的解释器实现,也可以实时编译 —— 将JavaScript代码编译成某种字节码。

这是目前热门的JavaScript引擎列表:

  • V8 —— 开源,Google出品,C++实现
  • Rhino ——开源,由Mozilla Foundation管理,完全由Java开发
  • SpiderMonkey —— 第一代JavaScript引擎,以前是Netscape(一首凉凉),现在由FireFox维护
  • JavaScriptCore——开源,由Apple为safari开发
  • KJS——KDE的引擎最初是由Harri Porten开发的,用于KDE项目的Konqueror web浏览器
  • Chakra(JScript9)——Internet Explorer
  • Chakra(JavaScript)——Microsoft Edge
  • Nashorn——作为OpenJDK的一部分开源,由Oracle Java语言和工具组编写
  • JerryScript——是物联网的轻量级引擎

为什么要创建V8引擎?

V8引擎是开源的,由Google公司通过C++实现。V8引擎不但被使用在Chrome内,而且在node中也同样被使用。
V8在初次设计时是为了提高JavaScript在浏览器的执行性能。为了提高执行速度,V8将JavaScript代码翻译成了更高效的机器码而不是交给解释器。它通过实现一个即时编译器来把JavaScript代码编译成机器码,就像当前很多JavaScript引擎一样,例如:SpiderMonkey或者Rhino(Mozilla)。和其他引擎不同的是V8不产生其他的字节码或者任何中间代码。

V8曾经有两种编译器

在V8的5.9版本之前,V8使用过两个编译器:

  • full-codegen —— 一个简单快捷的编译器,它编译出了简单并相对缓慢的机器代码
  • Crankshaft —— 一个更复杂(即时)的优化编译器,它会优化部分代码

V8引擎还在内部使用了几类线程

  • 主线程当然做你所期待的那些:获取代码,编译,执行
  • 还有一个单独的线程也在编译,他会持续优化代码以保证主线程的执行和效率
  • 分析线程会在运行时监听哪个方法运行花费了大量的时间,并且通知Crankshaft优化
  • 还有部分线程会处理垃圾回收

当第一次执行JavaScript代码,V8引擎利用full-codegen直接把解析过的JavaScript代码编译成机器代码,并且没有任何中间转换。这使得可以很快速的执行机器代码。注意V8引擎并没有使用任何中间字节码,这代表这种方式的执行并不依赖任何解释器。

当代码运行一段时间后,解析器已经收集了足够多的数据,并且找出哪个方法需要被优化。

接下来,Crankshaft优化器在另一个线程开始了。它首先把AST(抽象语法树)转换成高级的中间表示Hydrogen(SSA-静态单赋值),然后试图优化Hydrogen图。大多数的优化发生在此阶段。(译者注:SSA概念

内联

第一个优化就是预先内联尽可能多的代码。内联就是用函数主体替换调用此函数的调用点的过程。这简单的一步使接下来的优化变的更有意义。

内联示意图

隐藏类

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"执行,V8将会创建第二个以"C0"为基础的,叫做"C1"的隐藏类。"C1"描述了属性X在内存中(相对于对象指针)的位置。在这种情况下,"x"被存储在偏移量为0的位置上,这意味着当将内存中的一个Ponit对象视为连续缓冲区时,第一个偏移量将对应于属性"x"。V8还将使用一个“类转换”更新“C0”,它声明如果将一个属性“x”添加到一个点对象中,隐藏的类应该从“C0”切换到“C1”。下面的point对象的隐藏类现在是“C1”。

每当一个新属性被添加到一个对象时,旧的隐藏类就会被更新到新的隐藏类的转换路径中。隐藏类转换非常重要,因为它们允许在创建相同方法的对象之间共享隐藏的类。如果两个对象共享了一个隐藏的类,并且将相同的属性添加到这两个对象中,那么这种类转换将确保两个对象都收到相同的新隐藏类和随之而来的所有优化的代码.

当“this.y = y”执行时,上面的这个过程会重复。

一个新的隐藏类"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不会使用内联缓存,因为即使两个相同类型的对象,相应的隐藏类也会为属性分配不同的偏移量。

编译到机器码

一旦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应用程序中实现了更大的性能改进和显著的内存节省。

新的执行管道是在V8的解释器Ignition,和V8最新的优化编译器TurboFan的基础上建立的。

点击这里可以查看更多V8团队中关于这个主题的博客文章。

自从5.9版本的V8推出以来,V8引擎已经不再使用full-codeget 和 Crankshaft(自2010年以来一直为V8提供服务的技术),因为V8团队一直在努力跟上新的JavaScript语言特性和这些特性所需要的优化。

这意味着整体V8将会有更简单、更易于维护的架构。

这些改进仅仅是个开始。新的Ignition和TurboFan管道为进一步的优化铺平了道路,这将在未来几年内提高JavaScript的性能,并缩小在Chrome和Node上的V8的内存占用。

最后,这里有一些关于如何编写优化的、更好的JavaScript的技巧和技巧。你可以很容易地从上面的内容中推导出这些内容,但是为了方便,这里还是有一个便于你使用的总结:

如何编写优化的JavaScript

  • 对象属性的顺序:总是以相同的顺序实例化您的对象属性,这样就可以共享隐藏类,以及随后的优化代码。
  • 动态属性:在实例化之后给对象添加属性会强制隐藏的类发生变化,并减慢为前一个隐藏类优化的任何方法。所以,应该在它的构造函数中分配所有对象的属性。
  • 方法:重复执行相同方法的代码将比只执行许多只执行一次方法的代码运行得更快(由于内联缓存)。
  • 数组:避免使用键不是增量数字的稀疏数组。稀疏数组中如果元素不全,它就是个哈希表了。此类数组中的元素访问成本更高。另外,尽量避免预分配大数组。随着需求慢慢增长最好。最后,不要删除数组中的元素,它会使键变的稀疏。
  • 标记值:V8是用32位来表示对象和数字的。它使用一位来确定它是否是一个对象(flag=1)或者一个整数(flag=0)(因此它就只有31位表示了,所以叫SMall Integer -- 小正数)。然后,如果数字值大于31位,V8会将这个数字装箱,将其转换为double,并创建一个新的对象来将数字放入其中。尽量使用31位有符号的数字,以避免将昂贵的转换。

作者:Alexander Zlatkov

编译:mike

英文原文: How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code