借助devtools了解V8引擎运行过程
Opened this issue · 0 comments
V8引擎运行过程
了解V8引擎,从了解它的运行过程开始。
粗略来说,V8运行JavaScript仅包含两个步骤:
- 解析编译代码
- 执行代码
当然还包括穿插其中的垃圾回收过程。
以chrome浏览器为例,打开devtools的Performance,访问随便一个网页(这里是百度)。如下图:
从上图中的火焰图看出,几乎每一个Evaluate Script的前面部分是Compile Script,这就是解析编译代码阶段,这个阶段很重要,后面的(anonymous)就是执行阶段,之所以叫anonymous,是因为它是一个匿名函数。
初始化宿主环境
JavaScript作为脚本语言,主要是运行在宿主环境中的,宿主环境可以是NodeJS进程,也可以浏览器的渲染进程。
所以网页在执行第一行脚本代码时,应该已经准备好了JS的运行环境,主要包括如下:
- 堆空间和栈空间
- 全局执行上下文、全局作用域
- 内置的内建函数、宿主环境提供的扩展函数和对象,比如我们熟悉的window、document、navigator,还有ECMAScript标准里提到对象
- 消息队列和事件循环系统
解析编译代码
V8引擎在解析编译阶段,主要是把代码解析成一颗AST树(抽象语法树),然后进行词法分析作用域(链),进而得到执行上下文。
我们通常说的变量提升和函数声明提升,就是通过分析AST树然后把var、let、const声明的变量和函数声明放到执行上下文的,但是let和const什么的变量在初始化前不能访问,这就叫暂时性死区。
执行上下文,包括全局执行上下文和函数执行上下文。页面上的被<script>包括起来的脚本就是运行在全局执行上下文中的,全局执行上下文伴随着页面的整个生命周期。
函数执行上下文是运行时生成的,也就是常说的惰性编译。这里不得不提一下,在JS中,函数是一等公民,是特殊的对象,它有两个隐藏属性,一个是name,一个是code,name会在查看Performance的火焰图的时候展示出来,code属性表示这个对象可以被执行。
执行上下文包括了变量环境、词法环境、this。变量环境收集的是这个代码段(<script>里的代码或者函数代码)里的var声明的变量、函数声明、参数变量(在函数执行上下文里)和arguments对象(在函数执行上下文里),词法环境负责收集ES6提出的局部作用域里的let、const声明的变量,this比较特殊,它是运行时确定的,默认指向全局对象(window),也不会从作用域链中继承。
this是运行时确定的。可以通过bind、apply、call绑定this指向。
用一段代码来证明上面的结论,在devtools里的Source创建一个新的Snippet(效果相当于在当前页面添加一个script标签),在里面贴上如下代码:
var global_1 = "a";
function global_f(a1) {
var local_1 = "b";
function local_f() {
}
const local_2 = 2;
local_f();
if (true) {
debugger ;
const local_3 = 3;
var local_4 = 4;
}
console.log(arguments)
}
const global_2 = 2;
global_f(global_2);
图中的右边,Call Stack表示调用栈,Scope表示作用域。debugger是V8支持的断点语句,可以打印堆栈。第一个debugger暂停在global_f函数执行前,第二个debugger暂停在global_f函数里的块作用域里。
执行上下文和作用域是不一样的概念,执行上下文除了能生成当前“基”作用域外,还可以生成块作用域(ES6以后)。
从上图我们可以看出:
执行上下文的变量环境和this会放在“基”作用域(local)。
在全局执行上下文里声明的let、const变量(词法环境)并不会并入全局作用域(window)里,而是生成了一个块作用域(Script)。
在global_f函数执行的时候,local_1、local_2、local_4放在Local作用域(global_f)里,local_3放在了Block(块作用域,也叫局部作用域)里。所以let和const声明的变量只有在代码块里才会产生块作用域,不然和var声明的变量差别不大,除了会有暂时性死区的效果。
作用域链就是一个个作用域堆叠形成的,在当前作用域找不到变量,会往下继续查找。
箭头函数
箭头函数没有自己的this和arguments对象。
且不能通过bind、apply、call修改this指向。
惰性编译与闭包
惰性编译有一个问题,或许也困扰着你。
那就是,每个函数只有在准备执行的时候才会去解析它的执行上下文,那么闭包怎么办?
闭包的产生原因:
- JS允许在函数内部定义新的函数
- 在内部函数中访问父函数中定义的变量
- 函数可以作为返回值
可以看下面的代码:
function outter() {
debugger;
var local_1 = 1;
var local_2 = 2;
function inner(){
debugger;
console.log(local_1);
}
return inner
}
var inner = outter();
inner();
在outter执行前的堆栈:
在inner执行前的堆栈
outter执行完之后它的执行上下文(包括作用域)就被注销了,但是inner在执行的时候,会发现它的作用域下面还有一个作用域——Closure (outter),这就是闭包。
至此,可以得出一个结论,V8有一个预解析器,当它在解析一个执行上下文的时候,如果遇到函数并不会完全跳过去,而是进行一次快速的预解析,分析这个函数是否引用了当前执行上下文的变量,如果有的话,就会生成一个闭包与这个函数绑定,闭包里会存储外部变量的拷贝——当外部变量修改的时候闭包也会同步修改。所以当外部函数执行结束后就可以安全移除它的作用域了,因为内部函数所需要的外部变量已经存在闭包里。
修改执行上下文
在JS中,可以通过with和new Function()修改上下文。
看下面的代码:
function outter() {
var a = 1;
var b = 2;
var o = {a: 10, b: 20}
with(o) {
debugger;
console.log(a);
console.log(b);
}
}
outter();
使用with语句,会生成一个新的作用域(With Block),你也会看到这个作用域下面有很多其它的属性,诸如constructor、hasOwnProperty、toString,如果你在with语句里恰好想引用外部的toString,那么只能通过给o对象添加一个[Symbol.unscopables]
属性,详情可以看这里。
再看下面的代码:
var a = 10;
function outter() {
var a = 1;
var b = 2;
var f = new Function('debugger;console.log(a)')
f();
}
outter();
从上图可以看出来,虽然f函数是在outter函数里创建的,按照之前的讨论,它的作用域链的下一个作用域应该是outter,但从结果看并不是。
也就是说,使用new Function创建的函数的作用域链的下一个作用域都是全局作用域。
执行代码
当执行上下文已经准备就绪,V8引擎将会从上到下执行代码。
因为消息队列和事件循环系统的存在,当<script>里的代码都执行完毕后,并不会退出JS执行环境,因为会有一些用户交互事件:比如onClick、onScroll、网络请求等等的事件会发生,那么我们注册的相关事件监听器将会被执行。
宏任务与微任务
在消息队列里有宏任务和微任务之分。每一个宏任务在执行过程中如果生成了很多微任务,那么在执行下一个宏任务之前会把微任务执行完毕。
宏任务包括:setTimeout、setInterval、ajax回调、eventListener、UI rendering
微任务包括:Promise、MutationObserver
垃圾回收
JS和Java一样,是不需要开发者自己回收内存的,但是随着页面运行时间越来越长,无用的对象也越来越多,不回收的话系统资源将会很吃紧。
垃圾回收算法大致如下三个步骤:
1. 标记是否可以回收
V8引擎有GC Roots的概念,主要包括:
- 全局的 window 对象(位于每个 iframe 中)
- 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成
- 调用栈
通过GC Root可以标记哪些对象是可访问的,哪些对象是不可访问的,比如挂载到window的变量是不会被回收的,dom对象也是不会被回收的,调用栈上调用结束的作用域上的对象会被回收。
2. 回收内存
被标记为不可访问的对象所占的内存会被回收。
3. 内存整理
频繁回收对象后,内存中就会存在大量不连续空间(内存碎片),需要进行内存整理,以便重复使用。
V8引擎是基于代际假说进行垃圾回收的,它有两个垃圾回收器,主垃圾回收器 -Major GC 和副垃圾回收器 -Minor GC (Scavenger)。
代际假说:大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。
V8引擎的堆被分成两个区域,新生代和老生代。Minor GC负责新生代的垃圾回收,Major GC负责老生代的垃圾回收。
Minor GC的Scavenge 算法
Minor GC把新生代空间对半划分为两个区域,一半是对象区域 (from-space),一半是空闲区域 (to-space)。
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。清理操作就是把对象区里的存活对象有序放到空闲区里,然后清空对象区里的内存,完成之后就可以保证空闲区里没有内存碎片,对象区也是干净利落的。
然后对象区和空闲区角色对换,之前的对象区变成空闲区,之前空闲区变成对象区。这样一来,两个区域就可以无限重复使用下去。
对于经过两次垃圾回收依然还存活的对象,将会晋升到老生代里去。
Major GC的标记 - 整理(Mark-Compact)算法
除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。
首先也是标记阶段,标记可回收的对象。然后让所有存活的对象都向一端移动,最后直接清理掉这一端之外的内存。
最后
V8引擎在解析代码生成AST树,通过AST树生成了执行上下文还有中间字节码,然后由解释器去执行,到这一步,我们的代码就算执行完毕了。但是V8引擎还引入了即时编译技术,通过监视哪些代码是经常被执行的,就会直接把它编译成机器码,下一次执行的时候,就可以通过机器码来加速运行效率。
参考
极客时间——李兵的《图解 Google V8》
极客时间——李兵的《浏览器工作原理与实践》