JS引擎(一):JS中的JIT与基本执行逻辑

avatar
@智云健康

作者:Gavin,未经授权禁止转载。

前言

你是否曾想过浏览器、Node、Deno中的V8引擎如何执行你的代码呢?我们都知道JS是解释型语言不是编译型语言,这是否意味着我们代码在执行期间没有任何形式的转换呢?

JIT(just-in-time)是什么

一般来说,每个浏览器、运行时都可能实现自己的JIT编译器,但通常其理论是一样的,遵循相同的结构。

V8的执行逻辑

解释器

由于JS是解释型语言,JS引擎需要将代码逐行翻译为可执行的代码,可执行的代码有多种形式,其中较常见的有基于AST直接执行及ByteCode的执行方式。

监视器(分析器)

在解释器执行代码时,监视器会对代码进行分析,跟踪不同的语句被命中的次数,随着次数的不断增长,该语句会被标记为warmhotvery hot(SpiderMonkey中的really hot)。换句话说,监视器会检测哪些部分的代码被使用最多,然后将它们发送给JIT进行编译和存储。JIT引擎会针对这些代码逐行进行机器码编译,然后存储在一张表的单元中(表单元指向被编译的机器码)。

  • warm 将解释执行代码发送给JIT引擎,将其编译为机器码,但此处不会进行替换
  • hot 解释执行代码将被替换为warm编译出的机器码执行
  • very hot 将解释执行的代码发送给优化编译器,创建和编译出更高效的机器码执行代码并进行替换

基线编译器

warm部分的代码将被编译成字节码,然后由针对该类型代码进行优化的解释器运行,以此使代码执行更快,如果可能的话,基线编译器还将尝试通过分析每条指令创建“存根”来优化代码,例:

function concat(arr) {
  let res = '';
  for (let i = 0; i < arr.length; i++) {
    res += arr[i];
  }

  return res;
}

console.log(concat(['a', 1, 'b', true, 'c']));

基线编译器会将res += arr[i]转换为存根,但由于此指令是多态的(没有什么能保证i每次都是一个整数或arr[i]每次都为一个字符串),它将为每个可能的组合创建存根。

考虑for循环每一步,解释器都会检查:

  • i是一个整数?
  • res是一个字符串?
  • arr是一个数组?
  • arr[i]是一个字符串?

优化编译器

优化编译器将负责将所有这些孤立的存根变成一个组,如果可能会尽可能的对整个函数进行存根,上面代码通过JIT编译器优化最终只会做一次类型检查:在函数调用之前检查一遍。回到上面例子,如果数组的长度是10000,则判断会做30000遍:

  • arr是一个数组?是
  • i是一个整数?是
  • res是一个字符串?是

如果在循环之前就知道它们的类型,当然就做了优化,省去了30000遍判断。现在只需要关心数组中元素的类型,因为在读取之前是不知道其类型的。

Node中的字节码长什么样

# 将jit/bytecode.js文件编译成字节码输出到jit/bytecode.txt中
$ node --print-bytecode jit/bytecode.js > jit/bytecode.txt

v8中的字节码指令集

Node中的JS引擎

其实每个JS引擎编译出的字节码都不一样,常见的有:

区别

Chakra Core支持并行JIT编译,首先读取JS代码解析成AST,生成AST之后,将代码传递给字节码生成器,对字节码进行分析,与V8不同,如上V8有一个决策过程,会通过监视器决定一段代码是否应该进行分析优化,还是转换为字节码。而SpiderMonkey会首先将JS代码转换为AST再转换为字节码,也会利用监视器找出hot code,利用基线编译器,编译出字节码(非最优:编译时间权重高于性能权重),当hot code部分代码变为really hot code时,SpiderMonkey会启动其JIT编译器(IonMonkey)编译出最优的字节码(最优:性能权重高于编译时间权重)

js引擎切换

$ npx jsvu

JIT这样做值得吗

当然以上做法不能像静态类型编译语言那样高效,但JIT会对代码进行分析和监视,当代码需要长时间运行时,一旦它开始获得warm签名的代码,性能提升是显而易见的。另一方面,如果你创建的是生命周期非常短小的脚本,那完全不需要关心JIT。

利用JIT优化你的代码

不要改变对象的形状

class Car {
  constructor(color, made, model) {
    this.color = color
    this.made = made
    this.model = model
  }
}

const car1 = new Car("red", "chevrolet", "spark")
const car2 = new Car("blue", "hyundai", "tucson")

car1.doors = 2
car2.radio = true

上面代码第10行为Car对象创建了一个隐藏类,在11行继续使用相同的隐藏类,在13、14行中,通过动态添加属性改变了对象的形状,编译器处于亏损状态,不能再假设car1与car2都属于同一个隐藏类,因此它需要创建新的类,改变的越多,监视器就会越努力的跟踪,直到达到一个预设的阈值,最终它会失去所有可能的优化选项。

保持函数参数不变

实际上,更改用于调用函数的属性类型越多,在编译器眼中,函数就变得越复杂。优化器将尝试为用于属性的每个可能的组合创建存根,所以如果总是将字符串传递给函数,监视器将假定这是它所需的唯一类型,将其标记为单形,在快速查找表上跟踪它,如果此时再传入不同的类型,内部查找表将开始增长,因为现在已经将函数转换为多态函数。

如果继续使用更多的类型组合来调用它,发展到一定程度,变成超对称,此时它所有的引用都会被移到一个全局查找表中,丢失所有内部优化。例:

function yourFunction(a, b) {
  //... your logic goes here
}

yourFunction(1,2) // monomorphic
yourFunction("string a", "string b") // now it's polymorphic...
yourFunction(true, false)
yourFunction(1, "string c")
yourFunction(false, (err) => { // oops, now it's megamorphic
  //....
})

避免在函数中创建类

function newPerson(name) {
  class Person {
    constructor(name) {
      this.name = name
    }
    
    return new Person(name)
  }
}

function dealWithPerson(person) {
  //...your logic here
}

与前面技巧类似,每次调用newPerson函数时,都会为返回的实例创建一个新的隐藏类。因此,在第二次调用这个函数时,本质上以Person对象作为参数调用dealWithPerson将变成多态,如果继续调用它,它将变成超对称,从而结束所有内部优化。因此,请将类定义放在函数之外。

参考文章

WebAssembly 如何演进成为“浏览器第二编程语言”?