Js V8引擎-初识

1,479 阅读9分钟

V8 是什么

JavaScript一种解释性脚本语言,是一种动态类型、弱类型、基于原型的语言,内置支持类型。它的解释器被称为JavaScript引擎,为浏览器的一部分。

V8引擎就是JavaScript运行的解释器。

解释性语言

  • 源代码不能直接翻译成机器语言,而是先翻译成中间代码,再由解释器对中间代码进行解释运行

  • 程序不需要编译,程序在运行时才翻译成机器语言,每执行一次都要翻译一次;解释性语言代表:PythonJavaScriptShellRubyMATLAB 等;

编译性语言

  • 只须编译一次就可以把源代码编译成机器语言,后面的执行无须重新编译,直接使用之前的编译结果就可以;因此其执行的效率比较高;编译性语言代表:CC++

来源

V8的名字来源于汽车的“V型8缸发动机”(V8发动机)。V8发动机主要是美国发展起来,因为马力十足而广为人知。V8引擎的命名是Google向用户展示它是一款强力并且高速的JavaScript引擎。

背景

V8未诞生之前,早期主流的JavaScript引擎是JavaScriptCore引擎。JavaScriptCore是主要服务于Webkit浏览器内核,他们都是由苹果公司开发并开源出来。因为Google不满意JavaScriptCoreWebkit的开发速度和运行速度,Google另起炉灶开发全新的JavaScript引擎和浏览器内核引擎,所以诞生了V8和Chromium两大引擎,到现在已经是最受欢迎的浏览器相关软件。

JavaScriptCore && V8 编译

V8

编译阶段的过程是:sourceCode -> AST -> 本地代码。其中从抽象语法树到本地代码的过程使用的是JIT全码生成器,其作用是将抽象语法树转换成各个硬件平台可直接运行的本地代码。是不是很像C/C++?

JavaScriptCore

编译阶段的过程是:sourceCode -> AST -> 字节码 -> 本地代码。对这个阶段类似Java的编译过程,只是这里没有充裕的时间做优化。于是大量的字节码优化措施被延后,比如JIT。JavaScriptCore引擎使用DFG JIT、LLVM等继续对字节码做优化。

服务对象

V8是依托Chrome发展起来的,因为性能十分优秀,所以后来慢慢的也就被广泛的使用,例如流行的nodejs,weex,快应用,早期的RN。

V8早期架构

V8主要在速度和内存回收上进行优化的。

JavaScriptCore的架构是采用生成字节码的方式,然后执行字节码。Google觉得JavaScriptCore这套架构不行,生成字节码会浪费时间,不如直接生成机器码快。所以V8在前期的架构设计上是非常激进的,采用了直接编译成机器码的方式。后期的实践证明Google的这套架构速度是有改善,但是同时也造成了内存消耗问题。可以看下V8的初期流程图:

早期的V8Full-CodegenCrankshaft两个编译器。V8 首先用 Full-Codegen把所有的代码都编译一次,生成对应的机器码。JS在执行的过程中,V8内置的Profiler筛选出热点函数并且记录参数的反馈类型,然后交给 Crankshaft 来进行优化。所以Full-Codegen本质上是生成的是未优化的机器码,而Crankshaft生成的是优化过的机器码。

缺陷

随着版本的引进,网页的复杂化,V8也渐渐的暴露出了自己架构上的缺陷:

  • Full-Codegen编译直接生成机器码,导致内存占用大
  • Full-Codegen编译直接生成机器码,导致编译时间长,导致启动速度慢
  • Crankshaft 无法优化try catchfinally 等关键字划分的代码块
  • Crankshaft新加语法支持,需要为此编写适配不同的Cpu架构代码

V8 现有架构

为了解决上述缺点,V8采用JavaScriptCore的架构,生成字节码。这里是不是感觉Google又绕回来了。V8采用生成字节码的方式,整体流程如下图:

IgnitionV8的解释器,背后的原始动机是减少移动设备上的内存消耗。在Ignition之前,V8Full-Codegen基线编译器生成的代码通常占据Chrome整体JavaScript堆的近三分之一。这为Web应用程序的实际数据留下了更少的空间。

Ignition生成的字节码可以直接用TurboFan生成优化后的机器码,而不必像Crankshaft那样从源代码重新编译。Ignition的字节码在V8中提供了更清晰且更不容易出错的基线执行模型,简化了去优化机制,这是V8 自适应优化的关键特性。最后,由于生成字节码比生成Full-Codegen的基线编译代码更快,因此激活Ignition通常会改善脚本启动时间,从而改善网页加载。

TurboFanV8的优化编译器,TurboFan项目最初于2013年底启动,旨在解决Crankshaft的缺点。Crankshaft只能优化JavaScript语言的子集。例如,它不是设计用于使用结构化异常处理优化JavaScript代码,即由JavaScript的try,catch和finally关键字划分的代码块。很难在Crankshaft中添加对新语言功能的支持,因为这些功能几乎总是需要为九个支持的平台编写特定于体系结构的代码。

采用新架构的优势

结论:可以明显看出Ignition+TurboFan架构比Full-Codegen+Crankshaft架构内存降低一半多。

接下来我们大致的讲解下现有架构的每个流程:

V8的词法分析和语法分析

