阅读 49

V8 执行原理

概述

我们写的JavaScript代码直接交给浏览器或者Node执行时,底层的CPU是不认识的,也没法执行。CPU只认识自己的指令集,指令集对应的是汇编代码。

JavaScirpt引擎可以将JS代码编译为不同CPU(Intel, ARM以及MIPS等)对应的汇编代码

V8 如何执行一段 JS 代码?

image

V8 的内部结构

V8是一个非常复杂的项目,超过100万行C++代码。

V8由许多子模块构成,其中这4个模块是最重要的:

  • Parser:解析器负责将JavaScript源码转换为Abstract Syntax Tree (AST)

  • Ignition:interpreter,即解释器,负责将AST转换为Bytecode,解释执行Bytecode;同时收集TurboFan优化编译所需的信息,比如函数参数的类型;

  • TurboFan:compiler,即编译器,利用Ignitio所收集的类型信息,将Bytecode转换为优化的汇编代码;

  • Orinoco:garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收;

其中,Parser,Ignition以及TurboFan可以将JS源码编译为汇编代码,其流程图如下:

image-20200723173910273.png

简单地说,Parser解析器将JS源码转换为AST,然后Ignition将AST转换为Bytecode,最后TurboFan将Bytecode转换为经过优化的Machine Code(实际上是汇编代码)。

  • 如果函数没有被调用,则V8不会去编译它。

  • 如果函数只被调用1次,则Ignition将其编译Bytecode就直接解释执行了。TurboFan不会进行优化编译,因为它需要Ignition收集函数执行时的类型信息。这就要求函数至少需要执行1次,TurboFan才有可能进行优化编译。

  • 如果函数被调用多次,则它有可能会被识别为热点函数,且Ignition收集的类型信息证明可以进行优化编译的话,这时TurboFan则会将Bytecode编译为Optimized Machine Code,以提高代码的执行性能。

图片中的红线是逆向的,Optimized Machine Code会被还原为Bytecode,这个过程叫做Deoptimization。这是因为Ignition收集的信息可能是错误的,比如add函数的参数之前是整数,后来又变成了字符串。生成的Optimized Machine Code已经假定add函数的参数是整数,那当然是错误的,于是需要进行Deoptimization。

function add(x, y) {    
     return x + y;
}
add(1, 2);
add("1", "2")
复制代码

在运行C、C++以及Java等程序之前,需要进行编译,不能直接执行源码;但对于JavaScript来说,我们可以直接执行源码(比如:node server.js),它是在运行的时候先编译再执行,这种方式被称为即时编译(Just-in-time compilation),简称为JIT。因此,V8也属于JIT编译器。

结合上图,V8 执行代码可分为以下几步

Parser解析器 生成 AST 和执行上下文

将源代码转换为编译器和解释器可以理解的 AST,并生成执行上下文。

  • 为什么要生成 AST 以及什么是 AST?第一,因为编译器和解释器无法理解高级语言,所以要生成 AST,就像渲染引擎将 HTML 转换为计算机可以理解的 DOM 树一样。
    第二,AST 是代码的结构化表示,有着非常重要的作用。 Babel 原理: 将 ES6 源码转换为 AST,再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用它来生成 ES5 源代码。 ESLint 原理:检测流程也是将源码转换为 AST,再利用 AST 来检查代码规范。

  • 如何生成 AST?两个阶段:分词(词法分析),解析(语法分析)。
    词法分析:将源码拆解成最小的、不可再分的 token(关键字、标识符、赋值、字符串)。
    image
    语法分析:根据规则将上一步的 token 转换为 AST。若源码存在语法错误,则不会生成 AST。
    查看AST转换模型

Ignition:解释器

有了 AST 和执行上下文后,解释器根据 AST 生成字节码并解释执行。

字节码:介于 AST 与机器码之间的一种代码,需要通过解释器将其转为机器码后才能执行。

node命令提供了很多V8引擎的选项,使用node的--print-bytecode选项,可以打印出Ignition生成的Bytecode。

test.js如下,由于V8不会编译没有被调用的函数,因此需要在最后一行调用test函数。

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

test(10); // V8不会编译没有被调用的函数,因此这一行不能省略
复制代码

使用node命令(node版本为12.6.0)的--print-bytecode选项,打印出Ignition生成的Bytecode:

node --print-bytecode test.js
复制代码

image-20200723190754260.png

