JS 垃圾回收机制

我们在写一些底层语言如 C 的时候,会学习到 malloc 这样用于内存分配的接口,但在 JavaScript 中,这些接口没有暴露给开发者。JavaScript 的策略是在创建变量时分配内存,并在不使用它的时候自动释放内存进行垃圾回收(简称 GC)。但这里有个很明显的问题:如何界定变量是否需要使用呢?,因此本篇文章我们讨论一下 JavaScript 内部是如何处理垃圾回收的。

在我们深入研究垃圾回收器的内部工作原理之前,首先来看看堆是如何组织的。V8将堆分为了几个不同的区域:

  • 新生区:大多数对象被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立。
  • 老生指针区:这里包含大多数可能存在指向其他对象的指针的对象。大多数在新生区存活一段时间之后的对象都会被挪到这里。
  • 老生数据区:这里存放只包含原始数据的对象(这些对象没有指向其他对象的指针)。字符串、封箱的数字以及未封箱的双精度数字数组,在新生区存活一段时间后会被移动到这里。
  • 大对象区:这里存放体积超越其他区大小的对象。每个对象有自己mmap产生的内存。垃圾回收器从不移动大对象。
  • 代码区:代码对象,也就是包含JIT之后指令的对象,会被分配到这里。这是唯一拥有执行权限的内存区(不过如果代码对象因过大而放在大对象区,则该大对象所对应的内存也是可执行的。译注:但是大对象内存区本身不是可执行的内存区)。
  • Cell区、属性Cell区、Map区:这些区域存放Cell、属性Cell和Map,每个区域因为都是存放相同大小的元素,因此内存结构很简单。

可达性

如果一个值可以通过某种方式访问到或可用,那么它就是可达的,并且一定存储在内存中。

譬如当前执行函数内使用到的变量和参数、全局变量和许多 JavaScript 的内置全局对象等等。

举几个例子:

let obj = {
  a: 1,
}

假设改代码在全局执行,我们知道 Object 是引用类型,因此 obj 存在对对象 {a: 1} 的引用。因此,该对象具有可达性,JavaScript 不会自动回收该对象。

obj = null

现在我们重新将 obj 设置为 null,先前的对象 {a: 1} 已经无法访问到,JavaScript 会认为它是垃圾数据并进行回收,释放内存。

其他引用

如果一个值被多次引用呢?我们来看看:

let obj1 = {
  a: 1,
}
let obj2 = obj1

有经验的小伙伴们应该明白:obj1obj2 都只是对对象 {a: 1} 进行引用,因此二者指向的是同一个值。这个时候单单对 obj1 重新赋值是不会触发 GC 的,需要:

obj1 = null
obj2 = null

现在看个复杂点的:

function sum() {
  let a = 0
  let b = 0
  return function(addVal) {
    return (a += addVal)
  }
}
const b = sum()
b(10)
b(20)

这里我们写了一个闭包函数,那么在函数 sum 中哪些变量会被回收呢?我们可以用 Chrome 进行断点调试查看:

我们可以看到,在返回的闭包(Closure)函数内只有引用了变量 a,而没有 b,这说明变量 b 已经被回收了,JavaScript 不会回收闭包内引用到的外部函数的变量,反之则会进行 GC

垃圾收集策略

好了,我们现在大体明白在什么情况下 JavaScript 会进行 GC,那么它采用的回收策略是什么呢?

JavaScript 使用两种著名的策略来执行 GC:引用计数技术标记清除算法

引用计数

最初级的 GC 算法,该算法把 “对象是否不再需要” 定义为对象有没有其他对象引用到它,如果该对象引用次数为 0,则进行 GC。

let obj1 = {
  a: 1,
} // 现在{a: 1}的引用次数为1
let obj2 = obj1 // 现在{a: 1}的引用次数为2
obj1 = null // 现在{a: 1}的引用次数为1
let a = obj2.a // 引用{a: 1}对象的a属性,现在{a: 1}的引用次数为2
obj2 = null // 现在{a: 1}的引用次数为1,还不能回收
a = null // 现在{a: 1}的引用次数为0,可以进行GC

引用计数方法看似没问题,其实有个明显的限制:无法处理循环引用的对象

考虑以下情况:

function func() {
  const obj1 = {}
  const obj2 = {}
  obj1.a = obj2
  obj2.a = obj1
}
func()

按照引用计数的思想,即使函数 func 执行完毕,这两个对象也不会被回收,因为这两个对象存在着至少一次相互引用,即便脱离了函数作用域,这两个对象还是存在于内存中。

IE6,7 中的 GC 采用的就是这种方式对 DOM 对象进行 GC,处理循环引用的时候很有可能会造成内存泄漏。

标记清除算法

标记清除算法将 “对象是否不再需要” 简化为 对象是否可以获得

该算法步骤如下:

  • 垃圾收集器找到所有的根,并标记他们。
  • 遍历并标记来自它们的所有引用。
  • 遍历标记的对象并标记它们的引用,所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
  • ……如此操作,直到所有可达的(从根部)引用都被访问到。
  • 没有被标记的对象都会被删除。

按照该算法的思想,如果一个值无法通过任何路径访问到,就进行 GC

如果配上图可以理解得更快:

例如,使我们的对象有如下的结构:

我们可以清楚地看到右侧有一个“无法到达的岛屿”。现在我们来看看“标记和清除”垃圾收集器如何处理它。

第一步标记所有的根

然后它们的引用被标记了:

……如果还有引用的话,继续标记:

现在,无法通过这个过程访问到的对象被认为是不可达的,并且会被删除。

相对于引用计数法,标记清除不会受困于循环引用,因此许多 JavaScript 引擎选择使用该算法进行 GC。

总结

通过上面的讨论,我们得知:

  • JavaScript 会自动进行垃圾回收。
  • 标记清除算法是现在主流的 GC 算法。
  • 开发者也需要多注意清理不需要的对象节省内存开销。

参考文章