几周之前,我们开始了一系列旨在深入挖掘JavaScript及其实际工作原理的文章:我们认为通过了解JavaScript的构建块以及它们如何共同发挥作用,你将能够编写更好的代码和应用程序。
第一篇文章集中于提供引擎,运行时和调用堆栈的概述。第二篇文章将深入探讨谷歌V8 JavaScript引擎的内部。我们还将提供一些关于如何编写更好的JavaScript代码的小建议—最佳实践就是我们的团队构建的SessionStack,在开发时我们就遵循了这些tips。
概述
JavaScript引擎是执行JavaScript代码的程序或解释器。 JavaScript引擎可以实现为标准解释器,或以某种形式将JavaScript编译为字节码的即时编译器。
这是一个实现JavaScript引擎的热门项目列表:
- V8—开源,由谷歌开发,C++编写
- Rhino—由Mozilla Foundation管理,开源,完全用Java开发
- SpiderMonkey—第一个JavaScript引擎,过去由Netscape Navigator管理,现在由FireFox管理
- JavaScriptCore—开源,作为Nitro销售,由Apple为Safari开发
- KJS—KDE的引擎最初是由Harri Porten为KDE项目的Konqueror Web浏览器开发的
- Chakra(JScript9)—IE浏览器
- Chakra(JavaScript)—Microsoft Edge
- Nashorn,作为OpenJDK的一部分开源,由Oracle Java Languages and Tool Group编写
- JerryScript—是一个物联网的轻量级引擎。
V8引擎是如何产生的?
由谷歌构建的V8引擎是开源的,用C ++编写。此引擎在Google Chrome中使用。然而,与其他引擎不同,V8也用于流行的Node.js运行时。
V8最初是被设计来提高JavaScript在web浏览器中执行的性能的。为了提高速度,V8将JavaScript代码转换为更高效的机器代码,而不是使用解释器。它通过实现JIT(Just-in-Time) 编译器在执行时将JavaScript代码编译成机器代码来实现的,就像大多数现代JavaScript引擎所做的那样,比如SpiderMonkey or Rhino (Mozilla)。主要区别是V8不产生字节码或任何中间代码。
V8曾经有两个编译器
- full-codegen— 一个简单而快速的编译器,可以生成简单且相对较慢的机器代码。
- Crankshaft - 一种更复杂的(即时)优化编译器,可生成高度优化的代码。
- 主线程完成你的期望:获取代码,编译代码然后执行它
- 还有一个单独的线程用于编译,以保证主线程可以在前者(应该指的是Crankshaft )优化代码的同时能够继续执行。
- 一个Profiler线程,它将告诉运行时哪一个方法花费了大量时间,以便Crankshaft可以优化它们
- 一些线程来处理垃圾收集器的扫描。
当代码运行一段时间后,探查线程(profiler thread)已经收集了足够的数据来告诉(Crankshaft )应该优化哪个方法。
接下来, Crankshaft 优化 开启了另一个线程。它将JavaScript抽象语法树转换为名为Hydrogen的高级静态单指派(SSA)表现(a high-level static single-assignment (SSA) representation),并尝试优化氢图(Hydrogen graph)。大多数优化都是在这个级别完成的。
内联(Inlining)
第一次优化是尽可能提前内联更多的代码。内联就是一个用函数体替换函数调用点(函数被调用的代码行)的过程。这个简单的步骤使接下来的优化更有意义。
Hidden class(隐藏类)
JavaScript是一门基于原型的语言:没有类和对象是使用克隆过程创建的。JavaScript也是一种动态编程语言,这意味着在实例化后可以轻松地在对象中添加或删除属性。
大多数JavaScript解释器使用类似字典的结构(基于hash function)来存储对象属性值在内存中的位置。这种结构使得在JavaScript中检索属性的值比在Java或C#等非动态编程语言中的计算成本更高。在Java里,所有对象属性都是在编译之前由固定对象布局确定的,并且无法在运行时动态添加或删除(当然,C#也有动态类型,这又是另一个话题了。)结果是,属性值(或指向这些属性的指针)可以以两两之间有一个固定的偏移量(fixed-offset )作为连续缓冲区存储在内存中。这些偏移(offset )的长度可以根据属性类型轻松确定,而这在运行时可以更改属性类型的JavaScript中是不可能的。
由于使用字典来查找内存中对象属性的位置是非常低效的,V8使用了一个不同的方法来代替:hidden classes(隐藏类)。隐藏类的工作方式类似于Java等语言中使用的固定对象布局(类),除非它们是在运行时创建的。现在,让我们看看它们实际上是什么样的:
function(x,y){
this.x=x;
this.y=y
}
var p1 = new Point(1,2)复制代码
尚未为Point定义任何属性,因此“C0”为空。
一旦执行了第一个语句“this.x = x”(在“Point”函数内),V8将创建一个名为“C1”的第二个隐藏类,它基于“C0”。“C1”描述了可以找到属性x的存储器中的位置(相对于对象指针)。在这种情况下,“x”存储在偏移0处,这意味着当在存储中查看作为连续缓冲区存储的Point 对象时,第一个偏移将对应于属性“x”。(这句我觉得翻译可能有不准的地方,原句是:In this case, “x” is stored at offset 0, which means that when viewing a point object in the memory as a continuous buffer, the first offset will correspond to property “x”.)
V8也会用“类转换”(“class transition” )更新“C0”,该类转换指出如果将属性“x”添加到Point 对象,则隐藏类应该从“C0”切换到“C1”。下面的Point对象的隐藏类现在是“C1”。
每次将新属性添加到对象时,旧的隐藏类都会更新为新隐藏类的转换路径。隐藏类转换很重要,因为它们允许在以相同方式创建的对象之间共享隐藏类。如果两个对象共享一个隐藏类并且同一属性被添加到它们中,则转换要确保两个对象都接收相同的新隐藏类以及随其附带的所有优化代码。
执行语句“this.y = y”(在Point函数内,在“this.x = x”语句之后)时重复此过程。
一个新的隐藏类“C2”被创建了,一个新的声明了如果将属性“y”添加到Point对象(已包含属性“x”),则隐藏的类应更改为“C2”的类转换被添加给C1,并且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”以不同的隐藏类结束。在这些情况下,以相同的顺序初始化动态属性要好得多,以便可以重用隐藏的类。
内联缓存(Inline caching)
V8利用另一种技术优化动态类型语言,称为内联缓存。内联缓存依赖于观察到对相同类型的对象的重复调用倾向于发生在相同类型的对象上。
可以在这里找到有关内联缓存的深入说明。
我们将讨论内联缓存的一些基础概念(如果你没有时间浏览上面的深入解释的文章)。
那么它是怎么工作的呢?V8维持一个在最近的方法调用中作为参数传递的对象类型的缓存,并使用此信息来假设将来作为参数传递的对象类型。如果V8能够对将传递给方法的对象类型做出很好的假设,它可以绕过弄清楚如何访问对象属性的过程,而是使用先前查找中存储的信息到对象的隐藏类。
那么隐藏类和内联缓存的概念之间有什么关系呢?每当在特定对象上调用方法时,V8引擎必须执行对该对象的隐藏类的查找,以确定访问特定属性的偏移量。在将同一方法两次成功调用到同一个隐藏类之后,V8省略了隐藏类查找,只是简单地将属性的偏移量添加到对象指针本身。对于该方法接下来的调用,V8引擎假定隐藏类没有改变,然后使用先前查找中存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。
内联缓存也是为什么相同类型的对象共享隐藏类非常重要的原因。如果你创建两个相同类型且具有不同隐藏类的对象(如前面示例中所做的那样),V8无法使用内联缓存,因为即使两个对象属于同一类型,其对应的隐藏类也会为其属性分配不同的偏移量。
这两个对象基本相同,只是“a”和“b”属性创建的顺序不同。
编译到机器代码
氢图(Hydrogen graph)优化后,Crankshaft将其降低到称为锂(Lithium)的低级别表示。大多数Lithium实现都是特定于某种结构的。寄存器的分配(Register allocation)就发生在此级别。
在最后,Lithium被编译成机器代码。然后发生了一些其他的叫做OSR的事情:堆栈替换。在我们开始编译和优化一个明显长期运行的方法之前,我们可能正在运行它。V8不会忘记它只是慢慢执行以便再次使用优化版本。相反,它将转换我们拥有的所有上下文(堆栈,寄存器),以便我们可以在执行过程中切换到优化版本。这是一项非常复杂的任务,请记住,除了其他优化之外,V8最初还内联了代码。 V8并不是唯一能够做到这一点的引擎。
有一种称为去优化的保护措施可以进行相反的转换,并在引擎的假设不再适用的情况下恢复到非优化代码。
垃圾收集(Garbage collection)
在垃圾处理这一块,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的提示和技巧。你当然可以从上面的内容轻松地推导出这些内容,但是,这里有一个方便的总结:
How to write optimized JavaScript
- 对象属性的顺序:始终以相同的顺序实例化您的对象属性,以便可以共享隐藏的类和随后优化的代码。
- 动态属性:在实例化之后向对象添加属性会强制隐藏类改变并减缓为先前隐藏类优化的任何方法。因此,在其构造函数中分配所有对象的属性。
- 方法:重复执行相同方法的代码将比仅执行一次不同方法的代码运行得更快(由于内联缓存)。
- 数组:避免键不是增量数的稀疏数组。其中没有每个元素的稀疏数组是一个哈希表(hash table)。这种数组中的元素访问起来更加昂贵(费时、麻烦)。另外,尽量避免预先分配的大数组。随着需要增长其长度更好。最后,不要删除数组中的元素。这会使键变得稀疏。
- 标记值(Tagged values):V8的对象和数字都用32位来表示。它使用一个位来知道它是一个对象(flag = 1)还是一个称为SMI(SMall Integer)的31位的整数(flag = 0)。然后,如果数值大于31位,V8会将数字打包,将其变为双精度并创建一个新对象以将数字放入其中。尝试尽可能使用31位带符号的数字,以避免对JS对象进行昂贵的装箱操作。
使用SessionStack,你可以将网络应用中的问题作为视频重播,并查看用户发生的所有事情。并且所有的这一切都不会影响你的web应用性能。
这里可以免费试用。get started for free.
Resources
- https://docs.google.com/document/u/1/d/1hOaE7vbwdLLXWj3C8hTnnkpE0qSa2P--dtDvwXXEeD0/pub
- https://github.com/thlorenz/v8-perf
- http://code.google.com/p/v8/wiki/UsingGit
- http://mrale.ph/v8/resources.html
- https://www.youtube.com/watch?v=UJPdhx5zTaw
- https://www.youtube.com/watch?v=hWhMKalEicY