阅读 1404

V8引擎

一、摘要

V8使用C++开发,并在谷歌浏览器中使用。在运行JavaScript之前,相比其它的JavaScript的引擎转换成字节码(包含执行程序的二进制文件)或解释执行,V8将其编译成原生机器码(IA-32, x86-64, ARM, or MIPS CPUs),并且使用了如内联缓存(inline caching)等方法来提高性能。有了这些功能,JavaScript程序在V8引擎下的运行速度媲美二进制程序。

二、详解

1. 字节码(Bytecode):一种包含执行程序、由一序列op代码、数据对组成的二进制文件,是一种中间码,编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的机器码。

原生机器码(machine code):也被称为原生码(Native Code),是电脑的CPU可直接解读的数据,机器码是计算机可以直接执行,并且执行速度最快的代码。编出的程序全部是0和1组成的指令代码

字节码装换成原生机器码:首先编译器将源码编译成字节码,特定平台上 的虚拟机(如:Java虚拟机)器将字节码转译成可以直接执行的指令。字节码的典型应用为Java bytecode

2. javascript引擎

语言编译过程:


现在其他的js引擎的执行过程大致:源代码 -> 抽象语法树 -> 字节码 -> JIT -> 本地代码

(如:苹果的JavascriptCore引擎,08年引入SquirrelFish,实现了一个字节码寄存器Register Machine;Mozilla公司的SpiderMonkey;微软的Chakra等)

抽象语法树


V8引擎中没有中间字节码,直接将抽象语法树通过JIT技术转换成本地代码,后通过Profiler采集一些信息,优化本地代码,虽然少了这一阶段的性能优化,但极大减少了转换时间

但是在V8的5.9版本,新增了Ignition字节码解释器,默认启动,原因是减轻机器码占用的内存空间,提高代码的启动速度,重构V8的代码并降低代码复杂度。

3. V8引擎

3.1 5.9之前的版本:直译成原生机器码,并使用如内联缓存等方法提高性能

3.1.1 数据表示

javascript是一种无类型语言,在编译时不能确定变量的类型,只能在执行时确定;而像c++、java等静态类型语言在编译时就能知道变量类型,确定变量存取地址。在运行时计算和决定类型是会降低运行效率的原因。

变量的存取在代码执行过程中是非常普遍和平凡的,js的对象需要通过属性名匹配找到相应的值,需要更多的操作和内存空间。V8使用了一种特殊的方法:数据的内部表示由数据的实际内容和数据的句柄构成。

数据的实际内容长度可变,类型可变;数据的句柄固定大小,包含指向数据的指针。变量存取时通过查找、修改句柄中的指针即可。V8一个句柄对象的大小是4字节(32位设备)和8字节(64位设备),javascriptCore中是8字节。

V8中的指针包含三类:隐藏类指针,是V8为js对象创建的隐藏类;属性值表指针,指向该对象包含的属性值;元素表指针,指向该对象包含的属性。

3.1.2工作过程

V8中,js代码是在需要执行时才进行编译,而不是一次性全部编译;这也就提高了响应时间。源代码先被解析器转成抽象语法树(AST),然后使用JIT编译器的全代码生成器将其直接生成本地客执行代码。为了提升性能,V8在生成本地代码后,使用数据分析器(profiler)采集一些信息,然后根据这些信息优化本地代码,如果优化后代码性能更差,就会优化回滚。

在执行编译前,V8会构建众多全局对象,并加载一些内置的库(如math库),来构建运行环境。

3.1.3优化回滚

V8没有经过中间表示层的优化,即先编译确定变量类型等,所以引入Crankshaft编译器对热点函数,基于javascript源代码,进行优化分析。Crankshaft为了性能考虑,默认代码稳定且变量类型不变,生成高效的本地代码;但若遇到变量类型在执行过程中改变的情况,V8会将该编译器做的优化进行回滚,即重新从源码开始再次编译。

var counter =
0;

function
test(x, y) {

    counter++;

    if (counter < 1000000)
{

        // do something

        return 'jeri';

    }

    var unknown = new Date();

    console.log(unknown);

}复制代码

在未执行到new Date()之前,并不确定unknown变量的类型,执行到时,V8只能将这部分代码进行回滚。优化回滚是很耗时的操作。

3.1.4隐藏类