Bytecode某种程度上就是汇编语言,只是它没有对应特定的CPU,或者说它对应的是虚拟的CPU。这样的话,生成Bytecode时简单很多,无需为不同的CPU生产不同的代码。要知道,V8支持9种不同的CPU,引入一个中间层Bytecode,可以简化V8的编译流程,提高可扩展性。

TurboFan:编译器

第一次执行字节码时,解释器会逐条解释执行,如果发现有热点代码(即一段代码被重复执行多次),编译器就会将这段热点代码编译为机器码保存起来,当再次执行这段代码时,只需要执行编译后的机器码;这种技术就叫做即时编译(JIT)

image

使用node命令的--print-code以及--print-opt-code选项,打印出TurboFan生成的汇编代码:

node --print-code --print-opt-code test.js
复制代码

image-20200723190828954.png

比起Bytecode,正真的汇编代码可读性差很多。而且,机器的CPU类型不一样的话,生成的汇编代码也不一样。

这些汇编代码就不用去管它了,因为最重要的是理解TurboFan是如何优化所生成的汇编代码的。

如果我们的JS代码中变量的类型变来变去,是会给V8引擎增加不少麻烦的,为了提高性能,我们可以尽量不要去改变变量的类型。

对于性能要求比较高的项目,使用TypeScript也是不错的选择,理论上,如果严格遵守类型化的编程方式,也是可以提高性能的,类型化的代码有利于V8引擎优化编译的汇编代码,当然这一点还需要测试数据来证明。

Orinoco:垃圾回收

对于写代码来说,也有垃圾回收(garbage collection)这个问题,这里所说的垃圾,指的是程序中不再需要的内存空间,垃圾回收指的是回收这些不再需要的内存空间,让程序可以重新利用这些释放的内存空间。

首先,对于系统栈来说,它的功能除了保存变量之外,还有创建并切换函数执行上下文的功能。举个例子:

function f(a) {
  console.log(a);
}

function func(a) {
  f(a);
}

func(1);
复制代码

假设用ESP指针来保存当前的执行状态,在系统栈中会产生如下的过程:

  1. 调用func, 将 func 函数的上下文压栈,ESP指向栈顶。

  2. 执行func,又调用f函数,将 f 函数的上下文压栈,ESP 指针上移。

  3. 执行完 f 函数,将ESP 下移,f函数对应的栈顶空间被回收。

  4. 执行完 func,ESP 下移,func对应的空间被回收。

图示如下:

image

如果采用栈来存储相对基本类型更加复杂的对象数据,那么切换上下文的开销将变得巨大!

JS 中的垃圾数据分为:栈中的垃圾数据与堆中的垃圾数据

以下面这段代码为例分析:

function foo() {
  var a = 1;
  var b = { name: "极客邦" };
  function showName() {
    var c = "极客时间";
    var d = { name: "极客时间" };
  }
  showName();
}
foo();
复制代码

image

栈中的垃圾回收

通过 ESP 指针(即记录当前执行状态的指针)来操作,ESP 指向 showName 的函数上下文时,表示当前正在执行 showName 函数。

当 showName 执行完毕后,ESP 下移指向 foo 的执行上下文,这个操作就是销毁 showName 的函数执行上下文。

下图为从栈中回收 showName 执行上下文的过程

image

showName 虽然仍保留在栈中,但已属于无效内存。当 foo 函数再次调用另外一个函数时,showName 执行上下文会被覆盖掉。

堆中的垃圾回收

当 foo 执行结束后,ESP 就指向全局上下文了,此时内存状态如下图。

image

从图中可以看出,堆中的垃圾并没有被回收。 要想回收堆中的垃圾,就要用到 JS 的垃圾回收器了。 下面先介绍下垃圾回收领域的术语。

代际假说与分代收集
  • 代际假说的两个特点:
  1. 大部分对象在内存中存在时间很短,即很多对象一经分配内存,很快就变得不可访问。

  2. 不死的对象,会活的更久。

在 V8 中把堆分为新生代老生代两个区域,新生代存放生存时间很短的对象,容量只有 1~8M;老生代中存放生存时间久的对象,容量很大。

对应的就有两种垃圾回收器,副垃圾回收器与主垃圾回收器。 主垃圾回收器:主要负责老生代的垃圾回收。 副垃圾回收器:主要负责新生代的垃圾回收。

垃圾回收器的工作流程

