V8引擎原理简析

3,739 阅读10分钟

1. V8引擎的由来

引擎的定义:汽车引擎 vs JS引擎

汽车引擎:把燃料化学能转化为活塞的机械能

JS引擎:将JavaScript源码编译为计算机可执行的机器码,并执行

  • JScript(IE6,IE7, IE8)
  • Chakra(IE9,IE10, IE11, IE Edge)
  • SpiderMonkey(Firefox)
  • JavaScriptCore(Safari)
  • V8(Chrome)

JS中V8引擎名字取自同名V8跑车系列发动机引擎,代表大马力,速度。"8个气缸分成两组,每组4个,成V型排列"

苹果公司开源的webkit渲染引擎

  • V8引擎是一个JavaScript引擎实现,最初由一些语言方面专家设计,后被谷歌收购,随后谷歌对其进行了开源。
  • V8使用C++开发,在运行JavaScript之前,相比其它的JavaScript的引擎转换成字节码或解释执行,V8将其编译成原生机器码(V8后来又引入字节码),并且使用了如内联缓存(inline caching)等方法来提高性能。
  • V8支持众多操作系统,如windows、linux、android等,也支持其他硬件架构, 具有很好的可移植性和跨平台特性。

node.js = v8 + 内置模块(大部分由js编写)

2. V8引擎的工作过程

2017年4月之后:

当 V8 编译 JavaScript 代码时,解析器(parser)将生成一个抽象语法树。语法树是 JavaScript 代码的句法结构的树形表示形式。解释器 Ignition 根据语法树生成字节码。TurboFan 是 V8 的优化编译器,TurboFan 将字节码生成优化的机器代码。【传送门】 理解 V8 的字节码

  • 如果函数没有被调用,则V8不会去编译它。
  • 如果函数只被调用1次,则Ignition将其编译Bytecode就直接解释执行了。TurboFan不会进行优化编译,因为它需要Ignition收集函数执行时的类型信息。这就要求函数至少需要执行1次,TurboFan才有可能进行优化编译。
  • 如果函数被调用多次,则它有可能会被识别为热点函数,且Ignition收集的类型信息证明可以进行优化编译的话,这时TurboFan则会将Bytecode编译为Optimized Machine Code,以提高代码的执行性能。

图片中的红线是逆向的,这点有些奇怪,Optimized Machine Code会被还原为Bytecode,这个过程叫做Deoptimization(反向优化)。这是因为Ignition收集的信息可能是错误的,比如add函数的参数之前是整数,后来又变成了字符串。生成的Optimized Machine Code已经假定add函数的参数是整数,那当然是错误的,于是需要进行Deoptimization。

【问题】为什么V8在2017又重新引入字节码呢?

主要动机三个:

  • 减轻机器码占用的内存空间,即牺牲时间换空间
  • 提高代码的启动速度
  • 对 v8 的代码进行重构,降低 v8 的代码复杂度

解释:

因为机器码占空间很大,机器码占内存过大的情况下,v8 没有办法把所有 js 代码编译成机器码缓存下来。而且即使能全部缓存,这样缓存占用的内存、磁盘空间很大,退出 Chrome 再打开时序列化、反序列化缓存所花费的时间也很长,时间、空间成本都接受不了。如下图为JS源码直接编译为机器码:

所以 v8 退而求其次,只编译最外层的 js 代码,也就是上图这个例子里面绿色的部分。那么内部的代码(如上图图中的黄色、红色的部分)先不编译,那什么时候编译的呢?v8 推迟到第一次被调用的时候再编译。时间上的推移导致另外一个短板,就是代码必须被解析多次——绿色的代码一次、黄色的代码再解析一次(当 new Person 被调用)、红色的代码再解析一次(当 doWork() 被调用)。因此,如果你的 js 代码的闭包套了 n 层,那么最终他们至少会被 v8 解析 n 次。

而引入字节码之后,占空间的问题就可以得到缓解。通过恰当地设计字节码的编码方式,字节码可以做到比机器码紧凑很多。下图是V8 引入 Ignition 字节码后,代码的内存占用情况对比,可以发现确实降低了。

“字节码是机器代码的抽象” --- 字节码的解释执行比JS源码编译为机器代码执行要快,而且字节码占用内存比机器代码小,提前编译,所以缓存字节码可以达到既提速又能降低内存占用的作用。

【传送门】V8 Ignition:JS 引擎与字节码的不解之缘

3. V8引擎的三点性能优化

3.1 隐藏类(hidden class)

隐藏类类似于C类语言的指针,表示存储的地址。创建一个新的隐藏类,将开辟一个新的存储地址。

function Person(name, age) {
    this.name = name;
    this.age = age;
}

var xs = new Person("xszi", 1000);
var ve = new Person("v8", 19);

xs.email = "jiuhuaw@126.com";
xs.job = "coder";

ve.job = "driver";
ve.email = "v8@google.com";

初始化Person对象的时候, 最开始会创建一个C0的隐藏类,该类不带有任何属性。随后在调用构造器函数的时候,随着属性的增加,引擎会生成C1,C2的过渡隐藏类,隐藏类内部会记录属性的偏移量(offset)。之所以存在过渡隐藏类是为了在多个对象间能够共享隐藏类。

