javascript引擎——V8

racy 2019-06-27

通过上一篇文章,我们知道了JavaScript引擎是执行JavaScript代码的程序或解释器,了解了JavaScript引擎的基本工作原理。我们经常听说的JavaScript引擎就是V8引擎,这篇文章我们就来认识一下V8引擎,我们先来看一下除了V8引擎,还有哪些JS引擎:

  • V8 开源
    由Google开发,用C++编写。V8 最早被开发用以嵌入到 Google 的开源浏览器 Chrome 中,但是 V8 是一个可以独立的模块,完全可以嵌入您自己的应用,著名的 Node.js( 一个异步的服务器框架,可以在服务端使用 JavaScript 写出高效的网络服务器 ) 就是基于 V8 引擎的。
  • Rhino开源
     由Mozilla基金所管理,完全用Java开发
  • JavaScriptCore 开源
    由苹果公司为Safari开发
  • SpiderMonkey
    第一个JavaScript引擎,最早用在Netscape Navigator上,现在用在Firefox上
  • KJS
    KDE的引擎,最初由Harri Porten为KDE项目的Konqueror网页浏览器所开发
  • Chakra(JScript9)
     Internet Explorer 浏览器
  • Chakra(JavaScript)

    Microsoft Edge
  • Nashorn
    OpenJDK开源项目的一部分,用的是Oracle Java语言和工具组
  • JerryScript
    用于物联网的轻量级引擎

在这些项目中,V8引擎因其在性能上的突出表现,倍受大家的关注,所以我们也以介绍V8引擎为主。V8是Google开源的高性能JavaScript引擎,用C++编写。它用于谷歌浏览器,谷歌的开源浏览器,以及Node.js等等。

速度是V8追求的主要设计目标之一,在一些性能测试中,V8比IE的JScript,Firefox中的SpiderMonkey以及Safari中的JavaScriptCore要快上数倍。相比其他的JavaScript引擎转化成字节码或解释执行,V8将其编译成本地代码,并且使用了如隐类型,内联缓存等方法来提高性能。

javascript引擎——V8

http://kourge.net/node/122

V8按照ECMA-262第5版中的规定实施ECMAScript,支持众多操作系统,如windows、linux、android等,也支持其他硬件架构,如IA32,X64,ARM等,具有很好的可移植和跨平台特性。

V8的工作过程

V8工作的整个过程与Java有些类似,大致分成两个阶段:第一是编译,第二是运行。与C++直接编译成本地代码不同的是,V8只有在函数调用时才会编译成本地代码,这样就提高了响应时间减少了时间开销。

javascript引擎——V8

图片来源《WebKit技术内幕》

在V8引擎中,源代码先通过解析器转变成抽象语法树,这点同JavaScriptCore引擎一样,不同于JavaScriptCore引擎,V8引擎中并不将抽象语法树转变成字节码或者其他中间表示,而是通过JIT全代码生成器(full code generator)从抽象语法树直接生成本地代码,这样做可以减少抽象语法树到字节码的转换时间,提高代码的执行速度,但也是因为缺少了转换为字节码这一中间过程,也就减少了优化中间代码的机会。

下面来看一下V8引擎编译JavaScript生成本地代码使用了哪些主要类:

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

javascript引擎——V8

图片来源《WebKit技术内幕》

JavaScript代码编译的过程大致为:Script类调用Compiler类的Compile函数生成本地代码。在该函数中,先使用Parser类来生成抽象语法树;再使用FullCodeGenerator类来生成本地代码。

javascript引擎——V8

图片来源《WebKit技术内幕》

本地代码与具体的硬件平台密切相关,FullCodeGenerator使用多个后端来生成与平台相匹配的本地汇编代码。由于FullCodeGenerator通过遍历AST来为每个节点生成相应的汇编代码,缺失了全局视图,节点之间的优化也就无从谈起。

JavaScript代码编译之前需要构建一个运行环境,所以JavaScript代码编译之前,V8引擎会构建众多对象并加载一些内置的库(如Math库)。再次强调一下,在JavaScript源码中,并非所有的函数都被编译生成本地代码,而是延时编译,在调用时才会编译。

由于V8缺少生成字节码(中间表示)这一环节,缺少必要的优化,为了性能上的考虑,V8会在生成本地代码后,使用数据分析器(Profiler)采集一些信息,然后根据这些信息对本地代码进行优化,生成更高效率的本地代码,这是一个逐步改进的过程。同时,当发现优化后的代码性能并没有提高甚至还有所降低时,V8将退回到原来的代码。这些都是在运行阶段用涉及到的技术。

