coconilu/Blog

借助devtools了解V8引擎运行过程

Opened this issue · 0 comments

V8引擎运行过程

了解V8引擎,从了解它的运行过程开始。

粗略来说,V8运行JavaScript仅包含两个步骤:

  1. 解析编译代码
  2. 执行代码

当然还包括穿插其中的垃圾回收过程。

以chrome浏览器为例,打开devtools的Performance,访问随便一个网页(这里是百度)。如下图:

image

从上图中的火焰图看出,几乎每一个Evaluate Script的前面部分是Compile Script,这就是解析编译代码阶段,这个阶段很重要,后面的(anonymous)就是执行阶段,之所以叫anonymous,是因为它是一个匿名函数。

初始化宿主环境

JavaScript作为脚本语言,主要是运行在宿主环境中的,宿主环境可以是NodeJS进程,也可以浏览器的渲染进程。

所以网页在执行第一行脚本代码时,应该已经准备好了JS的运行环境,主要包括如下:

  1. 堆空间和栈空间
  2. 全局执行上下文、全局作用域
  3. 内置的内建函数、宿主环境提供的扩展函数和对象,比如我们熟悉的window、document、navigator,还有ECMAScript标准里提到对象
  4. 消息队列和事件循环系统

Web API 参考
JS 标准参考

解析编译代码

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);

image

image

图中的右边,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指向。

惰性编译与闭包

惰性编译有一个问题,或许也困扰着你。

那就是,每个函数只有在准备执行的时候才会去解析它的执行上下文,那么闭包怎么办?

闭包的产生原因:

  1. JS允许在函数内部定义新的函数
  2. 在内部函数中访问父函数中定义的变量
  3. 函数可以作为返回值

可以看下面的代码:

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执行前的堆栈:

image

在inner执行前的堆栈

image

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();

image

使用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();

image

从上图可以看出来,虽然f函数是在outter函数里创建的,按照之前的讨论,它的作用域链的下一个作用域应该是outter,但从结果看并不是。

也就是说,使用new Function创建的函数的作用域链的下一个作用域都是全局作用域。

执行代码

当执行上下文已经准备就绪,V8引擎将会从上到下执行代码。

因为消息队列和事件循环系统的存在,当<script>里的代码都执行完毕后,并不会退出JS执行环境,因为会有一些用户交互事件:比如onClick、onScroll、网络请求等等的事件会发生,那么我们注册的相关事件监听器将会被执行。

宏任务与微任务

在消息队列里有宏任务和微任务之分。每一个宏任务在执行过程中如果生成了很多微任务,那么在执行下一个宏任务之前会把微任务执行完毕。

宏任务包括:setTimeout、setInterval、ajax回调、eventListener、UI rendering

微任务包括:Promise、MutationObserver

垃圾回收

JS和Java一样,是不需要开发者自己回收内存的,但是随着页面运行时间越来越长,无用的对象也越来越多,不回收的话系统资源将会很吃紧。

垃圾回收算法大致如下三个步骤:

1. 标记是否可以回收

V8引擎有GC Roots的概念,主要包括:

  1. 全局的 window 对象(位于每个 iframe 中)
  2. 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成
  3. 调用栈

通过GC Root可以标记哪些对象是可访问的,哪些对象是不可访问的,比如挂载到window的变量是不会被回收的,dom对象也是不会被回收的,调用栈上调用结束的作用域上的对象会被回收。

2. 回收内存

被标记为不可访问的对象所占的内存会被回收。

3. 内存整理

频繁回收对象后,内存中就会存在大量不连续空间(内存碎片),需要进行内存整理,以便重复使用。

V8引擎是基于代际假说进行垃圾回收的,它有两个垃圾回收器,主垃圾回收器 -Major GC 和副垃圾回收器 -Minor GC (Scavenger)。

代际假说:大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。

V8引擎的堆被分成两个区域,新生代和老生代。Minor GC负责新生代的垃圾回收,Major GC负责老生代的垃圾回收。

image

Minor GC的Scavenge 算法

Minor GC把新生代空间对半划分为两个区域,一半是对象区域 (from-space),一半是空闲区域 (to-space)。

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。清理操作就是把对象区里的存活对象有序放到空闲区里,然后清空对象区里的内存,完成之后就可以保证空闲区里没有内存碎片,对象区也是干净利落的。

然后对象区和空闲区角色对换,之前的对象区变成空闲区,之前空闲区变成对象区。这样一来,两个区域就可以无限重复使用下去。

对于经过两次垃圾回收依然还存活的对象,将会晋升到老生代里去。

Major GC的标记 - 整理(Mark-Compact)算法

除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。

首先也是标记阶段,标记可回收的对象。然后让所有存活的对象都向一端移动,最后直接清理掉这一端之外的内存。

最后

image

V8引擎在解析代码生成AST树,通过AST树生成了执行上下文还有中间字节码,然后由解释器去执行,到这一步,我们的代码就算执行完毕了。但是V8引擎还引入了即时编译技术,通过监视哪些代码是经常被执行的,就会直接把它编译成机器码,下一次执行的时候,就可以通过机器码来加速运行效率。

参考

极客时间——李兵的《图解 Google V8》
极客时间——李兵的《浏览器工作原理与实践》