文章

V8引擎(十)

V8

V8 引擎学习(十)

V8引擎(十)

前瞻

前面的章节中了解了 V8 中的 JavaScript,但似乎对 V8 本身了解较浅,那么本章会来揭晓 V8 的细节以及它的编译流水线。

深入 V8 引擎

当你想执行一段 JavaScript 代码时,只需要将代码丢给 V8 虚拟机即可,V8 便会执行并返回给你结果。

其实在执行 JavaScript 代码前,V8 就已经准备好了代码的运行环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,当还有消息循环系统(上一章说过,V8 执行代码时,会进入消息循环的状态)。在准备好运行时环境后,V8 才可以执行 JavaScript 代码,这一过程包括解析源码、生成字节码、解释执行或编译执行这一操作。

image-20230302230022457

环境中的各个部分到底有啥用?

堆空间和栈空间可以让我们了解为什么要有传值和传引用,栈空间可以让我们了解函数是怎么被调用的,事件循环系统可以让我们了解各种回调函数是怎么被执行的等等…

image-20230302230814426

什么是宿主环境?

提到宿主,就会想到病毒。宿主为病毒提供了生存环境,并且宿主有自己的完整的系统,而病毒是没有的,因此病毒想要完成自我复制,是需要和宿主共用一套系统的。简言之,病毒离开宿主就生活不了了。同样地,可以把 V8 和浏览器的渲染进程的关系看成病毒和宿主,浏览器为 V8 提供了基础的消息循环系统、全局变量、web API 等,而 V8 的核心是实现了 ECMAScript 规范,V8 只提供了 ECMAScript 定义的一些对象和一些核心的函数,除此以外,V8 还提供了垃圾回收器、协程等内容,当然提供的一切内容都需要配合宿主环境才能完整执行。

假如 V8 使用不当,如不规范的代码触发了频繁的垃圾回收,或者某个函数执行时间过长,这些都会占用宿主环境的主线程,从而影响到程序的执行效率,甚至导致宿主环境的死机。

当然除了浏览器可以作为 V8 的宿主环境,Node.js 当然也可以作为 V8 的宿主环境,只是二者提供的宿主对象和 API 不同罢了。正因为 V8 实现的是 ECMAScript 规范,所以它都可以兼容浏览器和 Node 环境,只是两个环境自身的差异造就了不同,而非 V8 造成了不同。

总之,现在我们了解了要执行 V8,需要有一个宿主环境,宿主环境可以是浏览器中的渲染进程,也可以是 Node.js 进程,或者其他适配 V8 的开发环境。

堆空间和栈空间

堆空间和栈空间构造了数据存储的空间,由于 V8 堪比病毒,寄生在浏览器或者 Node.js 这些宿主中的,那么 V8 也是被这些宿主启动的。如,在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间。

其实,上面提到过堆空间和栈空间的作用是什么。栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是”先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放到栈上,如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在栈上。当一个函数执行结束后,那么该函数的执行上下文便会被销毁掉。

栈空间最大的特点就是空间连续,所以栈中每个元素的地址都会固定的,因此栈空间的查找效率非常高,但是通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层过深,那么 V8 就有可能抛出栈溢出的错误。其实,在日常开发中,很容易看到这个错误,下次看到这个错误时,就不要以为是浏览器警告的啦,实际上是 V8 在告诉你:栈溢出了哦 ❌

如果有一些占用内存比较大的数据,或者不需要存储在连续空间中的数据,使用栈空间就不太合适了,所以 V8 又使用了堆空间。

堆空间是另一种数据结构,它是一种树形的存储结构,用来存储对象类型的离散的数据,那么比如函数、数组等等这些都是存储在堆空间的。

宿主在启动 V8 的过程中,会同时创建堆空间和栈空间,再继续往下执行,产生的新数据都会存放在这两个空间中。

全局执行上下文和全局作用域

V8 初始化了基础的存储空间后,接下来就需要初始化全局执行上下文和全局作用域了。

