彻底弄懂JavaScript的垃圾回收机制

561 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第22天,点击查看活动详情

有些数据被使用之后,可能就不再需要了,我们把这种数据称为垃圾数据。如果这些垃圾数据一直保存在内存中,那么内存会越用越多,所以我们需要对这些垃圾数据进行回收,以释放有限的内存空间。

JavaScript 采用了自动垃圾回收的策略,也正是这个"自动"释放资源的特性带来了很多困惑,也让一些 JavaScript 开发者误以为可以不关心内存管理,这是一个很大的误解。

因为数据是存储在栈和堆两种内存空间中的,所以接下来我们就来分别介绍"栈中的垃圾数据"和"堆中的垃圾数据"是如何回收的。

调用栈中的数据是如何回收的

function foo() {
  var a = 1
  var b = { name: '极客邦' }
  function showName() {
    var c = '极客时间'
    var d = { name: '极客时间' }
  }
  showName()
}
foo()

当执行到第 6 行代码时,其调用栈和堆空间状态图如下所示:

image.png

如果执行到 showName 函数时,那么 JavaScript 引擎会创建 showName 函数的执行上下文,并将 showName 函数的执行上下文压入到调用栈中,最终执行到 showName 函数时,其调用栈就如上图所示。与此同时,还有一个记录当前执行状态的指针(称为 ESP),指向调用栈中 showName 函数的执行上下文,表示当前正在执行 showName 函数。

接着,当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程。

image.png

从图中可以看出,当 showName 函数执行结束之后,ESP 向下移动到 foo 函数的执行上下文中,上面 showName 的执行上下文虽然保存在栈内存中,但是已经是无效内存了。比如当 foo 函数再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文

所以说,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。

堆中的数据是如何回收的

当上面那段代码的 foo 函数执行结束之后,ESP 应该是指向全局执行上下文的,那这样的话,showName 函数和 foo 函数的执行上下文就处于无效状态了,不过保存在堆中的两个对象依然占用着空间,如下图所示:

image.png

从图中可以看出,1003 和 1050 这两块内存依然被占用。要回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器了。

标记清除(Mark-Sweep)

首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

比如最开始的那段代码,当 showName 函数执行退出之后,这段代码的调用栈和堆空间如下图所示:

image.png

从上图你可以大致看到垃圾数据的标记过程,当 showName 函数执行结束之后,ESP 向下移动,指向了 foo 函数的执行上下文,这时候如果遍历调用栈,是不会找到引用 1003 地址的变量,也就意味着 1003 这块数据为垃圾数据,被标记为红色。由于 1050 这块数据被变量 b 引用了,所以这块数据会被标记为活动对象。这就是大致的标记过程。

image.png

上面的标记过程和清除过程就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

image.png

全停顿

于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

比如堆中的数据有 1.5GB,V8 实现一次完整的垃圾回收需要 200ms 以上的时间,这也是由于垃圾回收而引起 JavaScript 线程暂停执行的时间,若是这样的时间花销,那么应用的性能和响应能力都会直线下降。主垃圾回收器执行一次完整的垃圾回收流程如下图所示:

image.png

如果在执行垃圾回收的过程中,占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行的,这将会造成页面的卡顿现象。

为了降低垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。如下图所示:

image.png

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

工作中如何避免内存泄漏

什么样的数据是垃圾

首先需要强调一点,什么样的变量是需要被回收的?闭包就一定要被回收吗?

答案是否定的。只有不是符合用户预期的变量,就是垃圾,需要被回收。

function fn() {
    const obj = { x: '123' }
    window.obj = obj
}

对象 {x: '123'}会被回收吗?不会。因为用户就是想要在window上挂载这个对象,这是符合用户预期的,所以这个对象不是垃圾。

function getDataFns() {
    const data = {} // 闭包
    return {
        get(key) {
            return data[key]
        },
        set(key, value) {
            data[key] = value
        }
    }
}

像闭包也不会被回收,因为闭包也是符合用户预期的,用户返回了一个get和set函数,就是要给外面使用的。

所以,我们可以知道内存泄漏主要是针对不是符合用户预期的变量没有被回收所导致的,闭包也是符合预期的,所以闭包不算内存泄漏。

内存泄漏的场景

  1. 被全局变量和函数引用,组件销毁时未清除

以vue举例

mounted() {
    window.arr = this.arr
    window.printArr = () => {console.log(this.arr)}
}
beforeUnmounted() {
    window.arr = null
    window.printArr = null
}

如果不清除,那么它引用了this,this就是当前这个组件,非常的大。

  1. 被全局事件、定时器引用,组件销毁时未清除
mounted() {
    this.intervalId = setInterval(() => {console.log(this.arr)}, 1000)
    window.addEventListener('resize', this.printerArr)
}
beforeUnmounted() {
   if (this.intervalId) {
       clearInterval(this.intervalId)
   }
   window.removeEventListener('resize', this.printerArr)
}
  1. 被自定义事件引用,组件销毁时未被销毁
event.$on('change')
event.$off('change')