Skip to content

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

@coconilu

Description

@coconilu

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》
极客时间——李兵的《浏览器工作原理与实践》

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions