9
JavaScript 是如何工作的系列——第二篇

前言

在上一篇《JavaScript 从下载到执行(阻塞、defer、async)》中介绍了浏览器是何时开始下载和执行 JavaScript 的 ,以及阻塞 HTML 解析问题。本篇文章将深入 JavaScript 引擎,了解 JavaScript 引擎(V8)是如何执行 JavaScript 代码的。

什么是 JavaScript 引擎

我们都知道 JavaScript 代码是不能直接在 CPU 中运行的,需要将 JavaScript 代码翻译成机器可以识别的指令(二进制码)。那么谁来翻译呢?没错,就是 JavaScript 引擎。当然 JavaScript 引擎并不是简单的将 JavaScript 代码翻译成 CPU 可执行的机器代码,这中间还涉及了很多优化策略。
Image  7

JavaScript 引擎是一个负责整个 JavaScript 程序执行的应用程序,它是浏览器引擎的一部分。现在也被应用于 Node.js 中。

大多数主流的浏览器对于 JavaScript 引擎都有自己的实现:
Image  8

本篇文章将以目前最为流行的 Chrome 浏览器的 V8 引擎为例,进行介绍。

V8 引擎

下图展示了 V8 引擎工作的基本流程:
Image  9

其中 Parser(解析器)、Ignition(解释器)、TurboFan(编译器) 是 V8 中三个主要的工作模块,除此之外还有一个主要的工作模块是 Orinoco(垃圾回收)。

工作流程概述:

  • 首先 V8 引擎会扫描所有的源代码,进行词法分析,生成 Tokens;

Image  10

  • Parser 解析器根据 Tokens 生成 AST;
  • Ignition 解释器将 AST 转换为字节码,并解释执行;
  • TurboFan 编译器负责将热点函数优化编译为机器指令执行;

下面我们将详细介绍这四个步骤。

词法分析

V8 引擎首先会扫描所有的源代码,进行词法分析(词法分析是通过 Scanner 模块来完成的,本文不进行详细介绍)。

什么是词法分析?

词法分析(Tokenizing/Lexing)就是将程序源代码分解成对编程语言来说有意义的代码块,这些代码块被称为词法单元(token)

我们来看下 var a = 2; 这句代码经过词法分析后会被分解出哪些 tokens ?
Image  11

从上图中可以看到,这句代码最终被分解出了五个词法单元:

  • var 关键字
  • a 标识符
  • = 运算符
  • 2 数值
  • 分号
Tokens 在线查看网站:https://esprima.org/demo/pars...

语法分析

Parser

Parser 是 V8 的解析器,负责根据生成的 Tokens 进行语法分析。Parser 的主要工作包括:

  • 分析语法错误:遇到错误的语法会抛出异常;
  • 输出 AST:将词法分析输出的词法单元流(数组)转换为一个由元素逐级嵌套所组成的代表了程序语法结构的树——抽象语法树(Abstract Syntax Tree, AST);
  • 确定词法作用域

词法作用域相关内容,敬请期待文章《JavaScript 之作用域》

我们简单介绍下什么是抽象语法树(Abstract Syntax Tree, AST)?

还是上面的例子,我们来看下 var a = 2; 经过语法分析后生成的AST是什么样子的:
Image  12

Image  13
可以看到这段程序的类型是 VariableDeclaration,也就是说这段代码是用来声明变量的。

AST 在线查看网站:https://astexplorer.net/

Pre-Parser

什么是预解析 Pre-Parser?

我们先来看看下面这段代码:

function foo () {
    console.log('I\'m function foo')
}

function bar () {
    console.log('I\'m function bar')
}

foo()

上面这段代码中,如果使用 Parser 解析后,会生成 foo 函数 和 bar 函数的 AST。然而 bar 函数并没有被调用,所以生成 bar 函数的 AST 实际上是没有任何意义且浪费时间的。那么有没有办法解决呢?此时就用到了 Pre-Parser 技术。

在 V8 中有两个解析器用于解析 JavaScript 代码,分别是 Parser 和 Pre-Parser 。

  • Parser 解析器又称为 full parser(全量解析) 或者 eager parser(饥饿解析)。它会解析所有立即执行的代码,包括语法检查,生成 AST,以及确定词法作用域。
  • Pre-Parser 又称为惰性解析,它只解析未被立即执行的代码(如函数),不生成 AST ,只确定作用域,以此来提高性能。当预解析后的代码开始执行时,才进行 Parser 解析。

