详解Google V8编译流水线

987 阅读4分钟

V8 编译流水线

编译流水线.jpg

V8 是怎么执行 JavaScript 代码的呢? 其核心流程分为编译和执行两步。首先需要将 JavaScript 代码转换为中间代码或者机器能够理解的机器代码,然后再执行转换后的代码并输出执行结果。

解释执行

解释执行.jpg

编译执行

编译执行.jpg

JIT(Just In Time)

V8 并没有采用某种单一的技术,而是混合编译执行和解释执行,我们 把这种技术称为 JIT。

这是一种权衡策略,因为这两种方法都各自有自的优缺点:

解释执行的启动速度快,但是执行时的速度慢。

而编译执行的启动速度慢,但是执行时的速度快。

流水线

流程图.jpg

我们先看上图中的最左边的部分,在 V8启动之前,它还需要准备执行 JavaScript 时所需要的一些基础环境,包括“堆栈空间” 、“全局执行上下文”、“全局作用域”、“事件循环系统”、“全局变量”等。

基础环境准备好之后,接下来就可以向 V8 提交要执行的 JavaScript 代码了。

首先 V8 会接收到要执行的 JavaScript 源代码,不过这对 V8 来说只是一堆字符串,V8 并不能直接理解这段字符串的含义,它需要结构化这段字符串。结构化之后,就生成了抽象语法树 (AST)。在生成 AST 的同时,还会生成相关的作用域。

有了 AST 和作用域之后,接下来生成字节码,字节码是介于 AST 和机器代码的中间代码。但是与特定类型的机器代码无关,解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行。生成了字节码之后,解释器就登场了,它会解释执行字节码,并输出执行结果。

值得注意的是,我们在解释器附近画了个监控机器人,这是一个监控解释器执行状态的模块,在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码。

当某段代码被标记为热点代码后,V8 就会将这段字节码丢给编译器,编译器会将字节码编译为二进制代码,然后再对二进制代码执行优化操作,优化后的机器代码执行效率会得到大幅提升。如果下面再执行到这段代码, V8 会优先选择优化后的机器代码,这样代码的执行速度就会大幅提升。

不过,JS 执行时对象的结构和属性是可以修改的,而经过编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被修改了,那优化后的机器代码会失效, 这时候编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。

流程总结

初始化环境;

解析代码生成 AST 和作用域;

依据 AST 和作用域生成字节码;

解释执行字节码;

监听热点代码;

优化热点代码为二进制机器代码;

反优化二进制机器代码。

惰性解析

在编译过程中,V8 并不会一次性将所有的 JavaScript 解析为字节码,这主要是基于以下两点:

  • 一次性解析所有的 JavaScript 代码会增加编译时间
  • 生成的字节码和编译之后的机器代码都会存放在内存中,这些代码一直占用内存是不合理的。

所以 V8 选择了惰性解析。解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,只解析顶层代码。等到需要执行函数代码时,在进行惰性解析。

然而对于闭包来说, V8 还要保证外层函数的执行上下文被销毁之后,内层函数引用的外层函数中的变量还保留在内存当中。所以虽然采取了惰性解析,不会直接解析内层代码,但是 V8 需要判断内层函数是否引用了外层函数中的变量,这就需要预解析器。当解析当前层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,一方面可以判断该函数是否存在语法错误,另一方面检查该函数是否引用了外部变量。如果引用了,预解析器会将变量复制到堆中,在下次执行到该函数的时候,直接使用堆中复制的变量,这样就解决了闭包所带来的问题。