JavaScript 如何工作的? 认识 V8 引擎

944 阅读15分钟

前言

在过去的几年里,JavaScript高速发展成为了互联网中最热门的高级语言之一,它在性能上的提升以及不断涌现的前沿web技术使其成为HTML5的中坚力量。

一、JavaScript引擎

我们写的JavaScript代码直接交给浏览器或者Node执行时,底层的CPU是不认识的,也没法执行。CPU只认识自己的指令集,指令集对应的是汇编代码。写汇编代码是一件很痛苦的事情,比如,我们要计算N阶乘的话,只需要7行的递归函数。但是,如果使用汇编语言来写N阶乘的话,要300+行代码(代码省略)

function factorial(N) {
    if (N === 1) {
        return 1;
    } else {
        return N * factorial(N - 1);
    }
}

JavaScript引擎 是通过某种方式将 JavaScript 脚本编译为CPU对应字节码的标准解释器或即时编译器。一般会附带在网页浏览器之中。当然,JavaScript引擎的工作也不只是编译代码,它还要负责执行代码、分配内存以及垃圾回收。

那么常见的JavaScript引擎有如下:

Mozilla浏览器 -----> 解析引擎为 Spidermonkey(由c语言实现的)
Chrome浏览器 ------> 解析引擎为 V8(它是由c++实现的)
Safari浏览器 ------> 解析引擎为 JavaScriptCore(c/c++)
IE and Edge ------> 解析引擎为 Chakra(c++)
Node.js     ------> 解析引擎为 V8

二、V8引擎诞生

V8引擎是一个JavaScript引擎,最开始由一些语言学家设计出来,后被Google收购。V8引擎是2008年发布的,它的命名灵感来自超级性能车的V8引擎,敢于这样命名确实需要一些实力。由于V8引擎在JavaScript性能优化方面做了很大的提升,所以也让他成为了大众喜爱的开源高性能JavaScript引擎,目前被用于谷歌浏览器,安卓浏览器,node.js等大型项目中,并成为了不可或缺的一部分。

三、V8引擎介绍

【3.1】调用V8编程接口的例子和对应的内存管理方式:

第一条语句:表示建立一个域,用于包含一组Handle对象,便于管理和释放他们;

第二条语句:根据isolate对象来获取一个Context对象,使用Handle来管理。Handle对象本身存放在栈上,而实际的Context对象保存在堆中。

第三条语句:根据两个对象Isolate和Context来创建一个函数间使用的对象,使用Persistent类来管理;

第四条语句:表示为Context对象创建一个基于栈的域,下面的执行步骤都是在该域中对应的上下文中来进行的;

第五条语句:读入一段JavaScript代码;

第六条语句:将代码字符串编译成V8的内部表示,并保存成一个Script对象;

第七条语句:执行编译后的内部表示,获得生成的结果;

【3.2】V8的编译:



首先通过编译器将源代码编译成抽象语法树,不同于JavaScriptCore引擎,V8引擎并不将抽象语法树转变成字节码,而是通过JIT编译器的全代码生成器从抽象语法树直接生成本地代码;

其过程中的主要类图如下:


  • Script:表示的是JavaScript代码,既包含源代码,又包含编译之后生成的本地代码,所以它既是编译入口,又是运行入口;
  • Compiter:编译器类,辅助Script类来编译生成代码,它主要起一个协调者的作用,会调用解析器(Parse)来生成抽象语法树和全代码生成器,来为抽象语法树生成本地代码;
  • Parse:将源代码解析并构建成抽象语法树,使用AstNodeFactory类来创建他们,并使用Zone类来分配内存;
  • AstNode:抽象语法树节点类,是其他所有节点的基类;包含非常多的子类,后面会针对不同的子类生成不同的本地代码;
  • AstVisitor:抽象语法树的访问者类,主要用来遍历抽象语法树;
  • FullCodeGenerator:AstVisitor类的子类,通过遍历抽象语法树来为JavaScript生成本地可执行的代码;

JavaScript代码编译的过程大致为:Script类调用Compiler类的Compile函数为其生成本地代码。Compile函数先使用Parser类生成AST,再使用FullCodeGenerator类来生成本地代码。本地代码与具体的硬件平台密切相关,FullCodeGenerator使用多个后端来生成与平台相匹配的本地汇编代码。由于FullCodeGenerator通过遍历AST来为每个节点生成相应的汇编代码,缺失了全局视图,节点之间的优化也就无从谈起。

在执行编译之前,V8会构建众多全局对象并加载一些内置的库(如math库),来构建一个运行环境。而且在JavaScript源代码中,并非所有的函数都被编译生成本地代码,而是延迟编译,在调用时才会编译。由于V8缺少了生成中间代码这一环节,缺少了必要的优化,为了提升性能,V8会在生成本地代码后,使用数据分析器(profiler)采集一些信息,然后根据这些数据将本地代码进行优化,生成更高效的本地代码,这是一个逐步改进的过程。同时,当发现优化后代码的性能还不如未优化的代码,V8将退回原来的代码,也就是优化回滚。