当 V8 开始执行一段可执行代码时,会生成一个执行上下文。V8 用执行上下文来维护执行当前代码所需要的变量声明、this 指向等。

执行上下文中主要包含了三部分:变量环境、词法环境、this 关键字。如在浏览器环境中,全局执行上下文就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数。而词法环境则包含了 let、const 等变量的内容。

image-20230303001614910

全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。另外,如果执行了一段全局代码时,全局代码中有声明的函数或者定义的变量,那么函数对象和声明的变量都会被添加到全局执行上下文中,如:

let x = 1;
function foo() {
  console.log(x);
}

V8 在执行这段代码时,会在全局执行上下文中添加变量 x 和函数 foo。

需要注意的是:全局作用域和全局执行上下文的关系。同一个全局执行上下文中,能存在多个作用域。

let x = 5;
{
  let y = 2;
  const z = 3;
}

上方代码中,就存在两个对应的作用域,一个是全局作用域,另外一个是括号内部的作用域,但是这些作用域都会保存到全局执行上下文中。

image-20230303141131202

当 V8 调用了一个函数时,就会进入函数的执行上下文,这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构。

let x = 1;
function foo() {
  console.log(x);
}
function bar() {
  foo();
}
bar();

上方代码中,当执行到 foo 函数时,其栈状态如下:

image-20230303141800751

事件循环系统

有了堆空间和栈空间,生成了全局执行上下文和全局作用域,接下来就可以执行 JavaScript 代码了吗?是不行的。因为 V8 还需要一个主线程,用来执行 JavaScript 和执行垃圾回收等工作。V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的。

显然地,只有一个主线程依然不行,如果只开启一个线程,在该线程中执行一段代码,那么当该线程执行完这段代码后,就会自动退出了,执行过程中的一些栈上的数据也随之销毁,下次执行另一段代码时,还需要另外重新启动一个线程,重新初始化栈数据,严重影响到程序执行时的性能。

为了在代码执行完后,让线程继续运行,通常是在代码中添加一个循环语句。在循环语句中,监听下个事件,要执行另外一个语句时,激活该循环就行了。

while(1) {
  Task task = GetNewTask();
  RunTask(task);
}

这段代码中使用了一个循环,不断地获取新任务,一旦有新任务,便立即执行该任务。

如果主线程正在执行一个任务,这时又来了一个新任务,如 V8 正在操作 DOM,这时又需要 V8 注册监听下载完成的事件,那么这种情况下就需要引入一个消息队列,让下载完成的事件暂存到消息队列中,等当前任务结束之后,再从消息队列中取出正在排队的任务。当执行完一个任务之后,事件循环系统就会重复这个过程,继续从消息队列中取出并执行下个任务。需要注意的是:因为所有的任务都是运行在主线程的,在浏览器的页面中,V8 会和页面共用主线程,共用消息队列,所以如果 V8 执行一个函数过久,那么就会影响到浏览器页面的交互性能。

总结

  • V8 并不是一个完整的系统,它的核心就是实现了 ECMAScript 规范,所以在执行时,是需要宿主环境配合的。
  • V8 不会自启动,而是伴随着宿主环境的启动而启动,启动后会构造堆空间和栈空间,堆空间是树形结构,用来存放一些对象数据;栈空间则是用来存放原生数据和函数调用的。由于堆空间不是连续存储,因此读取速度会比较慢,但可以存储很多数据;栈空间是连续的,所以查找速度非常快,由于在内存中开辟一块连续的区域有点难度,所以 V8 会限制栈空间的大小,从而会很容易出现栈溢出错误。
  • 全局执行上下文在 V8 启动过程中就已经准备好了。全局执行上下文和函数执行上下文的生命周期是不同的,函数执行上下文在函数执行结束之后就销毁了,而全局执行上下文则和 V8 的生命周期一致。
  • 宿主环境还需要构造事件循环系统,事件循环系统主要用来处理任务的排队和任务的调度。
  • 作用域是静态的,函数定义时就已经确定了;而执行上下文是动态的,调用函数时才创建,并且结束后会释放掉。