JavaScript工作机制(二)

JavaScript工作机制(二)

概述

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

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

V8 — 开源,由Google开发,用C++编写的
Rhino — 由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的运行时。V8最初是设计用来提升Web浏览器中JavaScript执行的性能。为了获得速度,V8将JavaScript代码转化为更高效的机器码,而不是使用解释器。它通过实现像很多现代JavaScript引擎(spiderMonkey或Rhino)所用的JIT编译器,从而将JavaScript代码编译成机器码。这里主要区别在于V8不会产生字节码或任何中间代码

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中,所有对象属性都是由编译前的固定对象布局确定的,并且不能在运行时动态添加或删除。因此,属性的值(或指向这些属性的指针)可以在内存中存为连续缓冲区,每个缓冲区之间有固定偏移量。偏移量的长度可以很容易根据属性类型来确定。而在JavaScript中,这是不可能的,因为属性类型可能会在运行期间发生变化。

由于用字典来查找内存中对象属性的位置是非常低效的,所以V8使用了不同的方法来替代:隐藏类。隐藏类的工作机制类似于像Java这样的语言中使用的固定对象布局(类),只不过隐藏类是在运行时创建的。

例子:

1
2
3
4
5
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2)

一旦new Point(1, 2)调用发生了,V8就会创建一个称为C0的隐藏类。

因为还没有给Point定义属性,所以CO为空。

一旦执行了第一条语句this.x = x (在Point函数中),V8就会创建一个基于C0的第二个隐藏类C1。C1描述了内存中的位置(相对于对象指针),属性X在这个位置可以找到。此时,x存储在偏移0处,就是说,当将内存中的point对象作为连续缓存器来查看时,第一个偏移就对应属性x。V8也会用“类转换”来更新C0,指出如果将一个属性X添加到点对象,那么隐藏类应该从C0切换到C1。下面的point对象的隐藏类现在是C1

每当向对象添加一个新属性时,旧的隐藏类就被用一个转换路径更新为新的隐藏类。隐藏类转换很重要,因为它们可以让隐藏类在以相同方式创建的对象之间共享。如果两个对象共享一个隐藏类,并且将相同的属性添加到这两个对象中,那么转换会确保两个对象都收到相同的新隐藏类和它附带的所以优化过的代码。

当执行语句this.y = y时,会重复此过程。
这时,又创建一个名为C2的新隐藏类,类转换被添加到C1,表示如果将属性y添加到Point对象(已包含属性x),那么隐藏类应更改为C2,同时point对象的隐藏类被更新为C2。

隐藏类转化取决于将属性添加到对象的顺序。如下:

1
2
3
4
5
6
7
8
9
10
11
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.a = 7;
p2.b = 8;

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

内联缓存

V8利用另一种称为内联缓存的技术来优化动态类型语言。内联缓存来自于观察的结果:对同一方法的重复调用往往发生在同一类型的对象上。

下面我们打算谈谈内联缓存的一般概念:

V8维护在最近的方法调用中作为参数传递的对象类型的缓存,并使用该信息对将来作为参数传递的对象类型做出假设。如果V8能够对传递给方法的对象类型做出一个很好的假设,那么它可以绕过算出如何访问对象的属性的过程,转而使用先前查找对象的隐藏类时所存储的信息。

那么隐藏类和内联缓存的概念是如何关联的呢?无论何时在特定对象上调用方法,V8引擎必须对该对象的隐藏类执行查找,以确定访问特定属性的偏移量。在对同一个隐藏类的同一方法进行了两次成功的调用之后,V8就省略掉了隐藏类的查找,只将属性的偏移量添加到对象指针本身上。对于所以将来对该方法的调用,V8引擎都会假设隐藏类没有改变,并使用先前查找中存储的偏移量直接转到特定属性的内存地址。这会大大提高执行速度。

内联缓存也是为什么同一类型的对象共享隐藏类非常重要的原因。如果您创建相同类型的两个对象,但是用的是不同的隐藏类,那么V8将无法使用内联缓存,因为即使两个对象的类型相同,但是它们的对应隐藏类也会为其属性分配不同的偏移量。

编译到机器码

一旦Hydrogen图被优化,Crankshaft将其降低到一个称为Lithium的较低级别表示。大多数Lithium实现都是针对架构的。寄存器分配发生在这一级。

最后,Lithium被编译成机器码。然后其它事情,也就是OSR发生了。在我们开始编译和优化一个明显要长期运行的方法之前,我们可能会运行它。V8不会蠢到忘记它刚刚慢慢执行的代码,所以它不会再用优化版本又执行一遍,而是将转换所有已有的上下文(栈,寄存器),以便我们可以在执行过程中间就切换到优化版本。这是一个非常复杂的任务,请记住,除了其它优化之外,V8最开始时已经内联了代码。V8并非唯一能做到这一点的引擎。
有一种称为去优化的保护措施,会做出相反的转换,并恢复为非优化的代码,以防止引擎的假设不在成立。

垃圾回收

对于垃圾回收来说,V8采用的是标记,清扫这种传统方式来清除旧一代。标记阶段应该停止执行JavaScript。为了控制GC成本,并使执行更加稳定,V8使用增量式标记:不是遍历整个堆,尝试标记每一个可能的对象,而是只遍历一部分堆,然后恢复正常执行。下一个GC停止会从之前的堆遍历停止的地方继续。这就允许在正常执行期间有非常短的暂停。如前所述,清扫阶段是由单独的线程处理。

如何编写优化的JavaScript

  1. 对象属性的顺序:始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后的优化的代码。
  2. 动态属性:在实例化后向对象添加属性会强制修改隐藏类,减慢为之前的隐藏类优化了的方法。所以应该在构造函数中指定对象的所以属性。
  3. 方法:重复执行相同方法的代码将比只执行一次的代码(由于内联缓存)运行的快。
  4. 数组:避免键不是增量数字的稀疏数组。元素不全的稀疏数组是一个哈希表,而访问这种数组中的元素更昂贵。另外,尽量避免预分配大数组。最好随着发展而增长。最后,不要删除数组中的元素。它会让键变得稀疏。
  5. 标记值:V8用32位表示对象和数字。它用一位来判断是对象(flag=1)还是整数(flag=0)。然后,如果一个数值大于31位,V8将会对数字装箱,将其转化为double,并创建为一个新对象将该数字放到里面。所以要尽可能使用31有符号数字,从而避免昂贵的转化为js对象的装箱操作。