现在我们来看一下运行阶段使用到的类:

  • Script: 表示是JavaScript代码,既包含源代码,又包含编译之后生成的本地代码,所以它既是编译入口,又是运行入口
  • Execution: 运行代码的辅助类,包含一些重要的函数,例如call,它辅助进入和执行Script中的本地代码
  • JSFunction: 需要执行的JavaScript函数表示类
  • Runtime:运行这些本地代码的辅助类,它的功能主要是提供运行时各种各样的辅助函数,包括但是不限于属性访问、类型转换、编译、算数、位操作、比较、正则表达式等
  • Heap:运行本地代码需要使用内存堆
  • MarkCompactCollector:垃圾回收机制的主要实现类,用来标记(Mark)、清除(Sweep)和整理(Compact)等基本的垃圾回收过程
  • SweeperThread:负责垃圾回收的线程

javascript引擎——V8
图片来源《WebKit技术内幕》

首先,当某个JavaScript函数被调用时,使用编译阶段的类和操作编译生成本地代码。具体的工作方式是V8查找函数是否已经生成本地代码,如果已经生成,那么直接使用这个函数。否则,V8引擎会触发生成本地代码,这样的工作方式可以节约时间,减少去处理那些使用不到的代码的时间。其次,执行编译后的代码为JavaScript构建JS对象,这需要Runtime类来辅助创建对象,并需要从Heap类分配内容。再次,借助Runtime类中的辅助函数来完成一些功能,如属性访问,类型转换等。最后,将不用的空间进行标记清除和垃圾回收。

javascript引擎——V8
图片来源《WebKit技术内幕》

V8特性简介

一. 优化回滚

FullCodeGenerator编译器基于抽象语法树直接生成本地代码,没有中间表示层,所以很多时候没有经过很好的优化。JavaScript引擎性能之争非常激烈,没有经过优化的代码导致该引擎在性能上同有特别大的突破,而其他引擎都在进度,有鉴于此,在2010年,V8引入了新的编译器,这就是Crankshaft编译器,它主要针对那些热点函数进行优化。该编译器基于JavaScript源代码开始分析,而不是本地代码,同时构建Hydtogen图并基于此来进行优化分析。

FullCodeGenerator是一个简单且快的编译器,生成未优化的本地代码,运行起来很慢;Crankshaft是一个相对慢的编译器,生成高度优化的代码。由FullCodeGenerator生成的未优化代码Crankshaft优化代码替换,传送门

Crankshaft编译器为了性能考虑,通常会做出比较乐观和大胆的预测,那就是编译器认为这些代码比较稳定,变量类型不会发生改变,所以能够生成高效的本地代码。但是在实际执行过程中,因为JavaScript弱类型语言的特性,变量类型有可能会改变,在这种情况下,V8会将该编译器做的错误优化回滚到之前的一般情况,这个过程称为优化回滚。

V8并不只是第一次执行一个JavaScript函数时才编译它;同一个JavaScript函数可以被这些JIT编译器多次编译。

基本流程是:

[JavaScript函数] ->
        第一次被调用 -> Full Code -> [初级编译后的代码]
         足够热之后 -> Crankshaft(Optimizing Compiler) -> [优化编译后的代码]
如果优化的代码需要去优化(优化回滚) -> deoptimize -> 回到[初级编译后的代码]
    ... 周而复始 ...

示例如下:

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

函数test被调用多次后,V8引擎可能会触发Crankshaft编译器来生成优化的代码,优化的代码认为示例代码的类型等信息都已经被获知,但事实上还未真正执行到new Date()这个地方,并未获取unknown这个变量的类型,V8只得将该部分的代码进行回滚。优化回滚是一个很费时的操作,所以在写代码的过程中,尽量不要触发这个过程。

二. 隐类型和内嵌缓存

我们都知道JavaScript属于动态类型语言,只有在运行时才能确定变量的类型,在运行时计算和决定类型,会带来严重的性能损失,这也就导致了JavaScript语言的运行效率比C++或Java都要低很多。

