# JS 编译器,解释引擎

这一章来巩固一下 js 底层知识,编译器和解释引擎。

  • 编译过程
  • 实战分析
  • JIT 优化

# 编译过程

  • 词法分析
  • 语法分析
  • 代码生成
  • 预编译
  • 解释器引擎执行

接下来的案例均根据以下代码进行。

var a = 10;
1

# 词法分析

把输入的字符串分解为一些对编程语言有意义的代码块(词法单元)。

例如上文案例会被解析成 vara=10

# 语法分析

这个过程会将词法单元转换成一棵抽象语法树(Abstract Syntax Tree,AST)。

# 代码生成

将 AST 代码转换为可执行代码。简单来说,就是将 AST 转化为一组机器指令,用来创建一个叫做 a 的变量(包括分配内存等),并将一个值 10 存储在 a 中。

# 预编译

在引擎执行代码之前,在当前作用域下,会把带有 var 和 function 关键字的事先声明,并保存在当前作用域内存中(这也就是变量声明提升和函数声明提升的原因)。

变量和函数在内的声明都在任何代码执行前被处理。

函数声明优先于变量声明提升,出现在后面的函数声明可以覆盖之前的声明。

在预编译阶段会执行以下操作:

  • 在编译器,遇到 var a 时,会查找同一作用域是否存在同名变量。
    • 如果存在,则忽略该变量声明。
    • 如果不存在,则会在当前作用域中创建一个 a 变量,

# 解释器引擎执行

解释器引擎,常用的就是 Chrome V8,和 Firefox SpiderMonkey 引擎。

  • 在引擎执行 a = 10 代码时,会查找同一作用域是否存在 a 变量。
    • 如果存在,则进行赋值操作。
    • 如果不存在,则会在上层作用域中去寻找 a。
    • 如果都找不到 a,那就在全局作用域去创建 a,并赋值(严格模式会报错)。

# 作用域

负责收集并维护所有的标识符(变量)。在 ES6 以前没有块级作用域,只有函数级作用域,但 catch 模块除外。

在编译器编译变量声明时,或者解释器引擎使用变量时,有一个 LHS 和 RHS 概念。

  • LHS 可以理解为变量赋值等号左边的部分,用于查询变量是否声明
  • RHS 可以理解为变量赋值等号右边的部分,用于查询变量的值

# 实战分析

接下来,我们根据以下代码进行 js 编译流程分析。

var a;
a = 2;
console.log(a);
1
2
3

1、编译器解析 var a,执行 LHS 查询,询问作用域,当前作用域有 a 吗?

2、作用域回复,没有。

3、于是编译器就在当前作用域上,创建了一个 a。

4、编译器执行到 a = 2,生成机器代码,交给引擎执行。

5、解释引擎开始执行 a = 2,首先进行 LHS 查询,询问作用域,当前作用域有 a 吗?

6、作用域回复,有,因为已经被编译器创建了。

7、执行赋值操作(如果没有,则会继续询问上层作用域)。

至此 var a ; a = 2; 执行完毕。


8、引擎执行 console.log(a); 执行 RHS 查询,询问作用域,当前作用域有 console 吗?

9、作用域回复,没有,但在全局作用域找到了。

10、解释引擎继续执行 console.log(a) ,首先执行 RHS 查询,询问作用域,当前作用域有 a 吗?

11、作用域回复,有,并返回 a。

12、引擎执行 console.log(a) 打印操作。

# JIT 引擎优化

JS 是解释型语言且弱类型,在生成 AST 之后,就开始一边编译,一边解释,但是有个弊端,当某段代码被多次执行时,就会进行多次编译解析操作,特别是在一个循环内,将消耗大量的性能。

JIT 是编译型语言和解释性语言的混合,在一边编译,一边解释时做了一些优化,常用的优化有以下几点。

1、监视器在 js 引擎中增加一个监视器。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息,如果同一行代码运行了几次,这个代码段就被标记成了 warm,如果运行了很多次,则被标记成 hot

2、基线编译器如果一段代码变成了 warm,那么 JIT 就把它送到基线编译器去编译,并且把编译结果存储起来。比如:监视器监视到了某行、某个变量执行同样的代码、使用了同样的变量类型,就会把编译后的版本,替换这一行代码的执行,并且存储。

3、优化编译器如果一个代码段变得 hot,监视器会把它发送到优化编译器中。生成一个更快速和高效的代码版本出来,并且存储。例如:循环加一个对象属性时,假设它是 INT 类型,优先做 INT 类型的判断。

4、去优化可是对于 JS 从来就没有确定这么一说,前 99 个对象属性保持着 INT 类型,可能第 100 个就没有这个属性了,那么这时候 JIT 会认为做了一个错误的假设,并且把优化代码丢掉,执行过程将会回到解释器或者基线编译器,这一过程叫做去优化。

# JIT 案例

var a;
for (var i = 0; i < 10; i++) {
  if (i > 5) {
    a = "a";
  } else {
    a += i;
  }
}
1
2
3
4
5
6
7
8

如果 a 和 i 一直都是 number 类型,但 js 是弱类型,在执行时,还是会先判断左右两边的类型,这样判断下去很费时,于是 JIT 做了以下两点优化:

增加一个监视器,监视每一步代码的执行情况。

1、将 warm 代码加入基线编译器,如果监视器监视到执行同样的代码和同样的类型,就会直接复用已编译的版本。

2、将 hot 代码加入优化编译器,为了更快的执行,编译器要会做假设,即将 a 和 i 的类型假设为 number,并缓存起来,下一次直接执行 number 类型相加。

3、如果 hot 代码中的假设不成立了,如 i > 5 之后,i 就变成了一个 string 类型了,这时候就执行(去优化)操作,重置到基线编译结果。

什么时候会进行编译优化呢?

在一行代码多次执行时,会被标记 warm,被更多次执行时,会被标记 hot。warm 会被加入到基线编译器,hot 会被加入到优化编译器。

# 参考链接