简介:本文是一个 V8 编译原理知识的介绍文章,旨在让大家感性的了解 JavaScript 在 V8 中的解析过程。
作者 | 子弈
来源 | 阿里技术公众号
一 简介
本文是一个 V8 编译原理知识的介绍文章,旨在让大家感性的了解 JavaScript 在 V8 中的解析过程。本文主要的撰写流程如下:
- 解释器和编译器:计算机编译原理的基础知识介绍
- V8 的编译原理:基于计算机编译原理的知识,了解 V8 对于 JavaScript 的解析流程
- V8 的运行时表现:结合 V8 的编译原理,实践 V8 在解析流程中的具体运行表现
本文仅代表个人观点,文中若有错误欢迎指正。
二 解释器和编译器
大家可能一直疑惑的问题:JavaScript 是一门解释型语言吗?要了解这个问题,首先需要初步了解什么是解释器和编译器以及它们的特点是什么。
1 解释器
解释器的作用是将某种语言编写的源程序作为输入,将该源程序执行的结果作为输出,例如 Perl、Scheme、APL 等都是使用解释器进行转换执行:
编译器的设计是一个非常庞大和复杂的软件系统设计,在真正设计的时候需要解决两个相对重要的问题:
- 如何分析不同高级程序语言设计的源程序
- 如何将源程序的功能等价映射到不同指令系统的目标机器
中间表示(Intermediate Representation,IR)是程序结构的一种表现方式,它会比抽象语法树(Abstract Syntax Tree,AST)更加接近汇编语言或者指令集,同时也会保留源程序中的一些高级信息,具体作用包括:
- 易于编译器的错误调试,容易识别是 IR 之前的前端还是之后的后端出的问题
- 可以使得编译器的职责更加分离,源程序的编译更多关注如何转换成 IR,而不是去适配不同的指令集
- IR 更加接近指令集,从而相对于源码可以更加节省内存空间
IR 本身可以做到多趟迭代从而优化源程序,在每一趟迭代的过程中可以研究代码并记录优化的细节,方便后续的迭代查找并利用这些优化信息,最终可以高效输出更优的目标程序:
解释器和编译器的具体特性比较如下所示:
4 JIT 编译技术
JIT (Just In Time)编译器是一种动态编译技术,相对于传统编译器而言,最大的区别在于编译时和运行时不分离,是一种在运行的过程中对代码进行动态编译的技术。
为了解决 JavaScript 在运行时性能较慢的问题,可以通过引入 JIT 技术,并采用混合动态编译的方式来提升 JavaScript 的运行性能,具体思路如下所示:
- 启动速度快:在 JavaScript 启动的时候采用解释执行的方式运行,利用了解释器启动速度快的特性
- 运行性能高:在 JavaScript 运行的过程中可以对代码进行监控,从而使用 JIT 技术对代码进行编译优化
三 V8 的编译原理
V8 是一个开源的 JavaScript 虚拟机,目前主要用在 Chrome 浏览器(包括开源的 Chromium)以及 Node.js 中,核心功能是用于解析和执行 JavaScript 语言。为了解决早期 JavaScript 运行性能差的问题,V8 经历了多个历史的编译框架衍变之后(感兴趣的同学可以了解一下早期的 V8 编译框架设计),引入混合动态编译的技术来解决问题,具体详细的编译框架如下所示:
Ignition 的主要作用是将 AST 转换成 Bytecode(字节码,中间表示)。在运行的过程中,还会使用类型反馈(TypeFeedback)技术并计算热点代码(HotSpot,重复被运行的代码,可以是方法也可以是循环体),最终交给 TurboFan 进行动态运行时的编译优化。Ignition 的解释执行流程如下所示:
这里不会过多讲解每个执行流程的细节问题。
2 TurboFan 优化编译器
TurboFan 利用了 JIT 编译技术,主要作用是对 JavaScript 代码进行运行时编译优化,具体的流程如下所示:
图片出处 An Introduction to Speculative Optimization in V8。
需要注意 Profiling Feedback 部分,这里主要提供 Ignition 解释执行过程中生成的运行时反馈向量信息 Feedback Vector ,Turbofan 会结合字节码以及反馈向量信息生成图示(数据结构中的图结构),并将图传递给前端部分,之后会根据反馈向量信息对代码进行优化和去优化。
这里的去优化是指让代码回退到 Ignition 进行解释执行,去优化本质是因为机器码已经不能满足运行诉求,例如一个变量从 string 类型转变成 number 类型,机器码编译的是 string 类型,此时已经无法再满足运行诉求,因此 V8 会执行去优化动作,将代码回退到 Ignition 进行解释执行。
四 V8 的运行时表现
在了解 V8 的编译原理之后,接下来需要使用 V8 的调试工具来具体查看 JavaScript 的编译和运行信息,从而加深我们对 V8 的编译过程认知。
1 D8 调试工具
如果想了解 JavaScript 在 V8 中的编译时和运行时信息,可以使用调试工具 D8。D8 是 V8 引擎的命令行 Shell,可以查看 AST 生成、中间代码 ByteCode、优化代码、反优化代码、优化编译器的统计数据、代码的 GC 等信息。D8 的安装方式有很多,如下所示:
- 方法一:根据 V8 官方文档 Using d8 以及 Building V8 with GN 进行工具链的下载和编译
- 方法二:使用别人已经编译好的 D8 工具,可能版本会有滞后性,例如 Mac 版
- 方法三:使用 JavaScript 引擎版本管理工具,例如 jsvu,可以下载到最新编译好的 JavaScript 引擎
本文使用方法三安装 v8-debug 工具,安装完成后执行 v8-debug --help 可以查看有哪些命令:
2 生成 AST
我们编写一个 index.js 文件,在文件中写入 JavaScript 代码,执行一个简单的 add 函数:
使用 --print-ast 参数可以打印 add 函数的 AST 信息:
我们以图形化的方式来描述生成的 AST 树:
3 生成字节码
AST 会经过 Ignition 解释器的 BytecodeGenerator 函数生成字节码(中间表示),我们可以通过 --print-bytecode 参数来打印字节码信息:
add 函数主要包含以下 3 个字节码序列:
这里 Ignition 的解释执行这些字节码采用的是一地址指令结构的寄存器架构。
关于更多字节码的信息可查看 Understanding V8’s Bytecode。
4 优化和去优化
JavaScript 是弱类型语言,不会像强类型语言那样需要限定函数调用的形参数据类型,而是可以非常灵活的传入各种类型的参数进行处理,如下所示:
为了可以进行 + 操作符运算,在底层执行的时候往往需要调用很多 API,比如 ToPrimitive(判断是否是对象)、ToString、ToNumber 等,将传入的参数进行符合 + 操作符的数据转换处理。
在这里 V8 会对 JavaScript 像强类型语言那样对形参 x 和 y 进行推测,这样就可以在运行的过程中排除一些副作用分支代码,同时这里也会预测代码不会抛出异常,因此可以对代码进行优化,从而达到最高的运行性能。在 Ignition 中通过字节码来收集反馈信息(Feedback Vector),如下所示:
通过 --allow-natives-syntax 参数可以在 JavaScript 中调用 %DebugPrint 底层 Native API(更多 API 可以查看 V8 的 runtime.h 头文件):
这里的 SharedFunctionInfo(SFI)中保留了一个 InterpreterEntryTrampoline 指针信息,每个函数都会有一个指向 Ignition 解释器的 trampoline 指针,每当 V8 需要进去去优化时,就会使用此指针使代码回退到解释器相应的函数执行位置。
为了使得 add 函数可以像 HotSpot 代码一样被优化,在这里强制做一次函数优化:
需要注意的是 V8 会自动监测代码的结构变化,从而执行去优化。例如下述代码:
我们可以通过 --trace-deopt 参数跟踪 add 函数的去优化信息:
五 总结
本文对于 V8 的研究还处在一个感性的认知阶段,并没有深入到 V8 底层的源码。通过本文可以对 V8 的编译原理有一个感性的认知,同时也建议大家可以使用 TypeScript,它确实能在一定程度上对 JavaScript 代码的编写产生更好的指导作用。
原文链接
本文为阿里云原创内容,未经允许不得转载。