我们知道,V8引擎是Google开发的开源JavaScript引擎,目前用在Chrome浏览器和Node.js中,核心功能是将JavaScript解析成机器能理解的机器码,供机器执行。
什么是V8
我们可以把V8看成一个虚拟机,其通过模拟实际计算机的各种功能来实现代码的执行,如模拟计算机的CPU,堆栈,寄存器等,此外,虚拟机还有自己的一套指令系统
有这样的虚拟机带来的好处是,只要我们按照虚拟机的规范去编写代码,那么即使我们在不同的操作系统,相同的代码执行出来的结果也能是相同的。可能Java虚拟机大家也都听过,听后端同学说面试的时候也常被问到,同样的也是通过将高级语言编译,只要在电脑上有Java虚拟机,我们就可以去编译执行Java语言,js也是同样的,但因为浏览器有自带的引擎来执行我们的代码,所以不像Java一样我们需要去安装,大家也就比较少感觉到它的存在了。
为什么要使用V8
知道了V8是什么之后,我们想想,为什么要使用V8,或者说为什么需要去解析我们写的代码
有计算机基础的同学应该知道,机器只能识别01的二进制代码,但是我们做为人,要我们去编写二进制代码来实现程序,显然是不太可能的,出于这个考虑,在一开始出现了汇编语言,相对于现在我们使用的高级语言诸如Java,C,JavaScript,python这些,汇编语言的编写还是相对麻烦的,但它还是需要解析执行,汇编语言都需要解析执行,何况高级语言呢。
V8是如何解析代码的
在谈到如何解析代码之前,我们要先知道两种主要的解析方式
主要的解析方式
动态解释执行
解释执行是解析方式的一种,需要先将输入的代码通过解析器编译成中间代码,然后通过解释器执行中间代码,然后直接输出结果。
使用解释执行的好处
- 维护更灵活,我们只要保留源代码,修改源代码,执行的时候交给虚拟机即可
- 只要存在解释器,源代码可以在任何操作系统上运行,可移植性好
静态编译执行
编译执行首先将源代码转换为中间代码,然后编译器再将中间代码转成机器代码。需要执行这段程序的时候,只要执行这段机器代码就可以了。通过这种方式,我们可以将虚拟机编译的机器代码直接存储起来,在需要的时候直接执行。
使用编译执行的好处
- 因为直接编译生成了机器代码,所以执行的时候更快
- 目标代码不需要编译器就能运行,在同类操作系统上使用灵活
这两种解析方式各有好处,虽然看上去过程很简单,但实际的解析,编译,解释还是很复杂的,每种语言的编译都会有所不同,会用到各自的虚拟机。
即便是一门语言,就如JavaScript,也有好几种流行的虚拟机,它们之间的实现也存在一定的差异,比如苹果公司在Safari中就是用JavaScriptCore虚拟机,Firefox使用了TraceMonkey虚拟机,而Chrome则使用了V8虚拟机。
那么回到我们的主题,V8是怎么解析代码的呢。是解释执行还是编译执行?都不是
V8并没有采用这两种方式中的一种,或者说,它结合了这两种方式。我们将这种混合使用解释执行和编译执行的方式称为JIT(Just In Time),实际上,JIT也有更细致的分类,但这里就不展开谈了。
看看V8执行JavaScript的完整流程
初始化运行环境
在V8开始解析代码之前,先要初始化运行JavaScript的环境,我们可以看到上面的图片左侧,列出了一些V8初始化环境时准备的东西:堆和栈空间、全局执行上下文、全局作用域、事件循环系统等,这些内容都是我们运行JavaScript时所必须的
- 堆栈是V8用来管理内存的管理模式
- 全局执行上下文中包含了全局执行的一些内置函数,全局变量等信息
- 全局作用域包含了一些全局变量
- 另外,要我们的V8系统活起来,还需要初始化消息循环系统,消息循环系统包含了消息驱动器和消息队列,它如同V8的心脏,不断接受消息并决策如何处理消息。
理解这里初始化的运行环境,可以让我们更好地理解JavaScript里面各种代码是如何被执行的
宿主环境
宿主环境是我们理解运行环境之前要理解的一个环境,对于V8来说,一般可以做为宿主环境的有浏览器环境和Node环境,之所以是宿主,可以理解为,JavaScript只能在宿主环境下才能被运行,同时,JavaScript会影响到“宿主”的状态。
举最经常发生的例子,当我们在浏览器中,JavaScript的代码运行的时间过长,就可能会影响到浏览器的渲染机制,有时甚至会导致浏览器未响应,这就是JavaScript对宿主环境的影响。
构造数据存储空间:堆栈空间
JavaScript运行在宿主环境上,V8会由这些宿主环境去启动,然后宿主环境就回去初始化V8,同时初始化堆栈空间。
其实很简单,我们要运行这门语言,当然要先为这门语言去构建它的存储空间,但问题是,为什么要用堆栈两种空间,两种空间各用来存储什么。这就涉及到了两种存储空间的利弊了。
栈
首先,我们说说栈。栈的特点是空间连续,且先进后出。至于它在JavaScript里的作用,可能你会经常听到函数调用栈这个名词,其实它在JavaScript中主要就是用来管理函数调用的,同时,和函数调用执行上下文相关的内容,如原始数据类型、用到的对象的地址、函数的执行状态、this的值都会存储在栈上。
而正因为栈的特点是空间连续,所以我们在初始化环境时,要在内存里面划分一块连续区域用于存储数据,但问题是,我们不能去划分一个过大的连续区域。先不说内存里面本身就很难得到一块大的连续区域,如果划分过大的栈空间用于存储的话,那对内存本身也是一种浪费,所以V8对栈空间大小做了限制,这也就是我们平时说的,栈空间溢出的原因。
堆
通过V8来对栈空间大小做出限制,解决了分配空间问题和内存浪费的问题,但是,如果我们遇到了比较大的数据的话,那分配空间又成了问题,因此V8引入了堆空间来存储较大的数据。
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据,和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的。在JavaScript中,除了原始数据类型,其他的基本都是对象,上面也说了,栈空间里面会存储用到的对象的地址,而这个地址就指向了堆空间的某一个对象的地址。
堆栈空间创建完后,后面的数据就都会存储在这两个空间中了。
全局执行上下文和全局作用域
有了存储空间,V8就可以开始初始化全局执行上下文和全局作用域了。
当 V8开始执行一段可执行代码时,会生成一个执行上下文。V8用执行上下文来维护执行当前代码所需要的变量声明、this指向等。
当V8开始执行代码的时候,就会生成执行上下文,因为一开始都是先有的全局执行上下文,所以在这里初始化了全局执行上下文。全局执行上下文包括词法环境,变量环境和this关键字。
- 词法环境:包含了使用let、const等变量的内容
- 变量环境:在这里初始化的,包括创建全局中像浏览器的window对象,setTimeout,setInterval等API。
- this关键字:指向当前的执行上下文
全局执行上下文在V8执行过程中是不会被销毁的,它会保存在堆中。
而执行上下文的区别,和全局作用域的区别,可能在ES6中,多了块级作用域后我们更能去区分,在一个执行上下文中,是可以有多个作用域的,就像下面在全局执行的代码
var g = 'global'
{
let b = 'block'
}
这里全局执行上下文包括了两个作用域,一个是全局作用域,一个是块级作用域,但它们都是在全局执行上下文中的。
另一个区别,执行上下文是在我们调用函数时,动态创建的,除了全局执行上下文需要在我们初始化环境时就创建,而作用域是在我们解析JavaScript的时候,就确定下来的。
具体的作用域和执行上下文我们会在下面解析JavaScript的时候详谈,这里我们只是说说全局执行上下文和全局作用域。
构建事件循环系统
在构建完存储空间和执行上下文之后,因为V8是使用宿主环境的线程来运行JavaScript代码的,像在浏览器上就会去使用浏览器的主线程,但是如果我们执行完一段代码,就退出线程,那意味着下次再有代码要执行时,要重新初始化环境,这显然是不合理的。
所以V8为此构建了一个事件循环系统,在代码中添加一个循环语句,在循环语句中监听下个事件,比如你要执行另外一个语句,那么激活该循环就可以执行了
浏览器的主线程除了执行JavaScript之外,还要执行别的内容,所以V8实现了这个事件循环系统后,每当有新的任务,就会推到主线程的任务队列,当前面的任务执行完,就会执行这个推入的任务。
因此我们在写JavaScript时,要注意一个函数执行的时间不能过长,如果过长的话可能会阻塞其他事件的执行或者页面的交互性能。可以考虑放到web worker执行或者使用时间切片处理。
解析执行JavaScript代码
在谈及解析之前,我们要明白,我们写的代码,说到底只是一段字符串内容,要让一个引擎去对一段字符串内容进行识别解析执行,不太实际,所以我们需要先对它进行一定的解析,转换成另一种结构,然后根据这种结构来转化代码执行。
上面的图中我们也可以看到了,代码通过解析,生成了AST抽象语法树和作用域。AST便是V8易于转换执行的结构。
作用域
我们先看看这里解析生成的作用域,为什么要在这里解析作用域,在这里解析的作用域有什么用,对我们的代码有什么影响,理解了这些问题,会让你对JavaScript的作用域链有更深的理解
我们可以看看下面的这段代码
var name = '全局'
var scope = 'global'
function foo(){
var name = 'foo'
console.log(name)
console.log(type)
}
function bar(){
var name = 'bar'
var type = 'function'
foo()
}
bar()
这段代码最终会打印出什么呢,可以想一想,即使你知道正确答案,你又知道为什么吗,我们先继续聊聊刚才的问题
首先,作用域是什么,简单来说,作用域就是用来存储变量和函数的地方,而我们常见的,在ES6之前,有全局作用域和函数作用域,一开始我们在V8初始化环境的时候,就会创建全局作用域,而当我们运行某个函数,就会创建相应的函数作用域,而当出现函数作用域时,就会出现作用域链。
全局作用域和函数作用域
全局作用域:上面已经提到了,时在V8初始化环境的时候就会创建的,所以我们的代码可以去直接调用到全局的一些变量,比如浏览器中的window和document,再比如node中的Global,File等。此外,全局作用域在运行过程中不会被销毁,除非V8退出。
函数作用域:在函数执行的时候的编译阶段创建的,当函数执行完毕,就会退出当前的函数作用域,而且这个函数作用域也随着退出而被销毁。
作用域链
知道了全局作用域和函数作用域之后,我们就可以来理解作用域链了。上面提到了,当出现函数作用域时,就会出现作用域链,因为既然V8引擎已经启动了,说明全局作用域已经存在了,而函数要么是在全局作用域中声明,要么是在函数作用域中声明,所以至少会有两个作用域,也就形成了一个作用域链。
嗯?上面我说的是在执行的时候函数作用域会被创建,为什么这里我用了声明这个词呢,要知道执行所在的作用域和声明的作用域可能不在同一个地方,执行的时候创建函数作用域,那作用域链不是应该按执行的时候所在的作用域来构成吗?这种“理所当然”的理解正是很多人答错上面那段代码输出结果的原因。
那么正确答案是什么
首先要知道一个概念性的东西,作用域分两种,一种是词法作用域,一种是动态作用域,而JavaScript使用的是词法作用域。在《你不知道的JavaScript 上》里面有写到
简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)
因此,上面得到的正确打印结果应该是
foo global
因为bar虽然是在foo函数里面被调用的,但它是在全局下声明的,所以它的作用域链是 global->bar而不是global->foo->bar。
因此,回到我们的问题,为什么要在解析的时候就把作用域创建好,我个人的理解是:因为大部分情况下,我们的函数作用域是由函数,或者是块级作用域,是由我们写的位置来决定的,所以我们在编译的时候就可以解析出相应的作用域链,在后面我们正式执行代码的时候,就可以直接使用这部分内容了,不用增加执行时的额外开销。这样也能加快我们代码的执行。
与执行上下文的关系
在上面谈到初始化环境时我们已经提到了,执行上下文是动态创建的,而作用域是在解析时静态确定的。对于函数调用栈来说,我们更应该看的时执行上下文。当我们在全局执行一个函数的时候,就会形成一个调用栈。虽然因为JavaScript使用的是词法作用域,所以在使用变量和调用方法时,会沿着作用域链去寻找相应的变量和方法,但是在内存中,调用栈的结构,栈里面的函数顺序,是由执行上下文来决定的,也就是看什么时候调用,越早调用的函数越早入栈,而栈底就是全局执行上下文。
AST
我们首先要看看AST是什么,可以通过这个网址https://astexplorer.net/来查看一些代码对应的AST结构
AST是一种树状的结构,如图我们也可以看到结构,我们在用babel来编译代码也是用到了AST
我们也可以通过一个npm包recast来更细致地学习AST
npm i recast -S
安装这个npm包之后可以根据里面的API来对AST树有细致的了解,因为这里主要讲的是V8,所以就不深入地去说AST了,我们现在只要知道在编译阶段V8会将其先编译为AST
解析成中间代码
那么根据图示
接下来我们要将AST解析成中间代码,但V8并不会一次性将所有的JavaScript解析为中间代码,这涉及到两个原因
首先,如果我们一次性解析所有的JavaScript,那么解析的时间无疑会受JavaScript代码大小的影响,如果JavaScript代码不多,那还好说,但是现如今的页面,一个页面就有好几个JavaScript文件,如果一次性都解析,那么毫无疑问,会让用户感到卡顿,这显然是不行的
其次,我们解析生成的字节码和编译之后的机器码都会存放在内存中,如果一次性解析所有的JavaScript,这些中间代码和机器码会一直存放在内存中,如果是在PC端还好,在移动端,手机本身内存就不多,内存占用的问题对用户的影响是很大的。
为了解决这些问题,所有主流的JavaScript虚拟机都实现了惰性解析。
惰性解析
所谓惰性解析,指的是解析器在解析过程中,如果遇到函数声明,那么会跳过函数内部的代码,不会为其生成AST和字节码,仅生成顶层代码的AST和字节码。
举个简单的例子
function fn(x,y){
var z = 1000
return x+y+z
}
var a = 1
var b = 10
fn(a,b)
当V8遇到这段代码的时候,V8会按代码的编写顺序,至上而下地去解析这段代码,在解析过程中,首先会遇到函数声明fn,那么按上面说的,会对函数声明做出惰性解析的处理,将函数变成一个对象,你可以理解为如下的对象
fn:{
name:'fn',
code:`
function (x,y){
var z = 1000
return x+y+z
}
`
}
接着继续解析,由于后面都是顶层代码,所以V8会为它们生成AST,然后根据该AST生成中间代码,再至上而下执行代码,当执行到fn(a,b)
时,就会去找到上面的那个对象,然后找到其code属性的字符串,使用原来的方式去解析这段字符串,同样编译生成AST和中间代码,然后再解释执行。通过这种方式,我们只有在真正执行到这个函数时,才会去解析这段函数,解析后再执行。
但是为了解决闭包问题,V8引用了预解析器,遇到函数声明时,除了生成转换对象外,还会进行简单的解析,判断内部是否有语法错误和是否有引用外部函数变量,如果引用了外部函数变量,则把这个函数变量复制到堆空间中。
字节码
字节码,就是上面图片中根据AST编译生成的中间代码。在V8中,字节码有两个作用,我们也从上面的图片中看到了
- 直接做为中间代码被解释器执行
- 由编译器将其转换为二进制机器码,再执行
然而,AST实际上也可以直接解析成二进制机器码,那你可能会有疑惑,为什么还要多一步中间的字节码,实际上,早期的V8并没有使用字节码,而是直接解析成二进制机器码
早期没用字节码的V8
早期的V8执行流程如下图
在早期的V8执行代码流程中,采用了两个编译器
- 基线编译器,它负责将JavaScript代码编译为没有优化过的机器代码。
- 优化编译器,它负责将一些热点代码(执行频繁的代码)优化为执行效率更高的机器代码。
- 在早期V8中,会先将JavaScript代码解析生成AST
- 然后将AST编译生成没有优化的二进制代码,并解释执行
- 在这个过程中,发现了执行频率比较高的代码,对它们做了标记,使用优化编译器将其编译成优化的二进制代码,执行该优化的代码
- 如果优化的代码不能满足现状,就通过反优化变回未优化二进制代码
早期的V8会将JavaScript编译成未经优化的二进制机器代码,然后再执行这些未优化的二进制代码,通常情况下,编译占用了很大一部分时间,为了减少编译的时间,早期的V8采用了机器代码缓存
这种方式其实就是我们算法上常说的空间换时间,因为空间常常比时间廉价。V8通过两种策略来实现机器代码缓存
- 首先,在首次执行JavaScript代码的时候,会编译生成二进制代码,然后将二进制代码缓存到内存中
- 除了缓存到内存外,V8还会将二进制代码缓存到硬盘上,这样即使我们关闭浏览器,重新打开的时候也能从硬盘上拿到缓存的代码来执行
这种直接编译成二进制代码的形式,其实比引入字节码性能更高,但是为什么后来的V8引入了字节码呢
拥抱字节码
上面提到了,早期的V8没有采用字节码,那是什么原因,让V8拥抱了字节码呢。
首先要回到上面的V8解决重复编译的问题,早期的V8采用了缓存策略来解决,但存在的问题是,JavaScript代码转换成二进制代码,占用的内存过大,看看下图
5k的JavaScript代码就会生成10M大小的二进制代码,那如果将多个JavaScript文件都编译为二进制代码存储在内存、硬盘中,那对内存来说可真是个噩梦。而引入字节码,就是为了解决这个策略对内存占用过多的问题,其实字节码能解决这个问题的原因也很简单,字节码占用内存少
如上图,虽然编译成字节码相比原JavaScript代码来说,大小还是多了几倍,但是相对于二进制代码来说,已经非常小了,是可以接受的内存占用。虽然采用字节码,会导致执行时性能略有降低,但是在代码的启动速度反而更快了,也就是说,采用字节码带来的代价,仅仅是减慢了执行时的一点时间而已。所以权衡利弊,V8最终拥抱了字节码。