我们还是以示例来说明:

function foo() {
    console.log('a');
    function inline() {
        console.log(''b)
    }
}

(function bar() {
    console.log('c')
})();

foo();
  1. 当 V8 引擎遇到 foo 函数声明时,发现它未被立即执行,就会采用 Pre-Parser 对其进行解析(inline 函数同)。
  2. 当 V8 遇到(function bar() {console.log(c)})()时,它会知道这是一个立即执行表达式(IIFE),会立即被执行,所以会使用 Parser 对其解析。
  3. 当 foo 函数被调用时,会使用 Parser 对 foo 函数进行解析,此时会对 inline 函数再进行一次预解析,也就是说 inline 函数被预解析了两次。如果嵌套层级较深,那么内层的函数会被预解析多次,所以在写代码时,尽可能避免嵌套多层函数,会影响性能。

Ignition

Ignition 是 V8 的解释器,它负责的工作包括:

  • 将 AST 转换为中间代码(字节码 Bytecode)
  • 逐行解释执行字节码:在该阶段,就已经开始执行 JavaScript 代码了。

什么是字节码?

字节码(Bytecode)是一种中间码,它比机器码更抽象,也更轻量,需要直译器转译后才能成为机器码的中间代码。

Image  14

早期版本的 V8 ,并没有生成中间字节码的过程,而是将所有源码转换为了机器代码。机器代码虽然执行速度更快,但是占用内存大。

TurboFan

TurboFan 是 V8 的优化编译器,负责将字节码和一些分析数据作为输入并生成优化的机器代码。

上面我们说到,当 Ignition 将 JavaScript 代码转换为字节码后,程序就可以执行了,那么 TurboFan 还有什么用呢?

我们再来看下 V8 的工作流程图:
Image  15

我们主要关注 Ignition 和 TurboFan 的交互:
Image  16

当 Ignition 开始执行 JavaScript 代码后,V8 会一直观察 JavaScript 代码的执行情况,并记录执行信息,如每个函数的执行次数、每次调用函数时,传递的参数类型等。

如果一个函数被调用的次数超过了内设的阈值,监视器就会将当前函数标记为热点函数(Hot Function),并将该函数的字节码以及执行的相关信息发送给 TurboFan。TurboFan 会根据执行信息做出一些进一步优化此代码的假设,在假设的基础上将字节码编译为优化的机器代码。如果假设成立,那么当下一次调用该函数时,就会执行优化编译后的机器代码,以提高代码的执行性能。

那如果假设不成立呢?不知道你们有没有注意到上图中有一条由 optimized code 指向 bytecode 的红色指向线。此过程叫做 deoptimize(优化回退),将优化编译后的机器代码还原为字节码。

读到这里,你可能有些疑惑:这个假设是什么假设呢?以及为什么要优化回退?我们来看下面的例子。

function sum (a, b) {
    return a + b;
}

我们都知道 JavaScript 是基于动态类型的,a 和 b 可以是任意类型数据,当执行 sum 函数时,Ignition 解释器会检查 a 和 b 的数据类型,并相应地执行加法或者连接字符串的操作。

如果 sum 函数被调用多次,每次执行时都要检查参数的数据类型是很浪费时间的。此时 TurboFan 就出场了。它会分析监视器收集的信息,如果以前每次调用 sum 函数时传递的参数类型都是数字,那么 TurboFan 就预设 sum 的参数类型是数字类型,然后将其编译为机器指令。

但是当某一次的调用传入的参数不再是数字时,表示 TurboFan 的假设是错误的,此时优化编译生成的机器代码就不能再使用了,于是就需要进行优化回退。

Orinoco

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

下一篇

本篇文章主要介绍了 JavaScript 引擎(V8)执行 JavaScript 代码的工作流程,主要涉及了Parser、Ignition、TurboFan、 Orinoco 四个模块。下篇文章将开始讲解 JavaScript 执行机制中的核心概念——执行上下文。

传送门《JavaScript 之执行上下文》

参考:

The Journey of JavaScript: from Downloading Scripts to Execution - Part
The Journey of JavaScript: from Downloading Scripts to Execution - Part II
视野前端(二)V8引擎是如何工作的
What is V8?
JavaScript 引擎 V8 执行流程概述
Ignition: An Interpreter for V8 [BlinkOn]
How JavaScript works: an overview of the engine, the runtime, and the call stack

Jojo
126 声望12 粉丝

Stick a little bit more every day