【3.3】V8运行

V8运行阶段的主要类图如下:


  • Script:前面介绍过,包含编译之后生成的本地代码,运行代码的入口;
  • Execution:运行代码的辅助类,包含一些重要的函数“call”,它辅助进入和执行Script中的本地代码;
  • JSFunction:需要执行的JavaScript函数表示类;
  • Runtime:运行本地代码的辅助类,主要提供运行时各种辅助函数;
  • Heap:运行本地代码需要使用的内存堆;
  • MarkCompactCollector:垃圾回收机制的主要实现类,用来标记,清除和整理等基本的垃圾回收过程;
  • SweeperThread:负责垃圾回收的线程;

先根据需要编译和生成这些本地代码,也就是使用编译阶段那些类和操作。在V8中,函数是一个基本单位,当某个JavaScript函数被调用时,V8会查找该函数是否已经生成本地代码,如果已经生成,则直接调用该函数。否则,V8引擎会生成属于该函数的本地代码。这就节约了时间,减少了处理那些使用不到的代码的时间。其次,执行编译后的代码为JavaScript构建JS对象,这需要Runtime类来辅组创建对象,并需要从Heap类分配内存。再次,借助Runtime类中的辅组函数来完成一些功能,如属性访问等。最后,将不用的空间进行标记清除和垃圾回收。

V8中代码的执行过程如下图:

​​​四、V8引擎所做优化

【4.1】优化回滚:因为V8是基于AST直接生成本地代码,没有经过中间表示层的优化,所以本地代码尚未经过很好的优化。于是,在2010年,V8引入了新的编译器-Crankshaft。它主要针对热点函数进行优化,它是基于JS源码分析的,而不是本地代码。为了性能考虑Crankshaft编译器会进行一些乐观大胆的预测,认为这些代码比较稳定,变量类型不会发生变化,所以能够生成高效的本地代码;但是,鉴于JavaScript的一个弱类型的语言,变量类型也可能在执行的过程中进行改变,鉴于这种情况,V8会将该编译器做的想当然的优化进行回滚,称为优化回滚。示例如下:

let counter = 0;
function test(x, y) {
    counter++;
    if (counter < 1000000) {
        // do something
        return 'hehe';
    }
    var let = new Date();
    console.log(unknown);
}

该函数被调用多次之后,V8引擎可能会触发Crankshaft编译器对其进行优化,而优化代码认为示例代码的类型信息都已经被确定。但,由于尚未真正执行到new Date()这个地方,并未获取unknown这个变量的类型,V8只得将该部分代码进行回滚。优化回滚是一个很耗时的操作,在写代码过程中,尽量不要触发优化该操作。

在最近发布的 V8 5.9 版本中,新增了一个 Ignition 字节码解释器,TurboFan 和 Ignition 结合起来共同完成JavaScript的编译。这个版本中消除 Cranshaft 这个旧的编译器,并让新的 Turbofan 直接从字节码来优化代码,并当需要进行反优化的时候直接反优化到字节码,而不需要再考虑 JS 源代码。

【4.2】隐藏类:隐藏类将对象划分成不同的组,对于组内对象拥有相同的属性名和属性值的情况,将这些组的属性名和对应的偏移位置保存在一个隐藏类中,组内所有对象共享该信息。同时,也可以识别属性不同的对象。示例如下:


实例中对象a和b包含相同的属性名,V8就会把他们归为同一个组,也就是隐藏类;这些属性在隐藏类中有相同的偏移值,这样,对象a和b可以共享这个类型信息,当访问这些对象属性的时候,根据隐藏类的偏移值就可以知道他们的位置并进行访问。由于JavaScript是动态类型语言,在执行时可以更改变量的类型,如果上述代码执行之后,执行a.z=1,那么a和b将不再被认为是一个组,a将是一个新的隐藏类。

【4.3】内存缓存

正常访问对象属性的过程是:首先获取隐藏类的地址,然后根据属性名查找偏移值,然后计算该属性的地址。虽然相比以往在整个执行环境中查找减小了很大的工作量,但依然比较耗时。能不能将之前查询的结果缓存起来,供再次访问呢?当然是可行的,这就是内嵌缓存。

内嵌缓存的大致思路就是将初次查找的隐藏类和偏移值保存起来,当下次查找的时候,先比较当前对象是否是之前的隐藏类,如果是的话,直接使用之前的缓存结果,减少再次查找表的时间。当然,如果一个对象有多个属性,那么缓存失误的概率就会提高,因为某个属性的类型变化之后,对象的隐藏类也会变化,就与之前的缓存不一致,需要重新使用以前的方式查找哈希表。