两种回收器有一套共同的执行流程。

  1. 标记空间中的活动对象与非活动对象。活动对象即还在使用的对象,非活动对象就是可以进行垃圾回收的对象。

  2. 回收非活动对象占用的内存。在所有对象标记完后,统一清理内存中被标记为可回收的对象。

  3. 整理内存。频繁回收会导致内存空间不连续,即有很多内存碎片。当要分配较大的连续内存时,就会出现内存不足的情况;所以需要整理碎片。(副垃圾回收器不会产生内存碎片,故不需要这步。)

副垃圾回收器(GC:garbage collect)

主要用于回收新生代的垃圾,故内存不大。 下图为 V8 中的堆空间分布。

image

新生代通过 Scavenge 算法将空间划分为对象区域与空闲区域。 每当对象区域被写满时,就会执行一次垃圾回收,具体过程如下:

  1. 先对对象区域中的垃圾做标记;

  2. 标记完成后,GC 将非活动的对象回收,将存活的对象复制到空闲区域中,同时将对象进行有序排列。(相当于内存整理)

  3. 完成复制后,再将对象区域与空闲区域进行反转,这样就完成了垃圾回收。

由于复制操作需要时间成本且操作频繁,所以为了执行效率,新生代的空间都不会太大;若经过两次 GC 回收依然存活,就会将活着的对象移到老生代中,这就是 JS 引擎的对象晋升策略

主垃圾回收器

主要用于回收老生代的垃圾,除了新生代中晋升的对象,一些大的对象会被直接分配到老生代。

老生代对象的两个特点:

  1. 占用空间大

  2. 存活时间长

基于上述两个特点,主垃圾回收器采用标记-清除(Mark-Sweep)的算法进行垃圾回收。

标记:从一组根元素开始,递归遍历这组根元素,能到达的元素成为活动对象,没有到达的为垃圾数据

function foo() {
  var a = 1;
  var b = { name: "极客邦" };
  function showName() {
    var c = "极客时间";
    var d = { name: "极客时间" };
  }
  showName();
}
foo();
复制代码

还是这段代码,当 showName 函数退出后,调用栈和堆空间如下图:

image

从上图可以看出,当 showName 执行结束后,ESP 执行 foo 的执行上下文,此时遍历调用栈,不会找到引用 1003 地址的变量,意味着 1003 这块数据为垃圾数据,被标记为红色;而 1050 被 b 引用了,所以会被标记为活动对象。

下图为垃圾清除的过程

image

对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。于是又出现了另一种算法标记-整理(Mark-Compact),标记过程都是一样的,标记完后将所有的活动对象都向一端移动,然后直接清除掉端边界以外的内存。

image

全停顿与增量标记

全停顿:JS 是运行在主线程之上的,一旦执行垃圾回收,就需要将正在执行的 JS 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行,这期间应用的性能和响应能力都会直线下降。这个过程就叫做全停顿(Stop-The-World)。

image

V8 新生代垃圾回收因为空间小,存活对象少,全停顿影响不大;但老生代就不一样了,比如正在执行 JS 动画,GC 工作导致主线程不能做其他事情,那个动画在这段时间无法执行,页面就会卡顿。

为降低卡顿,于是诞生了增量标记(Incremental Marking)算法。 增量标记:V8 将标记过程分为一个个子标记过程,同时让垃圾回收器和 JS 应用逻辑交替进行,直至标记完成。这些小任务执行时间短,穿插再 JS 任务间执行,这样就不会感受到页面卡顿了。

image

JS引擎的未来

V8引擎确实很强大,但是它也不是无所不能的,简单地分析都可以发现一些可以优化的点。

  • 使用TypeScript编程,遵循严格的类型化编程规则,不要写成AnyScript了;

  • 构建的时候将TypeScript直接编译为Bytecode,而不是生成JS文件,这样运行的时候就省去了Parse以及生成Bytecode的过程;

  • 运行的时候,需要先将Bytecode编译为对应CPU的汇编代码;

  • 由于采用了类型化的编程方式,有利于编译器优化所生成的汇编代码,省去了很多额外的操作;

这些其实可以基于V8引擎来实现,技术上应该是可行的:

  • 将Parser以及Ignition拆分出来,用于构建阶段;

  • 删掉TurboFan处理JS动态特性的相关代码;

这样做,可以将JS引擎简化很多,一方面不再需要parse以及生成bytecode,另一方面编译器不再需要因为JavaScript动态特性做很多额外的工作。因此可以减少CPU、内存以及电量的使用,优化性能,唯一的问题可能是必须使用严格的TS语法进行编程。