浅谈V8引擎到JS执行的过程
浅谈V8引擎到JS执行的过程
首先我们需要了解JavaScript是一门弱类型动态语言,什么是弱类型,什么是动态语言呢?我们看下下面两段代码:
// C语言
int main()
{
int a = 1;
bool b = true;
}
// JavaScript
let a = 1;
let b = true
上面C代码和JavaScript代码声明变量的方式明显不同,在声明变量之前先定义了变量类型,而JS的代码则没有,这种需要在声明变量之前定义变量类型的语言称为静态语言。而像JavaScript这种运行中才去检测数据类型的语言称为动态语言。
而C和javaScript语言都可以将不同类型的变量相互赋值:
b = a
在这段代码中我们将int
型的a变量赋值给bool
型的变量b,这段代码也是可以编译运行的,因为它在赋值过程中,编译器悄悄的将变量a的int
类型转换为了bool
类型,这种操作就叫做隐式类型转换,我们将支持这种转换的语言称为弱类型语言,C语言和JavaScript就是这种语言。
下面我们在了解下V8引擎,简单的来说V8引擎是一个JavaScript引擎,我们写的代码是机器无法直接识别的,而V8引擎主要工作就是将代码编译为机器可以识别的机器码让机器执行,以及内存管理。
在了解V8引擎的工作原理之前,你需要了解一些概念和原理比如,编译器(Compiler),解释器(Interpreter),抽象语法树(AST),字节码(ByteCode),**即时编译器(JIT)**等。
编译器和解释器
机器无法识别我们写的代码,所以在执行之前要将我们的代码编译为机器能够识别的机器码,按语言执行流程分为编译型语言和解释型语言:
编译型语言需要在代码执行之前,通过编译器进行编译,编译完成之后会生成机器能够直接读懂运行的二进制文件,这样每次编译完成之后,不需要在重新编译,而是可以直接运行生成的二进制文件。比如C/C++,Go语言。
解释型语言则是在执行过程中通过解释器对程序代码进行动态解释并执行,比如Python,JavaScript等。
下面是是编译器和解释器翻译代码的过程:
graph LR
编译器--->A(源代码)---语法分析---词法分析--->B(AST)---词义分析-->中间代码---代码优化-->二进制文件-->执行
解释器--->A---语法分析---词法分析--->B(AST)---词义分析---字节码---解释执行
从上面可以看出二者的执行流程,大致如下:
- 在编译语言的编译过程中,编译器会先对源代码进行语法分析和词法分析将源代码转换为AST(抽象语法树),然后优化代码,最后生成处理器可以理解的机器码。编译成功之后会生成可执行的文件,如果编译过程出现语法或者其他错误,编译器会抛出错误,最后二进制文件也不会生成。
- 解释型语言的解释过程中,同样也会通过语法分析和词法分析将代码转换为AST,但和编译器不同的是后面解释器会将根据AST(抽象语法树)生成字节码,最后在根据字节码来执行程序,输出结果。
V8引擎是如何执行一段JavaScript代码的
graph LR
A(源代码)---语法分析---词法分析-->B(AST)--解释器-->字节码--编译器-->机器码
词法分析-->执行上下文
字节码--逐行解释执行-->机器码
B--优化代码--编译器-->机器码
从上图可以看出V8引擎执行过程中有解释器,又有编译器,我们从上面分析它们是如何配合执行一段JavaScript代码的:
1. 生成抽象语法树(AST)和执行上下文
首先会将源代码转换为抽象语法树(AST),并生成执行上下文:
- 抽象语法树: AST是源代码的抽象语法结构的树状表达形式,可以将其看成代码结构化的表示,编译器和解释器后续工作都需要依赖AST,而不是源代码。
- 执行上下文: 是JavaScript代码执行过程中的环境信息,包括变量环境,this指向,词法环境等。
AST是非常重要的数据结构,在很多项目中都有应用比如Babel,Webpack以及ESLint,而生成AST需要两个阶段。
- 分词(tokenize),又称为词法分析,其作用是将一行行源代码拆解成一个个token,这个token是指在语法上不可能在分的最小单位字符串或字符,例如:
从上图可以看出声明一个简单的变量,关键字var,标识符myname, 赋值运算符 "=",字符串学习这四个都是token,而且表示的属性也不一样。
- 解析(parse),又叫语法分析作用是将上一步生成的token数据,按照语法规则转换为AST,如果源码错误则抛出一个语法错误。
- 有了AST之后,V8会生成这段代码的执行上下文,并将对应的变量加入到变量环境,词法环境中。
2. 生成字节码
完成第一步之后,解释器会根据AST语法树生成对应字节码,并解释执行字节码。
这里要了解一下为啥V8不直接将AST转为执行效率更高的机器码呢,刚开始时V8确实没有字节码,但是因为Web在手机端广泛运用,特别运行在内存小的低端手机上,内存占用问题就暴露了,因为V8引擎需要大量的内存保存机器码,为了解决这个问题所以V8引入了字节码。
那字节码是什么呢?为啥它能解决内存占用问题呢?
字节码就是介于AST和机器码之间的一种代码,但是与特定的机器码无关,字节码还必须通过解释器转换为机器码之后才能执行。
我们在对比一下代码语言和字节码,机器码:
从上图我们可以看出机器码占用的内存远远比字节码多,所以使用字节码能显著的减少内存的使用。
3. 执行代码
生成字节码之后,V8进入执行代码阶段,解释器(lgnition)会逐行解释执行。在执行字节码的过程中,如果一段代码被重复多次执行,解释器就会标记这段代码为热点代码,而后台的编译器(TurboFan)就会将这段热点字节码编译为高效的机器码,等后面在执行这段被优化的代码时,直接执行编译后的机器码就可以了,大大提高了代码的执行效率。这种字节码配合解释器和编译器的技术称为即时编译(JIT),V8引擎解释器(lgnition)会在执行字节码的同时会收集代码信息,当它发现一段代码执行多次之后,会通知编译器(TurboFan)将这段热点代码转换为机器码,并保存起来,用于下次使用。
所以这也是为什么我们说V8引擎执行时间越长,执行效率越高的原因,执行时间越长,标记的热点代码越多,编译器保存的热点代码对应机器码越多,代码的执行效率就越高了。
JcvaScript代码运行过程
上面我们了解了V8引擎如何编译执行一段JavaScript代码的,但是执行过程中还有一些细节需要了解,比如调用栈,执行上下文,变量环境, 词法环境,this指向等等。
1. 执行上下文
想要更好理解JavaScript这门语言,就必须理解JavaScript的执行上下文。我们先看下的代码:
showName()
console.log(myname)
var myname = "Jack"
function showName() {
console.log("函数执行")
}
使用过JavaScript
的程序员都知道,它是按顺序执行的,但是按这个逻辑理解的话:
- 当执行到第一行的时候,函数
showName
还没有定义,执行应该报错。 - 执行到第二行也是一样的情况
myname
未定义,执行应该报错。
但是实际结果输出函数执行
和undefined
,没有报错。
这是因为JavaScript
代码执行过程中,JavaScript
引擎会将变量的声明部分和函数的声明部分提升到代码开头,这个行为被称为变量提升,变量提升之后,会给变量设置一个默认值undefined。
上面的代码就相当于:
// 变量提升
var myname = undefined;
function showName() {
console.log("函数执行")
}
myname = "Jack"
showName()
通过这段代码就能明白可以在定义前使用变量和函数的原因了。
实际上变量提升是发生在代码的编译阶段被JavaScript引擎放入内存中的,而不会改变源代码的位置。
在V8执行JavaScript
的过程中经过编译会生成两部分执行上下文和可解释执行的字节码,而执行上下文内就是执行这段代码的运行环境,里面存在一个变量环境的对象,里面就保存了变量提升的内容。
V8引擎什么情况下才会创建执行上下文,一般有三种情况:
- 当JavaScript执行全局代码时,会编译全局代码和创建全局执行上下文,全局执行上下文只有一份。
- 当调用一个函数时,函数体内的代码会被编译,然后创建函数执行上下文,一般情况下函数执行上下文在执行完毕之后会被销毁。
- 当时有
eval
函数,eval
代码也会被编译,创建执行上下文。
在执行JavaScript
的过程中,可能会存在多个执行上下文,所以V8引擎通过调用栈来管理多个执行上下文,采用先进后出的形式管理,示例代码:
var a = 2
function add(b, c) {
return b+c
}
function addAll(b, c) {
var d = 10
result = add(b,c)
return a+result+d
}
addAll(3,6)
我们通过下图分析这段代码的执行中,调用栈的变化:
第一步,创建全局上下文,将其压入调用栈中。
这个时候变量a
,函数add
,addAll
都被保存在全局上下文的变量环境中。压入栈后V8引擎开始执行全局代码,首先执行a = 2
赋值操作如下图:
接下来调用addAll
函数。当调用该函数时,V8引擎会编译该函数,并创建函数的执行上下文,最后将其压入调用栈中。
addAll
函数创建执行上下文之后,执行函数代码,先执行b = 10
,然后执行到add
函数调用时,通过创建它的执行上下文压入到调用栈中.
当函数add
执行完毕返回时,该函数的执行上下文就会从调用栈的栈顶弹出,然后result
的值就会被设置为它的返回值也就是9
:
最后函数addAll
执行完毕后返回最后的值,它的执行上下文也从栈顶弹出,最后调用栈中就只有全局执行上下文,这段代码就执行完毕了。
从上图可以看到执行上下文除了变量环境之外还有一个词法环境,为什么会存在一个词法环境,这个要从JavaScript
诞生之初的设计说起,ES6
之前,JavaScript
的作用域只要两种:全局作用域和函数作用域。而不像其他语言那样有块级作用域,因为在设计的时候,没有想到它会火起来,所以只按照最简单的设计,没有了块级作用域,直接将作用域内的变量统一提升到最前面,无疑是最快速,最简单的设计,所以无论在哪里声明变量,在编译阶段都会被提取到执行上下文的变量环境中,这些变量可以在作用域任何地方访问,这也是JavaScript
中的变量提升。
因为JavaScript
中的变量提升这一特性,不符合人的线性思维,所以导致了很多不符合直觉的代码和问题。
比如变量容易在不被察觉的情况下被覆盖等。
为了解决这个问题ES6中引入了const
, let
关键字,让JavaScript
也拥有了块级作用域。
作用域就是指程序定义变量的区域,这个区域决定了变量和函数的可访问范围和生命周期。
因为JavaScript
需要向下兼容,所以需要在支持变量提升的同时支持块级作用域,而块级作用域中的代码在执行完毕之后还需要销毁,所以在执行上下文中引入了词法环境解决这个问题,那它是如何解决的呢,我们通过示例代码执行分析:
function foo() {
let a = 1;
var b = 2;
if(true) {
let b = 3
var c = 4
let d = 5
console.log(a)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
首先创建执行上下文,如图:
如上图所示我们知道了:
- 函数内部通过
var
声明的变量,在编译阶段全部被存放在变量环境中 - 通过
let
声明的变量,在编译阶段会被全部存放在词法环境中 - 在函数中的块级作用域,通过
let
声明的变量并没有存放在词法环境中
然后执行代码 a = 1
,b = 2
,然后执行到块级作用域,如下图所示:
在进入块级作用域时,作用域中let
声明的变量会被存放在词法环境的一个单独区域中,这个区域的变量并不影响外面作用域的变量,当执行到作用域内部时它们都是独立存在的。
在词法环境中维护着一个小型的栈结构,栈底是函数最外层的变量,当执行到块级作用域时会将块级作用域中的变量压入栈中,当该作用域执行完毕之后,该作用域的信息会从栈定弹出。这就是词法环境的结构,这里的变量指的let
,const
声明的变量。
接下来执行到作用域中的console.log(a)
时会在词法环境和变量环境中查找变量a
,先从词法环境的栈顶开始向下查找,如果没有再到变量环境中查找,如果变量环境也没有的话,就会沿着作用域链一直向上查找,一直到全局作用域为止。
当执行完块级作用域的代码后,执行上下文就变成如下图所示:
当执行完后面的代码后,foo
函数的执行上下文弹出调用栈,内部的变量数据可能就不在需要使用了,这种数据称为垃圾数据,而这个时候我们需要对这些垃圾数据进行回收,释放有限的内存空间。
垃圾回收机制(GC)
通常情况下垃圾回收策略分为两种手动垃圾回收和自动垃圾回收:
- 手动垃圾回收种何时分配内存,何时销毁内存都是由代码控制。
- 自动垃圾回收是由垃圾回收器来释放内存。
而JavaScript
的垃圾回收机制就是自动回收策略。JavaScript
的数据是存储在栈和堆两种内存空间中,它的基本类型数据存储在栈内存例如String
,Number
,Undefind
,NaN
,Null
,Boolean
,而像对象Object
,Array
等的引用类型数据则存储在堆内存中。
我们上面提到当foo
函数执行完毕之后,它的执行上下文会被销毁,但是是如何被销毁的呢?
当执行到foo
函数时,V8引擎会创建它的执行上下文并将其压入执行栈中,而这个时候还有一个记录当前执行状态的指针(ESP),这个指针会指向调用栈中的foo
的执行上下文,表示当前正在执行foo
函数。
当foo
函数执行完毕之后,需要销毁它的执行上下文,这个时候ESP会下移到全局执行上下文,这个下移操作就是销毁foo
函数执行上下文的过程,当ESP指向全局执行上下文后,foo执行上下文虽然还保存在栈内存中,但已经是无效内存了,当全局执行上下文中在调用其他函数时,创建的新函数执行上下文会将其直接覆盖掉,所以说V8引擎通过向下移动ESP来销毁该函数保存在栈中的执行上下文。
堆中数据如何回收
说完栈中数据回收我们再说下堆内存的数据回收,V8会将堆内存分为两块区域分别新生代和老生代:
- 新生代存储着生存时间短的对象。
- 老生代存储着生存时间长的对象。
新生代一般是1~8M的内存,而老生代就大很多。V8对这两块区域分别采用不同的垃圾回收器
- 副垃圾回收器负责新生代区域的垃圾回收
- 主垃圾回收器负责老生代区域的垃圾回收
不过不管是老生代还是新生代都是采用的同一套垃圾回收机制:
- 第一步标记内存中活动对象和非活动对象,活动对象是指还在使用的对象,而非活动对象则是已经不再使用的对象。
- 第二步回收非活动对象所占据的内存,在标记完成之后统一回收所有标记的可回收对象。
- 第三步做内存整理,因为多次回收之后,内存中回存在不连续的空间,称为内存碎片,当碎片过多的时候,在存储较大数据时,可能会出现内存不足的情况,不过这步操作是可选的,有些垃圾回收器不会产生内存碎片,比如副垃圾回收器。
副垃圾回收器
副垃圾回收器主要负责新生代区域的回收,新生代区域主要存储比较小的对象,它将区域划分为两块,一半是对象区域,一般是空闲区域。
新加入的对象放进对象区域,当对象区域快满的时候,执行一次垃圾回收,副垃圾回收器在过程中,会先标记对象区域的回收对象,后在将未标记的活动对象复制到空闲区域中,在复制的过程中还会进行排序整理,所以不会产生内存碎片,当复制完成以后,会对空闲区域和对象区域进行翻转,相互调换,这样空闲区域就成了新的对象区域,而之前的对象区域就变成了空闲区域,翻转之后,将新的空闲区域清空。
因为每次进行垃圾回收的时候都会进行一次复制操作,需要时间成本,所有新生区域被设置的比较小。
对象晋升策略
因为新生区域比较小,容易被装满所以又有了对象晋升策略,当新生区域经过两次回收之后还存活的对象会晋升到老生区域。
主垃圾回收器
主垃圾回收器负责老生区域的垃圾回收,老生区域主要存储大的对象和新生区域晋升上来的对象。因为老生区存储的对象比较大,复制时间成本较多,所以老生区没有使用新生区的策略,而是使用标记-清理的算法进行回收。
首先先从一组根元素进行递归遍历这组根元素,能到达的对象称为活动对象,没有到达的就标记为垃圾数据。
然后执行清理操作,但是多次进行标记-清理之后,会产生许多不连续的内存碎片,所有又有了标记-整理算法,在标记后不做清理操作,而做整理操作,将所有对象往一边移动,清理掉边界外内存。
全停顿
因为V8是单线程的,JS的执行和回收都在主线程执行,当进行垃圾回收时,JS会停止执行,回收完毕之后才会继续进行执行,我们称为全停顿。
这就导致了一个问题,当堆空间存储过大时,垃圾回收执行时间过长,这个时候如果有一个动画正在执行,就会造成卡顿的现象,为了解决这个问题,主垃圾回收器又将标记过程分为一个个子过程,让垃圾回收标记和JS执行交替执行,直至标记完成,这个算法称为增量标记。
标记完成之后在进行对应的清理和整理。