【4.4】内存管理:

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

堆:V8使用堆来管理JavaScript使用的数据,以及生成的代码,哈希表等;为了更方便的实现垃圾回收,同很多虚拟机一样,V8将堆分成三个部分,第一个是年轻分代,第二个是年老分代,第三个是大对象保留的空间;如下图:


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

垃圾回收:

V8 使用了分代和大数据的内存分配,在回收内存时使用精简整理的算法标记未引用的对象,然后消除没有标记的对象,最后整理和压缩那些还未保存的对象,即可完成垃圾回收。

在V8中,使用较多的是年轻分代和年老分代。年轻分代中的对象垃圾回收主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法:通过复制的方式实现的垃圾回收算法。它将堆内存分为两个 semispace,一个处于使用中(From空间),另一个处于闲置状态(To空间)。当分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。年轻分代中的对象有机会晋升为年老分代,条件主要有两个:一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制。

对于年老分代中的对象,由于存活对象占较大比重,再采用上面的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。为此,V8在年老分代中主要采用了Mark-Sweep(标记清除)标记清除和Mark-Compact(标记整理)相结合的方式进行垃圾回收。

【4.5】快照(Snapshot)

V8引擎开始启动的时候,需要加载很多内置的全局对象,同时也要建立内置的函数,比如Array、String、Math等;为了让引擎更加整洁,加载对象与建立函数等任务都是使用JS文件来实现的,V8引擎负责在编译和执行输入的JavaScript代码之前,先加载他们;

快照机制就是将一些内置的对象和函数加载之后的内存保存并序列化;序列化之后的结果很容易被发序列化,经过快照机制的启动时间,可以缩短启动时间;快照机制也能够将开发者认为需要的JS文件序列化,减少以后处理的时间;

【4.6】 绑定和扩展

V8提供两种机制来扩展引擎的能力,第一是Extension机制,就是通过V8提供的基类Extension来达到扩展JavaScript能力的目的;第二是绑定,使用IDL文件或者接口文件来生成绑定文件,然后将这些文件同V8引擎代码一起编译。

五、实践 – JavaScript 优化建议

【5.1】创建对象

不要破坏隐藏类,尽量在构造函数中初始化所有对象成员,不要在以后更改类型,以保证对象的结构不变,从而让 V8 可以优化对象。

    function Point(x, y) {
      this.x = x;
      this.y = y;
    }

    let p1 = new Point(11, 22);
    let p2 = new Point(33, 44);
    // p1和p2共享一个隐藏类
    p2.z = 55;
    // p1和p2拥有不同的隐藏类,隐藏类被破坏

以相同的顺序初始化对象成员

const obj = { a: 1 } // 隐藏的 classId 被创建
obj.b = 3

const obj2 = { b: 3 } // 另一个隐藏的 classId 被创建
obj2.a = 1

// 这样会更好
const obj = { a: 1 } // 隐藏的 classId 被创建
obj.b = 3

const obj2 = { a: 1 } // 隐藏类被复用
obj2.b = 3

【5.2】 数据表示

在V8中,数据的表示分成两个部分,第一个部分是数据的实际内容,他们是变长的,第二部分是数据的句柄,句柄的大小是固定的,句柄中包含指向数据的指针。为什么要这样设计呢?主要是因为V8需要进行垃圾回收,并需要移动这些数据内容,如果直接使用指针的话就会出问题或者需要比较大的开销,使用句柄的话就不存在这些问题,只需要将句柄中的指针修改即可。

具体的定义如下:


一个Handler的大小是4字节(32位机器),整数直接从value_中获取值,而无需从堆中分配,然后分配一个指针指向它,这可以减少内存的使用并增加数据的访问速度。

所以:对于数值来说,只要能够使用整数的,尽量不要使用浮点数。

【5.3】 数组初始化

    let a = new Array();
    a[0] = 11;
    a[1] = 22;
    a[2] = 0.2;
    a[3] = true;

这样会更好:

let a = [11, 22, 0.2, true]

建议:初始化使用数组常量小型固定大小的数组

不要储存在数字数组非数字值(对象)

不要删除数组中的元素,尤其是数字数组

不要装入未初始化或删除元素

【5.4】 内存

对引用不再使用的对象的变量设置为空(a = null),引入delete关键字,删除无用对象。

【5.5】优化回滚

不要书写出触发优化回滚的代码,否则会大幅降低代码的性能;执行多次之后,不要出现修改对象类型的语句;


文章每周持续更新,可以微信搜索「 前端大集锦 」第一时间阅读,回复【视频】【书籍】领取200G视频资料和30本PDF书籍资料