# JS引擎工作原理详解

发布于:2021-07-24

作为前端开发了解javascript引擎的原理和工作流程是很有必要的。

# Javascript引擎的工作原理

js-engine-pipeline.svg

Javascript引擎的工作原理基本都大同小异,整体流程上分为以下步骤:

  1. Javascript代码解析为ATS(抽象语法树)。
  2. 基于AST,解释器(interpreter )将AST转化为字节码(bytecode),这一步js引擎实际上已经在执行js代码了。
  3. 为了进一步的优化,优化编译器(optimizing compiler)将热点函数优化编译为机器指令(machine code)执行。
  4. 如果优化假设失败,优化编译器会将机器码回退到字节码。

如果对上述流程有疑问,别着急,接下来会有详细解释。

# 各大主流浏览器js引擎对比

在了解V8的具体工作流程之前,我们先来看看各大浏览器的js引擎具体的工作流程是怎样的。

上面也提到,js引擎工作的流程大致是一致的:

  • 解释器负责快速的生成没有优化过的字节码,
  • 优化编译器负责生成优化过后的机器码但是相对来说花的时间会长一些。

但是不同js引擎的优化过程和策略会有一些区别。

# V8引擎

interpreter-optimizing-compiler-v8.svg

V8 引擎用于ChromeNodeJs中。V8的解释器叫做Ignition,负责产生和执行字节码。

Ignition在执行字节码的过程中会收集分析数据用于后续的优化。例如当一个函数经常被调用执行,这个函数就会变成热点函数,这个时候字节码和分析数据就被传递给优化编译器做进一步的优化的处理。

V8的优化编译器叫做TurboFan,根据分析数据产生高度优化的机器指令。

# SpiderMonkey引擎

interpreter-optimizing-compiler-spidermonkey.svg

SpiderMonkeyMozillajs引擎,用于FirefoxSpiderNode (opens new window)

SpiderMonkey有两个优化编译器。解释器将字节码交给Baseline编译器,Baseline编译器会优化部分代码并执行,执行过程中会产生分析数据。结合分析数据IonMonkey编译器会生成高度优化的代码。如果优化失败,IonMonkey会将代码回滚到Baseline产生的代码。

# Chakra引擎

interpreter-optimizing-compiler-chakra.svg

Chakra 是微软的js引擎,用于EdgeNode-ChakraCore (opens new window)

Chakra 同样也有两个优化编译器。解释器将字节码交给SimpleJIT编译器,SimpleJIT做部分优化。结合分析数据FullJIT编译器会生成高度优化的代码。如果优化失败,FullJIT将代码回滚到字节码。

# JavaScriptCore引擎

interpreter-optimizing-compiler-jsc.svg

JavaScriptCore(缩写JSC),是苹果的js引擎用于SafariReact Native

JSC引入了三个优化编译器。LLInt解释器 (the Low-Level Interpreter) 将代码交给Baseline编译器,Baseline优化过后将代码交给DFG (Data Flow Graph)编译器,最后DFG将代码交给FTL (Faster Than Light) 编译器。

通过对比其实不难发现,js引擎的整体架构基本都是相同的,都是 parser -> interpreter -> compiler。 那为什么有些只有一个优化编译器而有些又有多个呢?其实是为了做一些权衡。

解释器(interpreter)能够快速的生成可执行的代码,但是代码的执行效率不高。编译器呢需要多花些时间来做编译优化,但是最后生成的是可以高效执行的机器码。 所以这里就需要权衡到底是要快速生成并执行还是要 多花些时间生成并高效执行,一些引擎引入多个具有不同时间/效率特性的优化编译器,以增加复杂性为代价,就是为了对这些权衡做更细粒度的控制。这中间还涉及到内存的权衡,机器码比字节码会占用更多的内存,接下来会详细讲解。

# js引擎的优化权衡

tradeoff-startup-speed.svg

js引擎在优化过程中做的权衡,核心点就是上面讲到的 解释器能够快速的生成可执行的代码,但是代码的执行效率不高。编译器呢需要多花些时间来做编译优化,但是最后生成的是可以高效执行的机器码