使用point构造了两个对象p和q,这两个对象有相同的属性名,V8将他们归为同一隐藏类,具有相同的偏移位置信息,p和q共享这一信息,进行属性访问时,只需根据隐藏类的偏移信息即可。但假如,代码执行后,对象q执行了q.z = 5,则p和q不在具有相同的隐藏类,q是一个新的隐藏类。

隐藏类转换取决于将属性添加到对象的顺序:

function Point(x, y) {

    this.x = x;

    this.y = y;

}

var p = new Point(1, 2);

p.a = 5;

p.b = 6;

var q = new Point(3, 4);

q.b = 7;

q.a = 8;复制代码

而在该函数中,p和q添加属性的顺序不同,隐藏类的偏移量不同,也是两个不同的隐藏类。

3.1.5内联缓存

正常访问对象的过程:首先获取隐藏类的地址,然后根据属性名查找偏移量,然后计算该属性的地址。多次变量存取,就要重复执行这一过程,也较耗时。因此,V8提供了内嵌缓存,即将初次查找的隐藏类和偏移量保存起来,当下次查找相同对象时,可以省略计算地址的过程。但是如果一个对象有多个属性,缓存失误的概率就会提高,因为某个属性的类型变化后,对象的隐藏类也会发生变化,就与之前的缓存不一致,需要重新计算。

3.1.6内存管理

V8垃圾回收机制限制js使用的内存(如果可使用内存太大,垃圾回收时需要耗费更多的资源和时间),因此对内存进行管理:分配和回收。

内存的管理组要由分配和回收两个部分构成。V8的内存划分如下:

Zone:管理小块内存。其先自己申请一块内存,然后管理和分配一些小内存,当一块小内存被分配之后,不能被Zone回收,只能一次性回收Zone分配的所有小内存。当一个过程需要很多内存,Zone将需要分配大量的内存,却又不能及时回收,会导致内存不足情况。

堆:管理JavaScript使用的数据、生成的代码、哈希表等。为方便实现垃圾回收,堆被分为三个部分:
年轻分代:为新创建的对象分配内存空间,经常需要进行垃圾回收。为方便年轻分代中的内容回收,可再将年轻分代分为两半,一半用来分配,另一半在回收时负责将之前还需要保留的对象复制过来。
年老分代:根据需要将年老的对象、指针、代码等数据保存起来,较少地进行垃圾回收。
大对象:为那些需要使用较多内存对象分配内存,当然同样可能包含数据和代码等分配的内存,一个页面只分配一个对象。


3.2 5.9版本

3.2.1引入Ignition字节码解释器的原因


1).V8对代码的编译是在该段代码被执行时,因此代码需要被解析多次--绿色代码(总的)先解析一次,当new Person被调用时黄色代码再解析一次,当doWork被调用时红色代码再解析一次。因此,如果闭包嵌套了n层,在Crankshaft预算正确的情况下,代码至少要被V8解析n次。

2).机器码占空间大,如果要将所有js直译的机器码缓存,占用的内存、磁盘空间很大,而退出浏览器再重新打开,执行使用缓存的时间也会很长。如此,Chrome的缓存之作用在js代码的最外层,真正的执行逻辑并没有被缓存,这也是导致内存资源被占用被浪费的原因。

字节码比机器码紧凑更多,可以降低代码内存的占有率


(来源: docs.google.com/presentatio…)

同样的内存分配下,内存占用减少,启动速度加快,使得V8可以期望提前编译所有js代码,并把字节码缓存,二次打开网站时速度就会更快。也就不再需要Cranshaft这个旧的编译器,引用新的Turbofan直接从字节码优化,并当需要进行反优化的时候,直接反优化到字节码,而不是将机器码反优化到js源码。


(原文出处: docs.google.com/presentatio…)

三、总结

从上述V8引擎的设计特征总结来看,在编码过程中应注意:

  1. 类型。因为JS是动态类型语言,JavaScriptCore和V8都使用隐藏类和内嵌缓存来提高性能。为了降低反优化概率,一个函数应该使用较少的数据类型;对于对象,应尽量存放相同类型的数据。
  2. 内存。及时回收不用的内存,不再使用的对象设置为null。
  3. 优化回滚。闭包嵌套层数不要过多,且执行多次后,尽量不修改对象类型。
  4. 动态属性。多次New 一个相同的对象时,最好以相同的顺序初始化动态属性,以便隐藏类可以被复用。


文章分类
前端
文章标签