JS文件只是一个源码,机器是无法执行的,词法分析就是把源码的字符串分割出来,生成一系列的token,如下图可知不同的字符串对应不同的token类型。

词法分析完后,接下来的阶段就是进行语法分析。语法分析语法分析的输入就是词法分析的输出,输出是AST抽象语法树。当程序出现语法错误的时候,V8在语法分析阶段抛出异常。

V8 AST抽象语法树 AST对象文档

树结构

V8 Parse阶段后,接下来就是根据抽象语法树生成字节码。如下图可以看出add函数生成对应的字节码:

BytecodeGenerator类的作用是根据抽象语法树生成对应的字节码,不同的node会对应一个字节码生成函数,函数开头为Visit**。如下图+号对应的函数字节码生成:

void BytecodeGenerator::VisitArithmeticExpression(BinaryOperation* expr) {
  FeedbackSlot slot = feedback_spec()->AddBinaryOpICSlot();
  Expression* subexpr;
  Smi* literal;
  
  if (expr->IsSmiLiteralOperation(&subexpr, &literal)) {
    VisitForAccumulatorValue(subexpr);
    builder()->SetExpressionPosition(expr);
    builder()->BinaryOperationSmiLiteral(expr->op(), literal,
                                         feedback_index(slot));
  } else {
    Register lhs = VisitForRegisterValue(expr->left());
    VisitForAccumulatorValue(expr->right());
    builder()->SetExpressionPosition(expr);  //  保存源码位置 用于调试
    builder()->BinaryOperation(expr->op(), lhs, feedback_index(slot)); //  生成Add字节码
  }
}

上述可知有个源码位置记录,然后下图可知源码和字节码位置的对应关系:

字节码

Ignition引擎可以对字节码进行解释执行,那就是说他的功能类似于Java的JVM,本质上就是一个虚拟机。

虚拟机通常有两种分别是基于Stack(栈)的和基于Register(寄存器)的, 比如基于Stack的虚拟机有JVM,是一种比较广泛的实现方法,而我们V8引擎中的Ignition是基于Register的,也就是基于寄存器的虚拟机,通常基于Register的虚拟机比基于Stack的虚拟机执行的更快,但是指令相对较长。

首先说下V8字节码:

  1. 每个字节码指定其输入和输出作为寄存器操作数
  2. Ignition 使用register寄存器 r0,r1,r2... 和累加器寄存器(accumulator register)
  3. register寄存器:函数参数和局部变量保存在用户可见的寄存器中

累加器:是非用户可见寄存器,用于保存中间结果

字节码执行

  1. 先将源码转换成字节码
  1. 进行函数 f 的初始化工作
  1. 将小整数 -100 存储到累加器中,LdaSmi 可以理解为一个定义好的handle函数 后面接的 #100 就是这个函数的参数
  1. 将a2中存储的150和累加器中的值求和,并将结果存于累加器 。
  1. 将累加器中存储的50保存到寄存器r0中,此时r0的值为50。
  1. 将寄存器a1也就是参数b的值存储到累加器中,此时累加器值为2。(a0、a1、a2 也是寄存器)
  1. 将寄存器r0中的值和累加器中的值求乘积,并将结果存于累加器。
  1. 将寄存器a0中的值和累加器中的值求和,并将结果存于累加器。
  1. Return (包括上面的语句)本身都是定义好的handle函数,Return代表的就是将累加器中的值返回。

随着V8版本的更新,不同的版本生成的字节码结构可能有细微差别,但是这些定义好的函数一般不会有太大变化。

Turbofan

TurboFan是根据字节码和热点函数反馈类型生成优化后的机器码,TurboFan很多优化过程,基本和编译原理的后端优化差不多,采用的sea-of-node

add函数优化:

function add(x, y) {
  return x+y;
}
add(1, 2);
%OptimizeFunctionOnNextCall(add);
add(1, 2);

V8是有函数可以直接调用指定优化哪个函数,执行%OptimizeFunctionOnNextCall主动调用TurboFan优化add函数,根据上次调用的参数反馈优化add函数,很明显这次的反馈是整型数,所以TurboFan会根据参数是整型数进行优化直接生成机器码,下次函数调用直接调用优化好的机器码。(注意执行V8需要加上 --allow-natives-syntax,OptimizeFunctionOnNextCall为内置函数,只有加上 --allow-natives-syntax,JS才能调用内置函数 ,否则执行会报错)。

JS的add函数生成对应的机器码如下:

垃圾回收

V8中所有的对象都是通过堆来分配的,当代码声明变量并且赋值时,该对象的内存就分配到堆中。如果堆内存不够,就继续申请内存,知道大小达到V8限制为止。此时就会触发V8的垃圾回收动作。

V8采取了一种分代回收的策略,即将堆内存划分为不同的生代,根据各个生代的特点执行不同的垃圾回收算法。V8里主要会处理新生代和老生代两个分区。

新生代特点是区域小、回收频繁。主要采用Scavenge算法,利用空间换时间。 老生代特点是对象生命周期长,占用内存较多。主要采用Mark-Sweep和Mark-Compact相结合的策略,节省空间。

参考

  1. V8.dev/docs
  2. tc39.es/ecma262/#se…
  3. V8字节码表