JavaScript的垃圾回收机制

JavaScript 在定义变量时就完成了内存分配,我们创建基本类型、对象、函数……所有这些都需要内存,那么内存是在什么时候被回收的呢?了解JavaScript内存分配和垃圾回收是必不可少的技能,本文将整理这个问题。

<!--more-->

参考

1. 垃圾回收

由于字符串、对象和数组没有固定大小,所有当他们的大小已知时,才能对他们进行动态的存储分配。JavaScript程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。只要像这样动态地分配了内存,最终都要释放这些内存以便他们能够被再用,否则,JavaScript的解释器将会消耗完系统中所有可用的内存,造成系统崩溃 ——《JavaScript权威指南》

变量都会占据内存空间,未再被使用的内存应该及时回收,否则就会导致内存泄漏。

在C、C++等语言中,依赖程序员手动分配和释放内存。

char * buffer;
buffer = (char*) malloc(20); // malloc分配内存
free(buffer); // 释放内存

JavaScript嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。

垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做“一个对象引用另一个对象”。

垃圾回收器要解决的最基本问题就是:辨别需要回收的内存。目前有两种主流的解决方案:引用计数标记-清除

1.1. 引用计数算法

这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

下面是MDN上的一个示例,很好的阐述了引用计数算法的执行流程