主要体现在以下几个部分:

  1. 编译确定位置:
    C++在编译阶段对象的属性和偏移信息都计算完成;而这些信息JavaScript只有在执行阶段才可以确定
  2. 偏移信息共享:
    C++属于静态类型语言,不能在执行时动态改变类型,这些对象都是共享偏移信息的。访问对象时就按编译时的偏移量即可;JavaScript每个对象都是自描述,属性和位置偏移信息都包含在自身的结构中。

    一个简单的C++函数:

    class Class1 {
         int x;
         int y;
     }
     int add(Class1 a, Class1 b){
         return a.x*a.y + b.x*b.y;
     }

    示例代码中的类型和对象的结构表示,如下图:
    javascript引擎——V8
    图片来源《WebKit技术内幕》

    一个简单的JavaScript函数:

    function add(a,b){
        return a.x*a.y + b.x*b.y; // 这里对象a和b的类型未知
    }
    var a = {x:3.3,y:5.5};
    var b = {x:4.4,y:6.6};

    示例代码中对象a和b的结构表示,如下图:
    javascript引擎——V8
    图片来源《WebKit技术内幕》

  3. 偏移信息查找:
    C++中查找偏移地址很简单,都是在编译代码时,对使用到某类型的成员变量直接设置偏移量。而对于JavaScript,使用到一个对象则需要通过属性名匹配才能查找到对应的值

因为对象属性的访问非常普遍而且次数非常频繁,像C++这种通过偏移量来访问值使用少数两个汇编指定就能完成,但是Javascript这种通过属性名来匹配对于性能造成的影响可能会多很多倍,因为属性名匹配需要特别长的时间,而且额外浪费很多内存空间。

有方法解决这一问题么?答案是肯定的。下面我们就来看一下V8引擎是如何解决这一问题的。虽然JavaScript语言没有类型的定义,但是V8使用类和偏移位置思想,将本来需要通过字符串匹配来查找属性值的算法改进为使用类似C++编译器的偏移位置的机制来实现。这就是隐藏类(Hidden Class)

JavaScript对象的实现在V8中包含3个成员,第一个是隐藏类的指针,这是V8为JavaScript对象创建的隐藏类。第二个指向这个对象包含的属性值。第三个指向这个对象包含的元素。

javascript引擎——V8
图片来源《WebKit技术内幕》

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

V8引擎的发展历史

  • 2008年9月,V8的第一个版本随着Chrome的第一版发布。
  • 2010年12月,官方公布V8的名为Crankshaft的优化编译器,与原来的Full Compiler一起工作,声称较2008年版本提高50%性能。
  • 2015年7月7日,官方公布又一个新的中为TurBoFan的优化编译器,主要提供ES6的新语法,以及提高性能。并表明该编译器最终目标是全部替代Crankshaft编译器。
  • 2015年7月17日,官方公布集成了TurboFan的V8版本(v4.5)
  • 2015年8月28日,V8发布v4.6版本
  • 2016年3月15日,V8发布v5.0版本
  • 2016年7月18日,V8发布v5.3版本,新增名为Ignition的解析器(Interpreter),跟原有的优化编译器(Crankshaft and TurboFan)进行串联工作,提供了更加优化的内存使用方案,主要针对于低内存的Android设备,并称在未来会普及到全平台。
  • 2016年9月9日,V8发布v5.4版本
  • 2016年10月24日,V8发布v5.5版本,在5.5版本中开始支持ES7异步函数,这使得编写使用和创建Promise的代码变得更加容易。
  • 2016年12月2日,V8发布v5.6版本,从5.6版本开始,V8可以优化整个JavaScript语言。而且,许多语言功能都是通过V8中的新优化管道发送的。该管道使用V8的Ignition解释器作为基准,并使用V8更强大的TurboFan优化编译器优化经常执行的方法。新的流水线激活了新的语言功能(例如ES2015和ES2016规范中的许多新功能)或Crankshaft(V8的“经典”优化编译器)无法优化某种方法(例如try-catch,with)的情况。
  • 2017年2月6日,V8发布v5.7版本
  • 2017年3月20日,V8发布v5.8版本
  • 2017年4月27日,V8发布v5.9版本,V8 5.9将成为默认启用Ignition + Turbofan的第一个版本。一般来说,这种交换机应该可以降低内存消耗,并且可以更快地启动Web应用程序。
  • 2017年6月9日,V8发布v6.0版本,V8 6.0引入了对SharedArrayBuffer的支持,SharedArrayBuffer是一种在JavaScript工作人员之间共享内存并在工作人员之间同步控制流的低级机制。 SharedArrayBuffers为JavaScript提供了对共享内存,原子和futex的访问。 SharedArrayBuffers还解锁了通过asm.js或WebAssembly将线程化应用程序移植到Web的功能。
  • 2017年8月3日,V8发布v6.1版本
  • 2017年9月11日,V8发布v6.2版本
  • 2017年10月25日,V8发布v6.3版本,改进了速度和内存消耗,详细
  • 2017年12月19日,V8发布v6.4版本,提升了速度和优化内存消耗,详细
  • 2018年2月1日,V8发布v6.5版本,编译速度显著提升,详细
  • 2018年3月27日,V8发布v6.6版本,异步性能大幅提升,详细

相关推荐