作者:Gavin,未经授权禁止转载。
前言
你是否曾想过浏览器、Node、Deno中的V8引擎如何执行你的代码呢?我们都知道JS是解释型语言不是编译型语言,这是否意味着我们代码在执行期间没有任何形式的转换呢?
JIT(just-in-time)是什么
一般来说,每个浏览器、运行时都可能实现自己的JIT编译器,但通常其理论是一样的,遵循相同的结构。
V8的执行逻辑
解释器
由于JS是解释型语言,JS引擎需要将代码逐行翻译为可执行的代码,可执行的代码有多种形式,其中较常见的有基于AST
直接执行及ByteCode
的执行方式。
监视器(分析器)
在解释器执行代码时,监视器会对代码进行分析,跟踪不同的语句被命中的次数,随着次数的不断增长,该语句会被标记为warm
、hot
、very 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
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
将变成多态,如果继续调用它,它将变成超对称,从而结束所有内部优化。因此,请将类定义放在函数之外。