JavaScript内存管理

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

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

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

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

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

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

示例

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

控制台打印结果
控制台打印结果

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

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

1
2
3
(function recursive() {
  recursive()
})()

栈溢出错误

递归优化 - 尾调用

递归调用由于调用次数较多,同时每层函数调用都需要保存栈帧,所以通常是比较消耗内存的操作。对递归的优化一般有两个思路,减少递归次数和使用尾调用

尾调用(Tail Call)是指函数的最后一步返回另一个函数的调用。例如下面的代码中,函数 a() 返回了函数 b() 的调用。

1
2
3
function a(x){
return b(x);
}

尾调用由于是在 return 语句中,并且是函数的最后一步操作,所以局部变量等信息不需要再用到,从而可以立即释放节省内存空间。

1
2
3
4
5
6
7
8
function fib(n) {
  if (n < 3) return 1
  return fib(n-1) + fib(n-2)
}
function fibTail(n, a = 0, b = 1) {
  if (n === 0) return a
  return fibTail(n - 1, b, a + b)
}

堆空间存储的数据比较复杂,大致可以划分为下面 5 个区域:代码区(Code Space)、Map 区(Map Space)、大对象区(Large Object Space)、新生代(New Space)、老生代(Old Space)。这一课时重点讨论新生代和老生代的内存回收算法。

新生代

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

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

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

示例图

老生代

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

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

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

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

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

坚持原创技术分享,您的支持将鼓励我继续创作!