var o = { 
  a: {
    b:2
  }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
var o2 = o; // o2变量是第二个对“这个对象”的引用
o = 1;      // 现在,“这个对象”的原始引用o被o2替换了
var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa
o2 = "yo"; // 最初的对象现在已经是零引用了
           // 他可以被垃圾回收了
           // 然而它的属性a的对象还在被oa引用,所以还不能回收
oa = null; // a属性的那个对象现在也是零引用了
           // 它可以被垃圾回收了

该算法有个限制:无法处理循环引用的事例。比如,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。

1.2. 标记-清除算法

这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。

这个算法假定设置一个叫做根(root)的对象(在Javascript里,根是全局对象Window)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。

一个实现标记-清除算法的垃圾回收器会定期执行以下“垃圾回收”步骤:

  • 垃圾回收器获取根并“标记”(记住)它们。
  • 然后它访问并“标记”所有来自它们的引用。
  • 然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
  • 以此类推,直到有未访问的引用(可以从根访问)为止。
  • 除标记的对象外,所有对象都被删除。
  • 如果想要了解哪些变量将会被标记,哪些变量将会被清除,则需要了解JavaScript作用域相关知识点。

这个算法比引用计数要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定,如“循环引用”。

从2012年起,所有现代浏览器都使用了标记-清除垃圾回收算法。所有对JavaScript垃圾回收算法的改进都是基于标记-清除算法的改进,并没有改进标记-清除算法本身和它对“对象是否不再需要”的简化定义。

这种方案存在的一个缺陷是:那些无法从根对象查询到的对象都将被清除。

2. 几种内存泄漏的场景

本质上,内存泄漏可以定义为:应用程序不再需要占用内存的时候,由于某些原因,内存没有被操作系统或可用内存池回收。内存泄漏可能带来诸如反应迟缓,崩溃,高延迟,以及其他应用问题。

根据上面提到的垃圾回收机制,我们大致可以认定:内存泄漏是由于某些被忽略的引用导致的。

2.1. 循环引用

如果使用引用计数,则在循环引用时就会导致内存泄漏,IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收

var div;
window.onload = function(){
  div = document.getElementById("myDivElement");
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join("*");
};

如果lotsOfData数据很大,则会浪费很多的内存空间。因此,对于循环依赖的对象,如果使用引用计数的方式来进行垃圾回收,则需要手动切断对象之间的循环依赖,然后再交给回收器进行处理

 div.circularReference = null

目前浏览器基本都采用标记-清除算法来进行垃圾收集,因此循环引用造成的内存泄漏场景应该比较少了。

2.2. 全局变量

如果变量挂载在全局变量上,则标记-清除算法是可以从根节点开始标记到该变量的,正因如此,全局变量无法被清除掉,在某些意外将变量设置为全局变量时,就可能导致内存泄漏,下面是几种常见的场景

function test(){
    a = 100 // 未定义的变量会在全局对象创建一个新变量
  this.b = 100 // 宽松模式下this指向全局对象
}
test() 

如果在某些必须要使用全局变量的场景下(如使用全局变量作为缓存),必须确保用完之后手动清除引用,及时释放内存。

window.myCache = {...一串很大的数据}
// 进行一些操作
window.myCache = null // 使用后释放引用

2.3. 计数器和事件处理函数

声明函数也是会占据内存的,但是在开发中很容易忽略对于函数的内存回收

var data = {}
// 未被停止的计数器将一直保存着回调函数的内存,由于闭包的存在,该函数保持了对于data的引用,因此也会导致data无法被回收
setInterVal(function(){
  var node = document.querySelector("#node")
  if(node) {
    node.innerText = JSON.stringify(data)
  }else {
    // 这里应该清除定时器回调
  }
})

// 同样,如果node不在存在,则该事件处理函数也应该被回收
// 目前对于大部分浏览器而言,如果一个DOM节点不再可访问,则浏览器可以主动回收注册的相关事件处理函数
node.addEventListener("click", function(){
  // ...
}, true)

计数器和事件处理函数导致的内存泄漏,大部分情况来自于函数内部对于闭包的引用。如果函数引用了较大的变量,则会导致占用的内存一直无法释放

var cache = []
module.exports = {
    add(obj){
    // 模块外调用add方法时,会导致内存增长且无法被回收
    // 这种设计在事件订阅-发布等场景下中是很常见的操作,因此需要在不需要时手动取消订阅,释放内存
        cache.push(obj) 
    }
}

2.4. DOM节点的引用

DOM树默认保存了对于DOM节点的引用,如parentNodesiblings等DOM API。在某些时候,我们在代码里面也会保存DOM节点,此时对于该节点而言,就存在两个引用:DOM树和逻辑代码作用域中。

如果在某个时刻需要清除该DOM节点,就需要从DOM树和代码作用域两个地方都删除才行。

var button = document.getElementById('btn');
var cache = {
  [button]: {
    // 一些很大的数据信息
  }
}
function remove() {
  document.body.removeChild(button);
  // 此时仍然可以通过button变量访问到该DOM节点,且chche对象无法被回收
  button.innerText = "I'm removed";
  // button引用的DOM节点此时在作用域中存在button和cache键名两个引用
  button = null
  // 此时该DOM节点此时在作用域中仍存在cache键名的引用
  console.log(cache)
}
button.onclick = remove

DOM节点之间的引用是十分复杂的,如td节点与table节点之间的引用,如果在作用域中还保留了td节点,则可能导致整个table节点也无法被回收掉,因此处理这种场景也需要十分小心。

由于保存DOM节点的引用是一种很常见的场景,ES6 推出了两种新的数据结构:WeakSetWeakMap。它们对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个"Weak",表示这是弱引用。

WeakMap为例,我们来了解一下该数据结构的使用

  • WeakMap只接受对象作为键名(null除外),不接受其他类型的值作为键名

  • WeakMap键名所指向的对象,不计入垃圾回收机制

  • WeakMap 的 键值 是不可枚举的,如果可枚举,就存在引用,就会进入垃圾回收机制了

因此,如果需要在DOM节点上保存一些额外信息,又希望该节点从DOM节点移除时就自动释放(不用从作用域手动释放),则可以使用WeakMap

var domCache = new WeakMap()
var button = document.getElementById('btn')
domCache.set(button, {
  // ... 一些很大的信息
})
function remove() {
  document.body.removeChild(button);
  // 此时button引用的DOM节点在作用域中仅存在button一个引用,当button引用消失后,DOM节点将会被回收,WeakMap的键值也会被回收
  button = null
  console.log(domCache.has(button))
}

button.onclick = remove

由于WeakMap无法遍历键名也无法清空,因此很难进行演示。如果引用所指向的值占用特别多的内存,可以通过Node的process.memoryUsage或者Chrome开发者工具的Memory检测内存占用。

3. 小结

本文整理了JavaScript的垃圾回收原理,然后了解在web开发中几种可能导致内存泄漏的场景,以及如何避免这些问题。

在大部分时候我们都不需要去关注JavaScript的内存管理机制,但是了解它们可以让我们可以更深入的理解编写的代码。