通过下面这个例子来看看各大浏览器的处理过程:

let result = 0;
for (let i = 0; i < 4242424242; ++i) {
	result += i;
}
console.log(result);

# v8引擎

V8Ignition解释器中生成字节码并执行,在这个执行过程中V8会收集一些多次执行函数的分析数据,这些函数叫做热点函数,紧接着就会启动TurboFan frontendTurboFan frontendTurboFan编译器的一部分,专门负责集成分析数据和构建代码的基础机器表示。随后这部分结果被发送到另一个线程上的 TurboFan 优化器(TurboFan optimizer)被进一步优化。

pipeline-detail-v8.svg

当优化器在运行时,V8继续执行Ignition编译器中的字节码。当优化完成并生成了可执行的机器码,随后V8就以机器码来接替执行。

在2021年发布的Chrome 91中,V8在IgnitionTurboFan中间增加了一个新的编译器Sparkplug

# SpiderMonkey引擎

SpiderMonkey 也是在解释器中生成字节码然后执行,它还多了一个额外的Baseline层,热点函数首先被发送到Baseline编译器 (Baseline compiler),Baseline编译器在主线程中生成Baseline code,然后以Baseline code接替执行。

Baseline code 执行一定时间后,SpiderMonkey会启动IonMonkey frontend接着在另一个线程中启动IonMonkey optimizer。这个过程就和V8非常相似了,在优化过程中还是执行Baseline code,当优化完成后就以最后优化完成的代码接替执行。

# Chakra 引擎

Chakra 引擎的做法是将编译器完全独立到一个专用的进程中。Chakra将字节码和编译器可能需要的分析数据复制出来然后发送给编译器,这样做的好处是不会阻塞主线程的执行。

SimpleJIT编译器生成代码以后Chakra就开始执行SimpleJIT的代码。同理FullJIT的处理过程也是类似。这样的好处是复制的时间通常比运行整个编译器(frontend部分)的时间短。但其缺点在于这种启发式复制可能会遗漏某些优化所需的某些信息,因此它在一定程度上是用代码质量来换取时间。

# JavaScriptCore 引擎

JavaScriptCore中编译器完全独立于主线程,主线程触发编译过程,编译器然后使用一套复杂的加锁机制从主线程获取分析数据。

pipeline-detail-javascriptcore.svg

这种方式的优点是减少了主线程上因优化而产生的阻塞,缺点是需要处理一些多线程的问题还有因为各种操作而产生的加锁消耗。

# 内存使用权衡

上面一直在讨论生成快跑得慢,生成慢跑得快的问题,实际上还有一个需要权衡的因素,那就是内存的使用。前面也简单的提到过,机器码会比字节码占用更多的内存。下面通过一个例子来看看为什么。

function add(x, y) {
	return x + y;
}

add(1, 2);

这里声明并执行了一个两数相加的函数。以V8为例,通过Ignition生成的字节码如下:

StackCheck
Ldar a1
Add a0, [0]
Return

读不懂没关系,重点是这里生成的字节码只有短短四行

如果add函数被调用多次,add函数就会变成热点函数,TurboFan 就会进一步生成高度优化的机器码,如下:

leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xe88]
jna StackOverflow
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
movq rdx,rbx
shrq rdx, 32
movq rcx,rax
shrq rcx, 32
addl rdx,rcx
jo Deoptimize
shlq rdx, 32
movq rax,rdx
movq rsp,rbp
pop rbp
ret 0x18

跟上面的字节码比起来代码量剧增,字节码和优化过的机器码比起来通常要少得多。字节码需要解释器才能执行,而优化过的机器码可以由处理器直接执行。

这也解释了js引擎为什么不优化所有的代码。一个原因是上面讲到的优化需要花一定的时间,最主要的原因还是机器码会占用更多的内存。

小结:

js引擎拥有不同的优化层是为了权衡生成快跑得慢,生成慢跑得快的问题。使用多个优化层可以做出更细粒度的决策,但是会引入额外的复杂性和开销。另外还需要权衡优化级别和代码的内存占用。这也解释了为什么JS引擎只会尝试优化热点函数的原因。

