浏览器中的JavaScript执行机制

6,976 阅读5分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

前言

想要了解 JavaScript 的执行机制,我们可以从编译过程和内存管理来入手。之前,我在《从 Babel 到组件按需引入原理》一文中介绍过 JavaScript 编译过程。那今天我们主要讲一讲 JavaScript 引擎内存管理的过程。

JavaScript 引擎的内存空间分为(Heap)和(Stack)。堆是具有树结构的数组,栈也是数组,但是遵循“先进后出”规则。

栈是一个临时存储空间,主要存储局部变量和函数调用(对于全局表达式会创建匿名函数并调用)。

对于基本数据类型(String、Undefined、Null、Boolean、Number、BigInt、Symbol)的局部变量,会直接在栈中创建,而对象数据类型局部变量会存储在堆中,栈中只存储它的引用地址,也就是我们常说的浅拷贝。全局变量以及闭包变量也是只存储引用地址。总而言之栈中存储的数据都是轻量的。

对于函数,解释器创建了“调用栈”(Call Stack)来记录函数的调用流程。每调用一个函数,解释器就会把该函数添加进调用栈,解释器会为被添加进的函数创建一个栈帧 (Stack Frame,这个栈帧用来保存函数的局部变量以及执行语句)并立即执行。如果正在执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈并执行。一旦这个函数执行结束,对应的栈帧也会被立即销毁。

查看调用栈的方式有 2 种:

  • 调用函数 console.trace() 打印到控制台;
  • 利用浏览器开发者工具进行断点调试。

示例:

下面的代码是一个计算斐波那契数列的函数,分别通过调用 console.trace() 函数以及断点的方式得到了它的调用栈信息。

function fib(n) {
  if (n < 3) return 1
  console.trace();
  return fib(n-1) + fib(n-2)
}
fib(4)

捕获.PNG

加上 debugger:

捕获1.PNG

栈会在使用时创建,使用结束时销毁,但它并不是可以无限增长的。当分配的调用栈空间被占满时,就会引发“栈溢出”错误。

下面是一个递归函数导致的栈溢出报错代码片段:

(function stackMax() {
  stackMax()
})()

栈溢出错误:

image.png

堆空间存储的数据比较复杂,大致可以划分为下面 5 个区域:

  • 代码区(Code Space)
  • Map 区(Map Space)
  • 大对象区(Large Object Space)
  • 新生代(New Space)
  • 老生代(Old Space)

这里重点讨论新生代和老生代的内存回收算法。

新生代

大多数的对象最开始都会被分配在新生代,该存储空间相对较小,只有几十 MB,分为两个空间:from 空间和 to 空间。

image.png

程序中声明的对象首先会被分配到 from 空间,当进行垃圾回收时,会先将 from 空间中存活的的对象(存活对象可以理解为被引用的对象)复制到 to 空间进行保存,对未存活的对象空间进行回收。当复制完成后,from 空间和 to 空间进行调换,to 空间会变为新的 from 空间,原来的 from 空间则变为 to 空间,这种算法称之为 “Scavenge”。

新生代的内存回收频率很高、速度也很快,但空间利用率较低,因为让一半的内存空间处于“闲置”状态。

老生代

新生代中多次回收仍然存活的对象会被转移到空间较大的老生代。因为老生代空间较大,如果回收方式仍然采用 Scanvage 算法来频繁复制对象,那性能开销会比较大。

所以老生代采用的是另一种标记清除(Mark-Sweep)的方式来回收未存活的对象空间。

这种方式主要分为标记和清除两个阶段。标记阶段会遍历堆中所有对象,并对存活的对象进行标记;清除阶段则是对未标记对象的空间进行回收。

image.png

由于标记清除不会对内存一分为二,所以不会浪费空间。但是进行过标记清除之后的内存空间会产生很多不连续的碎片空间,这种不连续的碎片空间中,在遇到较大对象时可能会由于空间不足而导致无法存储的情况。

为了解决内存碎片的问题,提高对内存的利用,还需要使用到标记整理(Mark-Compact) 算法。标记整理算法相对于标记清除算法在回收阶段进行了改进,标记整理对待未标记的对象并不是立即进行回收,而是将存活的对象移动到一边,然后再清理。当然这种移动对象的操作相对而言是比较耗时的,所以执行速度上,比标记清除要慢。

image.png

总结

今天聊了一下 JavaScript 引擎的内存分为栈和堆两个部分,栈可以保存函数调用信息以及局部变量,特点是“先进后出”以及“用完立即销毁”。堆区存储的数据对象通常比较大,需要采用一定的回收算法来处理,包括用于新生代的 Scanvage 算法,以及用于老生代的标记清除和标记整理算法。