5.2 V8脚本执行原理
目前开发人员使用的编程语言主要分为两大类:一类是编译执行的语言;另一类是解释执行的语言。
像C、C++和汇编语言都是编译执行的语言,使用这些语言开发程序的开发者,需要把自己编写的代码编译成二进制文件,这些针对不同机器生成的二进制文件可以直接在目标机器上运行,但为不同机器生成的二进制文件不能在其他的机器上运行。
像Java、C#等编程语言是解释执行的语言,使用这些语言开发程序的开发者,需要把自己编写的代码编译成字节码文件,这些字节码文件不能直接在任何机器上运行,目标机器必须安装某个虚拟机或运行时,比如Java虚拟机JVM或C#的运行时.NET Framework,才能执行这类文件,但这些字节码文件在任何机器的虚拟机上都可以正确执行。
我们把第一种方式称为编译执行,第二种方式称为解释执行,第一种方式的特点是启动速度较慢,执行速度较快,调试不是很方便,针对不同的目标机器要生成不同的可执行文件;第二种方式的特点是启动速度较快,执行速度较慢,调试方便,只需生成一套字节码文件就可以在不同的机器上正确执行。
很显然JavaScript属于解释执行的编程语言,但V8引擎却是身兼多能的运行时,它既有编译执行的特点又有解释执行的特点,下面我们就来具体分析一下。
在V8开始执行一段JavaScript脚本前,它做了如下三个事情:
1)初始化内存中的堆栈结构。V8有自己的堆空间和栈空间设计,代码运行期产生的数据都是存储在这些空间内的,所以要提前完成初始化工作。
2)初始化全局环境。这个工作包含一些全局变量、工具函数的初始化任务。
3)初始化消息循环。这个工作包含V8的消息驱动器和消息队列的初始化任务。
做完这些工作之后V8就可以执行JavaScript源码了,需要说明的是,我们经过webpack或Rollup处理过的JavaScript源码,甚至被javascript-obfuscator混淆过的JavaScript源码,对于V8引擎来说都没什么两样,就是一个很长的字符串。
V8首先把这些字符串转化为抽象语法树(AST abstract syntax code),我们来分析一段简单的JavaScript代码:
let param = 'v8' console.log('hello ${param}')
这段代码生成的抽象语法树如下所示:
从上面的信息可以看出,第8行使用LET定义了变量param,第12~13行为param变量赋值字符串v8,第17~20行开始调用console对象的log方法,第21~24行使用模板字符串拼接字符串,最后一行返回执行结果。
在生成抽象语法树之后,V8会为程序中的变量生成作用域,如下所示:
global { // (000001952B7A00F8) (0, 47) // will be compiled // 1 stack slots // 3 heap slots // temporary vars: TEMPORARY .result; // (000001952B7A0698) local[0] // local vars: LET param; // (000001952B7A0348) context[2] // dynamic vars: DYNAMIC_GLOBAL console; // (000001952B7A0768) never assigned }
从上面的信息可以看出,V8在执行这段代码时用到了三个变量:一个是临时变量.result,用于存储返回值;一个是用户使用LET关键字声明的param变量;一个是动态全局变量console。所有这些变量都包裹在全局作用域中。
有了抽象语法树之后,V8接下来会把抽象语法树转换为字节码,如下所示:
Parameter count 1 Register count 4 Frame size 32 000002440824FABE @ 0 : 12 00 LdaConstant [0] 000002440824FAC0 @ 2 : 1d 02 StaCurrentContextSlot [2] 000002440824FAC2 @ 4 : 13 01 00 LdaGlobal [1], [0] 000002440824FAC5 @ 7 : 26 f9 Star r2 000002440824FAC7 @ 9 : 28 f9 02 02 LdaNamedProperty r2, [2], [2] 000002440824FACB @ 13 : 26 fa Star r1 000002440824FACD @ 15 : 12 03 LdaConstant [3] 000002440824FACF @ 17 : 26 f8 Star r3 000002440824FAD1 @ 19 : 1a 02 LdaCurrentContextSlot [2] 000002440824FAD3 @ 21 : 78 ToString 000002440824FAD4 @ 22 : 34 f8 04 Add r3, [4] 000002440824FAD7 @ 25 : 26 f8 Star r3 000002440824FAD9 @ 27 : 59 fa f9 f8 05 CallProperty1 r1, r2, r3, [5] 000002440824FADE @ 32 : 26 fb Star r0 000002440824FAE0 @ 34 : aa Return Constant pool (size = 4) 000002440824FA85: [FixedArray] in OldSpace - map: 0x0244080404b1 <Map> - length: 4 0: 0x02440824fa05 <String[#2]: v8> 1: 0x0244081c6971 <String[#7]: console> 2: 0x0244081c69e5 <String[#3]: log> 3: 0x02440824fa15 <String[#6]: hello > Handler Table (size = 0) Source Position Table (size = 0)
这段字节码中类似Add、Star、Lda ***等指令都是在操作寄存器。
我们知道通常有两种架构的解释器,基于栈的解释器和基于寄存器的解释器。基于栈的解释器会将一些中间数据存放到栈中,而基于寄存器的解释器会将一些中间数据存放到寄存器中。由于采用了不同的模式,所以字节码的指令形式是不同的。
很显然,V8引擎是基于寄存器的解释器。
如果V8的解释器发现某段代码将会被反复执行(很可能代码中存在循环体或某个函数被反复调用),V8的监控器就会将这段代码标记为热点代码,并提交给编译器优化执行。
我们修改一下前面的测试代码,以促使V8对其优化,如下所示:
let x = 0 for (let i = 0; i < 666666; i++) { x = 66+66 }
这是一段无意义的代码,for循环内不断地执行一个加法运算,当V8解释执行这段代码时,发现这个加法运算是可以优化的,就会对其进行优化,优化的过程信息如下所示:
[marking 0x028b0824fab9 <JSFunction (sfi = 0000028B0824FA2D)> for optimized recompilation, reason: small function] [compiling method 0x028b0824fab9 <JSFunction (sfi = 0000028B0824FA2D)> using TurboFan OSR] [optimizing 0x028b0824fab9 <JSFunction (sfi = 0000028B0824FA2D)> - took 1.026, 1.095, 0.064 ms]
这个过程信息解释了优化的原因:small function,这个for循环是一个小函数,为了减少函数调用的开销,所以对其做优化操作。using TurboFan OSR是V8使用的优化引擎。最后一行信息代表着优化所耗费的时间。
V8会针对不同类型的代码执行不同的优化手段,最为人所称道的就是直接把字节码编译为二进制代码,由于二进制代码具备执行速度快的特点,所以这也是V8引擎高效的原因之一。
回到本节的开头,我们可以说V8是一种内嵌了编译执行能力的JavaScript解释器。它是复杂的,但也是高效的,因为它同时具备两种能力——编译执行能力和解释执行能力。
关于V8执行过程中的这些中间信息我是怎么拿到的,后面还会有更详细的介绍。