# V8引擎工作流程详解

再通过一张图来回顾一下V8 引擎工作的基本流程: 接下来详细的分析一下这个过程

# 词法分析

解析过程实际上分为两步: 扫描器(scanner 做词法分析,语法分析器(parser 做语法分析。

我们知道JS代码实际上只是一串字符串,机器是无法直接执行的,需要经过一系列的转换。词法分析就是把代码中的字符串分割出来,生成一系列的token

token: 词义单位,是指语法上不能再分割的最小单位,可能是单个字符,也可能是一个字符串。

那具体的token长什么样呢,通过一个例子来说明:

function foo() {
  let bar = 1;
  return bar;
}

这段代码经过词法分析过程生成如下token列表:

[
    {
        "type": "Keyword",
        "value": "function"
    },
    {
        "type": "Identifier",
        "value": "foo"
    },
    {
        "type": "Punctuator",
        "value": "("
    },
    {
        "type": "Punctuator",
        "value": ")"
    },
    {
        "type": "Punctuator",
        "value": "{"
    },
    {
        "type": "Keyword",
        "value": "let"
    },
    {
        "type": "Identifier",
        "value": "bar"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    },
    {
        "type": "Keyword",
        "value": "return"
    },
    {
        "type": "Identifier",
        "value": "bar"
    },
    {
        "type": "Punctuator",
        "value": ";"
    },
    {
        "type": "Punctuator",
        "value": "}"
    }
]

可以看到实际上就是把代码做了拆分,拆分成不同类型的表示。

可以通过esprima (opens new window)做在线解析

# 语法分析 (parser

词法分析完成后,紧接着就是进行语法分析。语法分析的输入就是词法分析的输出,输出是AST抽象语法树AST是表示token关系的一棵树,它是源代码语法结构的一种抽象表示,它以树状的形式表现语法结构,树上的每个节点都表示代码中的一种结构。当代码中有语法错误的时候,V8在语法分析阶段抛出异常。

还是上面那段代码,生成的AST如下:

{
  "type": "Program",
  "body": [
    {
      "type": "FunctionDeclaration",
      "id": {
        "type": "Identifier",
        "name": "foo"
      },
      "params": [],
      "body": {
        "type": "BlockStatement",
        "body": [
          {
            "type": "VariableDeclaration",
            "declarations": [
              {
                "type": "VariableDeclarator",
                "id": {
                  "type": "Identifier",
                  "name": "bar"
                },
                "init": {
                  "type": "Literal",
                  "value": 1,
                  "raw": "1"
                }
              }
            ],
            "kind": "let"
          },
          {
            "type": "ReturnStatement",
            "argument": {
              "type": "Identifier",
              "name": "bar"
            }
          }
        ]
      },
      "generator": false,
      "expression": false,
      "async": false
    }
  ],
  "sourceType": "script"
}

后续会专门针对AST写一片文章详解。

# 解释器 (Ignition

生成AST树后,就是根据AST来生成字节码,这一步由V8Ignition编译器来完成。同时Ignition会执行字节码。在这个执行过程中,如果一个函数被调用多次,这个函数就会被标记为热点函数,并将该函数的字节码以及执行的相关信息发送给TurboFan进行优化处理。

这一步的特点是:V8可快速生成字节码,但是字节码的执行效率不高。

# 编译器(TurboFan

TurboFan会根据执行信息做出进一步优化代码的假设,在假设的基础上将字节码编译为优化的机器代码。如果假设成立,那么当下一次调用该函数时,就会执行优化编译后的机器代码,以提高代码的执行性能。如果假设失败就会进行回退操作,就是上图中Deoptimize 这一步,把机器码还原为字节码。

那什么是假设? 什么要进行回退呢?还是通过一个例子来阐述:

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

JavaScript动态类型的语言,这里ab可以是任意类型数据,当执行sum函数时,Ignition解释器会检查ab的数据类型,并相应地执行加法或者连接字符串的操作。

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

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

相信看到这里大家已经对浏览器大概的执行流程有了一个宏观上的认识了。

# 参考资料