racy 2019-06-27
通过上一篇文章,我们知道了JavaScript引擎是执行JavaScript代码的程序或解释器,了解了JavaScript引擎的基本工作原理。我们经常听说的JavaScript引擎就是V8引擎,这篇文章我们就来认识一下V8引擎,我们先来看一下除了V8引擎,还有哪些JS引擎:
Microsoft Edge
在这些项目中,V8引擎因其在性能上的突出表现,倍受大家的关注,所以我们也以介绍V8引擎为主。V8是Google开源的高性能JavaScript引擎,用C++编写。它用于谷歌浏览器,谷歌的开源浏览器,以及Node.js等等。
速度是V8追求的主要设计目标之一,在一些性能测试中,V8比IE的JScript,Firefox中的SpiderMonkey以及Safari中的JavaScriptCore要快上数倍。相比其他的JavaScript引擎转化成字节码或解释执行,V8将其编译成本地代码,并且使用了如隐类型,内联缓存等方法来提高性能。
V8按照ECMA-262第5版中的规定实施ECMAScript,支持众多操作系统,如windows、linux、android等,也支持其他硬件架构,如IA32,X64,ARM等,具有很好的可移植和跨平台特性。
V8工作的整个过程与Java有些类似,大致分成两个阶段:第一是编译,第二是运行。与C++直接编译成本地代码不同的是,V8只有在函数调用时才会编译成本地代码,这样就提高了响应时间减少了时间开销。
图片来源《WebKit技术内幕》
在V8引擎中,源代码先通过解析器转变成抽象语法树,这点同JavaScriptCore引擎一样,不同于JavaScriptCore引擎,V8引擎中并不将抽象语法树转变成字节码或者其他中间表示,而是通过JIT全代码生成器(full code generator)从抽象语法树直接生成本地代码,这样做可以减少抽象语法树到字节码的转换时间,提高代码的执行速度,但也是因为缺少了转换为字节码这一中间过程,也就减少了优化中间代码的机会。
下面来看一下V8引擎编译JavaScript生成本地代码使用了哪些主要类:
图片来源《WebKit技术内幕》
JavaScript代码编译的过程大致为:Script类调用Compiler类的Compile函数生成本地代码。在该函数中,先使用Parser类来生成抽象语法树;再使用FullCodeGenerator类来生成本地代码。
图片来源《WebKit技术内幕》
本地代码与具体的硬件平台密切相关,FullCodeGenerator使用多个后端来生成与平台相匹配的本地汇编代码。由于FullCodeGenerator通过遍历AST来为每个节点生成相应的汇编代码,缺失了全局视图,节点之间的优化也就无从谈起。
JavaScript代码编译之前需要构建一个运行环境,所以JavaScript代码编译之前,V8引擎会构建众多对象并加载一些内置的库(如Math库)。再次强调一下,在JavaScript源码中,并非所有的函数都被编译生成本地代码,而是延时编译,在调用时才会编译。
由于V8缺少生成字节码(中间表示)这一环节,缺少必要的优化,为了性能上的考虑,V8会在生成本地代码后,使用数据分析器(Profiler)采集一些信息,然后根据这些信息对本地代码进行优化,生成更高效率的本地代码,这是一个逐步改进的过程。同时,当发现优化后的代码性能并没有提高甚至还有所降低时,V8将退回到原来的代码。这些都是在运行阶段用涉及到的技术。
现在我们来看一下运行阶段使用到的类:
图片来源《WebKit技术内幕》
首先,当某个JavaScript函数被调用时,使用编译阶段的类和操作编译生成本地代码。具体的工作方式是V8查找函数是否已经生成本地代码,如果已经生成,那么直接使用这个函数。否则,V8引擎会触发生成本地代码,这样的工作方式可以节约时间,减少去处理那些使用不到的代码的时间。其次,执行编译后的代码为JavaScript构建JS对象,这需要Runtime类来辅助创建对象,并需要从Heap类分配内容。再次,借助Runtime类中的辅助函数来完成一些功能,如属性访问,类型转换等。最后,将不用的空间进行标记清除和垃圾回收。
图片来源《WebKit技术内幕》
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都要低很多。
主要体现在以下几个部分:
偏移信息共享:
C++属于静态类型语言,不能在执行时动态改变类型,这些对象都是共享偏移信息的。访问对象时就按编译时的偏移量即可;JavaScript每个对象都是自描述,属性和位置偏移信息都包含在自身的结构中。
一个简单的C++函数:
class Class1 { int x; int y; } int add(Class1 a, Class1 b){ return a.x*a.y + b.x*b.y; }
示例代码中的类型和对象的结构表示,如下图:
图片来源《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的结构表示,如下图:
图片来源《WebKit技术内幕》
因为对象属性的访问非常普遍而且次数非常频繁,像C++这种通过偏移量来访问值使用少数两个汇编指定就能完成,但是Javascript这种通过属性名来匹配对于性能造成的影响可能会多很多倍,因为属性名匹配需要特别长的时间,而且额外浪费很多内存空间。
有方法解决这一问题么?答案是肯定的。下面我们就来看一下V8引擎是如何解决这一问题的。虽然JavaScript语言没有类型的定义,但是V8使用类和偏移位置思想,将本来需要通过字符串匹配来查找属性值的算法改进为使用类似C++编译器的偏移位置的机制来实现。这就是隐藏类(Hidden Class)。
JavaScript对象的实现在V8中包含3个成员,第一个是隐藏类的指针,这是V8为JavaScript对象创建的隐藏类。第二个指向这个对象包含的属性值。第三个指向这个对象包含的元素。
图片来源《WebKit技术内幕》
隐藏类将对象划分成不同的组,对于相同的组,也就是该组内的对象拥有相同的属性名和属性值的情况,将这些属性名和对应的偏移位置保存在一个隐藏类中,组内的所有对象共享该信息。同时,也可以识别属性不同的对象。