NodeJs-V8引擎的特性和认知

即时编译

V8采用即时编译技术(JIT),直接将JavaScript代码编译成本地平台的机器码。宏观上看,其步骤为JavaScript源码—>抽象语法树—>本地机器码,并且后一个步骤只依赖前一个步骤。 这与其他解释器不同,例如Java语言需要先将源码编译成字节码,然后给JVM解释执行,JVM根据优化策略,运行过程中有选择地将一部分字节码编译成本地机器码。 V8不生成中间代码,一步到位,编译成机器码,CPU就开始执行了。比起生成中间码解释执行的方式,V8的策略省去了一个步骤,程序会更早地开始运行。并且执行编译好的机器指令,也比解释执行中间码的速度更快。不足的是,缺少字节码这个中间表示,使得代码优化变得更困难。

隐藏类

首先我们看一下C++/Java这种静态类型语言的每一个变量,都有一个唯一确定的类型。因为有类型信息,一个对象包含哪些成员和这些成员在对象中的偏移量等信息,编译阶段就可确定,执行时CPU只需要用对象首地址 —— 在C++中是this指针,加上成员在对象内部的偏移量即可访问内部成员。这些访问指令在编译阶段就生成了。 但对于JavaScript这种动态语言,变量在运行时可以随时由不同类型的对象赋值,并且对象本身可以随时添加删除成员。访问对象属性需要的信息完全由运行时决定。为了实现按照索引的方式访问成员,V8“悄悄地”给运行中的对象分了类,在这个过程中产生了一种V8内部的数据结构,即隐藏类。隐藏类本身是一个对象。 隐藏类起到给对象分组的作用。同一组的对象,具有相同的成员名称。隐藏类记录了成员名称和偏移量,根据这些信息,V8能够按照对象首地址+偏移量访问成员变量。

内联缓存

上面讲到,借助隐藏类,可以使用数组索引的方式存取对象成员。但成员的索引值是以哈希表的方式存储在隐藏类中。如果每次访问属性都搜寻隐藏类的哈希表,那么这种使用偏移量的方式不会带来任何好处。 内敛缓存是基于程序运行的局部性原理,动态生成使用索引查找的代码。下一次存取成员变量就不必再去搜寻哈希表。

优化回退

V8 为了进一步提升JavaScript代码的执行效率,使用了Crankshaft编译器生成更高效的机器码。程序在运行时,V8会采集JavaScript代码运行数据。当V8发现某函数执行频繁,就将其标记为热点函数。针对热点函数,V8的策略较为乐观,倾向于认为此函数比较稳定,类型已经确定,于是调用Crankshaft编译器,生成更高效的机器码。后面的运行中,万一遇到类型变化,V8采取将JavaScript函数回退到优化前的较一般的情况。

1
2
3
4
5
6
7
function add(a, b){
return a + b
}
for(var i=0; i<10000; ++i){
add(i, i);
}
add('a', 'b');

上述代码在执行for循环的过程中,每次调用add()函数,传入的参数是整型,运行一定次数后,V8可能把这个函数标记为热点函数,并根据每次运行传入的参数预测,此函数的参数a、b为整型。于是调用Crankshaft编译器生成相应的代码。但当循环退出,执行字符串想加时,V8只好将函数回退到一般状态。回退过程就是根据函数源码,生成相应的语法时,然后编译成一般形式的机器码。可以预见这个过程是比较耗时的,并且放弃了优化后的代码去执行一般形式的代码,因此要尽量避免触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 片段 1
var person = {
add: function(a, b){
return a + b;
}
};
obj.name = 'li';

// 片段 2
var person = {
add: function(a, b){
return a + b;
},

name: 'li'
};

以上代码实现的功能相同,都是定义了一个对象,这个对象具有一个属性name和一个方法add()。但使用片段2的方式效率更高。片段1给对象obj添加了一个属性name,这会造成隐藏类的派生。给对象动态地添加和删除属性都会派生新的隐藏类。假如对象的add函数已经被优化,生成了更高效的代码,则因为添加或删除属性,这个改变后的对象无法使用优化后的代码。 上面的优化回退的例子也启示我们,函数内部的参数类型越确定,V8越能够生成优化后的代码。我们也要避免优化回退,例如可以再编写一个专门针对字符串想加的函数,而不是一个函数同时处理整型和字符串。

  • 版权声明: 本博客所有文章,未经许可,任何单位及个人不得做营利性使用!转载请标明出处!如有侵权请联系作者。
  • Copyrights © 2015-2020 翟天野

请我喝杯咖啡吧~