这里,xs和ve两个对象使用的是同一个构造函数,所以它们会共享同一个隐藏类C2。随后虽然xs和ve两个对象都添加了job和email两个属性,但由于初始化顺序不同,会生成不同的隐藏类。

不同初始化顺序的对象,所生成的隐藏类是不一样的。因此,在实际开发过程中,应该尽量保证属性初始化的顺序一致,这样生成的隐藏类可以得到共享,隐藏类存在于内存之中,从而加速对象的存取操作。同时,尽量在构造函数里就初始化所有对象成员,减少隐藏类的产生。

3.2 内联缓存(incline caching)

上小节介绍了隐藏类,但仅拥有隐藏类还不够,引擎在执行过程中需要查找隐藏类。内联缓存(Inline Caching)技术便是用来优化运行时查找对象及其属性的过程。

【问题】隐藏类和内联缓存这两个概念的关联是什么呢?

每当在特定对象上调用方法时,V8 引擎必须找到该对象的隐藏类,才能确定访问特定属性的偏移量。当同一方法两次成功调用到同一个隐藏类之后,V8会省略对隐藏类的查找,直接使用存储在内联缓存的偏移量跳转到该属性的内存地址。对于该方法的所有将来的调用,V8引擎假设隐藏类并未更改,并且使用之前查找到并存储的偏移量直接跳转到特定属性的内存地址。这就大大提高了执行速度。

下图是对象a,b共享隐藏类Shape的一个例子,x,y具有不同的offset偏移量。

接下来我们看看什么是内联缓存。

假设我们用一个对象{x: 'a'}作为参数调用一个函数getX来获取属性值。由以上分析我们可以知道,此对象具有属性为'x'的隐藏类Shape,Shape存储该属性x的偏移量和属性。其中下图绿色JSFunction 'getX'是函数getX经过编译生成的字节码。其中get_by_id指令从参数(arg1)加载属性'x',并将结果存储到loc0中。此外,引擎还将Shape嵌入get_by_id指令,该指令由两个未初始化的槽组成。

当你第一次执行该函数时,get_by_id指令会查找属性'x'并发现该值存储在偏移量0处。

嵌入到get_by_id指令中的内联缓存会记住找到属性的Shape和偏移量:

对于后续运行,只需要比较内联缓存,如果它与以前相同,只需加载记忆偏移量的值。具体地说,如果JavaScript引擎看到的对象具有之前记录的shape,那么它就根本不需要访问属性信息,可以完全跳过昂贵的属性信息查找。这比每次查看属性要快得多。

内联缓存详解:

Optimizing dynamic JavaScript with inline caches

形状和内联缓存

3.3 垃圾回收(GC)

v8中,所有的JavaScript对象都是通过堆来分的。堆分为新生代内存空间和老生代内存空间,且分别使用不同的垃圾回收算法进行垃圾回收。此外,垃圾回收是渐进式的垃圾回收机制

  • 新生代垃圾回收算法 --- Scavenge算法(牺牲空间换取时间)

检查Form空间,如果是存活对象就会复制到To空间,非存活对象占用的空间就会直接释放,完成复制后,From空间和To空间角色发生对换。

该算法的优点是只复制存活的对象,对于新生代存活对象只占少部分,所以效率上由优异的表现;缺点是只能使用堆内存中的一半,所以无法大规模地应用到所有的垃圾回收中。但新生代对象的生命周期较短,非常适合这个算法。

v8的对内存示意图

第一种晋升条件
第二种晋升条件

【问题】为什么限制值设置为25%?

当这次Scavenge回收完成后,这个To空间将变成From空间,接下来的内存分配将在这个空间种进行,如果占比过高,会影响后续的内存分配。

  • 老生代垃圾回收算法 --- Mark-Sweep & Mark-Compact

    老生代不使用Scavenge算法的原因:

    • 老生代存活对象很多,复制效率很低;
    • 浪费一半空间

标记清楚的方法的原理:遍历堆中老生代的所有对象,标记活着的对象,清除死亡的对象。如下图:

由于Mark-Compact需要移动对象,效率低,一般情况下不会用到,只有在空间不足时才会使用。

V8还引入了其他优化方法,目前只了解了这三个方法,后续有时间再补充。

4. 总结

4.1 V8引擎的优势

  • 使用Ignition+ TurboFan组合,牺牲部分时间换取空间,可以提升V8性能
  • 使用隐藏类和内联缓存实现数据的快速存取
  • 针对不同类别的内存空间使用不同的垃圾回收方法,提升回收效率。

4.2 前端开发者代码优化方式

  • 总是以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码,在其构造函数中分配所有对象的属性

  • 重复执行相同方法的代码将比仅执行一次(由于内联高速缓存)执行许多不同方法的代码运行得更快,所以可以归纳封装一些公用方法。

  • Deoptimization非常耗性能,减少Deoptimization操作,可以使用Typescript等规范变量类型。

5. 参考资料