V8是一个由Google开发的开源JavaScript引擎,用于Chrome、Node.js等环境中,作用是将JS代码编译为不同CPU(Intel, ARM以及MIPS等)对应的汇编代码。
本文将帮助你理解V8引擎的工作流程,从而帮助你学会如何写出更容易被优化的JS代码。
理解V8浏览器,首先得知道JavaScript是解释型语言。
为了使计算机硬件能理解源代码,我们需要将其转换成二进制指令(机器码)。
按照转换时机的不同,我们把高级语言分为编译型语言(静态语言)和解释型语言(动态语言)。
提前将所有源代码一次转换成二进制指令,生成一个可执行程序。比如C语言、C++、Golang等。其转换工具成为编译器。
一边执行一边编译,需要哪些源码就转换哪些源码,不会生成可执行程序。比如 Python、JavaScript、PHP、Shell、MATLAB 等,使用的转换工具称为解释器。
编译型语言一般是不能跨平台的,也就是不能在不同的操作系统之间随意切换。
相比于编译型语言,解释型语言几乎都能跨平台,解释型语言之所以能够跨平台,是因为有了解释器这个中间层。在不同的平台下,解释器会将相同的源代码转换成不同的机器码,解释器帮助我们屏蔽了不同平台之间的差异。
Java 和 C# 是一种比较奇葩的存在,它们是半编译半解释型的语言,源代码需要先转换成一种中间文件(字节码文件),然后再将中间文件拿到虚拟机中执行。Java 引领了这种风潮,它的初衷是在跨平台的同时兼顾执行效率;C# 是后来的跟随者,但是 C# 一直止步于 Windows 平台,在其它平台鲜有作为。
类型 | 原理 | 优点 | 缺点 |
---|---|---|---|
编译型语言 | 通过专门的编译器,将所有源代码一次性转换成特定平台(Windows、Linux 等)执行的机器码(以可执行文件的形式存在)。 | 编译一次后,脱离了编译器也可以运行,并且运行效率高。 | 可移植性差,不够灵活。 |
解释型语言 | 由专门的解释器,根据需要将部分源代码临时转换成特定平台的机器码。 | 跨平台性好,通过不同的解释器,将相同的源代码解释成不同平台下的机器码。 | 一边执行一边转换,效率很低。 |
JavaScirpt引擎的作用是将JS代码编译为不同CPU(Intel, ARM以及MIPS等)对应的汇编代码。
JavaScript引擎的工作也不只是编译代码,它还要负责执行代码、分配内存以及垃圾回收。
目前主流的JS引擎有以下这些:
其中最流行的是谷歌的V8引擎,除了Chrome等浏览器,Node.js、Electron(桌面应用框架)也是用的V8引擎。
2008年,V8引擎和Chrome在同一天开源,V8是C++实现的。
经过不断优化,V8引擎的性能也在不断提升,关于V8引擎的演变过程,可以参考这篇文章《深入理解JS引擎》,其中有很详细的图解。
现在的V8引擎,是怎么工作的呢?
V8引擎是由许多子模块构成的,其中最重要的是这4个模块。
Parser和Ignition负责V8的编译和执行,这是我们了解的编译型语言的执行方式,那为什么又会有TurboFan这个编译器呢?
我们需要先了解一下JIT(Just in Time)即时编译。
在运行C、C++以及Java等程序之前,需要进行编译,不能直接执行源码;但对于JavaScript来说,我们可以直接执行源码(比如:node server.js),它是在运行的时候先编译再执行,这种方式被称为即时编译(Just-in-time compilation),简称为JIT。因此,V8也属于JIT编译器。
实现JIT编译器的系统通常会不断地分析正在执行的代码,并确定代码的某些部分,在这些部分中,编译或重新编译所获得的加速将超过编译该代码的开销。 JIT编译是两种传统的机器代码翻译方法——提前编译(AOT)和解释——的结合,它结合了两者的优点和缺点。大致来说,JIT编译将编译代码的速度与解释的灵活性、解释器的开销以及额外的编译开销(而不仅仅是解释)结合起来。
AOT-ahead of time和just in time相对应。
除了V8引擎,Java虚拟机、PHP 8也用到了JIT。
Ignition会先将JavaScript转换为字节码(Bytecode),而不是机器能直接执行的机器码(Machine Code)。为什么需要这一步呢?
首先了解什么是字节码?
字节码(英语:Bytecode)通常指的是已经经过编译,但与特定机器代码无关,需要解释器转译后才能成为机器代码的中间代码。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令等构成的序列。
字节码的优点是总结来说就是:
利用字节码,可以实现Compile Once,Run anywhere(一次编译到处运行)。
具体到V8引擎,JIT的工作流程是这样的:
JavaScript源码被Parser解析成AST后,Ignition解释器一边解释执行代码,一边收集信息(包括函数的执行次数)。如果行数被调用多次,它就有可能被识别为热代码(Hot Code),同时将运行信息反馈给优化编译器TurboFan,TurboFan 根据反馈信息,会优化并编译字节码,最后生成优化的机器码。
下图总结了这个过程,更多具体的解释,可以观看参考文章中的视频,或者看下文的解释。
解释器的解析过程分为:词法分析和语法分析两个阶段。
这部分的知识,学习过编译原理就能理解。
贴一个可以看AST结构的网站:https://astexplorer.net/
V8引擎在解析阶段的一个优化是**惰性解析(Lazy Parsing)**,简单来说就是对不是立即执行的函数,只进行Pre-Parser预解析(只验证语法是否有效、解析函数声明、确定行数作用域,而不完全解析)。
解释器,负责将AST转换为字节码,解释执行字节码。
同时Ignition会收集TurboFan优化编译所需的信息,比如函数参数的类型。
node命令提供了很多V8引擎的选项,我们可以通过这些选项,查看V8引擎的工作过程中各个阶段的产物。
我们新建一个实验代码。
// test.js
function load(obj){
return obj.x;
}
load({x:4, a:7});
运行下面的node命令,打印出Ignition生成的字节码。
node --print-bytecode test.js
简单理解下,LdaNamedProperty是加载属性,然后Returm返回。
TurboFan:compiler,即编译器,利用Ignitio所收集的类型信息,将字节码转换为优化的机器码;
我们再回到上面的图。
上图中,绿色的线,是TurboFan利用Ignition收集的信息,对识别为热代码的字节码转换为优化后的机器码。
那什么时候会进行优化呢?分为下面几种情况:
上图中,红色的线,是“去优化(Deoptimize)”的过程,如果TurboFan生成的优化机器码,对需要执行的代码不适用,会把优化的机器码,重新转换成字节码来执行。
看下面这个例子。
function add(x, y) {
return x + y;
}
add(1, 2);
add(2, 2);
add("1", "2");
add函数的参数之前是整数,后来又变成了字符串。生成的优化机器码已经假定add函数的参数是整数,那当然是错误的,于是需要进行去优化。
我们可以执行下面的node命令来打印TurboFan生成的机器码。
node --print-code --print-opt-code test.js
TurboFan基于类型对字节码进行优化和去优化的例子,可以看下这个视频:Franziska Hinkelmann: JavaScript engines - how do they even? | JSConf EU。
TurboFan的优化手段很多,除了上面视频基于类型做优化,还有包括内联(inlining
)和逃逸分析(Escape Analysis
)等,参考这篇文章深入理解JS引擎。
内联就是将相关联的函数进行合并。比如:
function add(a, b) {
return a + b
}
function foo() {
return add(2, 4)
}
内联优化后:
function fooAddInlined() {
var a = 2
var b = 4
var addReturnValue = a + b
return addReturnValue
}
// 因为 fooAddInlined 中 a 和 b 的值都是确定的,所以可以进一步优化
function fooAddInlined() {
return 6
}
逃逸分析就是分析对象的生命周期是否仅限于当前函数,如果是的话会对其进行优化。比如:
function add(a, b){
const obj = { x: a, y: b }
return obj.x + obj.y
}
逃逸分析优化后:
function add(a, b){
const obj_x = a
const obj_y = b
return obj_x + obj_y
}
视频:Franziska Hinkelmann: JavaScript engines - how do they even? | JSConf EU