内存泄漏

当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者空闲内存池的现象。

所有的全局对象window的子节点都被递归地检查过,每块可以从根节点访问的内存都不会被视为垃圾。

在 Javascript 的环境中,不必要的引用是某些不再被使用的代码中的变量。这些变量指向了一块本来可以被释放的内存。一些人认为这是程序员的失误。

1 意外的全局变量

1.1 在局部作用域产生的没有必要的全局变量。 解决办法:使用“use strict”。 1.2 显式全局变量存放大量不再需要的数据。

2 遗漏的定时器和回调函数(闭包的一种)

在 JavaScript 中 setInterval 的使用十分常见。其他的库也经常会提供观察者和其他需要回调的功能。这些库中的绝大部分都会关注一点,就是当它们本身的实例被销毁之前销毁所有指向回调的引用。在 setInterval 这种情况下,一般情况下的代码是这样的:

var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // Do stuff with node and someResource. node.innerHTML = JSON.stringify(someResource)); } }, 1000);

这个例子说明了摇晃的定时器会发生什么:引用节点或者数据的定时器已经没用了。那些表示节点的对象在将来可能会被移除掉,所以将整个代码块放在周期处理函数中并不是必要的。然而,由于周期函数一直在运行,处理函数并不会被回收(只有周期函数停止运行之后才开始回收内存)。如果周期处理函数不能被回收,它的依赖程序也同样无法被回收。这意味着一些资源,也许是一些相当大的数据都也无法被回收。

下面举一个观察者的例子,当它们不再被需要的时候(或者关联对象将要失效的时候)显式地将他们移除是十分重要的。在以前,尤其是对于某些浏览器(IE6)是一个至关重要的步骤,因为它们不能很好地管理循环引用(下面的代码描述了更多的细节)。现在,当观察者对象失效的时候便会被回收,即便 listener 没有被明确地移除,绝大多数的浏览器可以或者将会支持这个特性。尽管如此,在对象被销毁之前移除观察者依然是一个好的实践。示例如下:

var element = document.getElementById('button');

function onClick(event) { element.innerHtml = 'text'; }

element.addEventListener('click', onClick); // Do stuff element.removeEventListener('click', onClick); element.parentNode.removeChild(element); // Now when element goes out of scope, // both element and onClick will be collected even in old browsers that don't // handle cycles well.

对象观察者和循环引用中一些需要注意的点

观察者和循环引用常常会让 JavaScript 开发者踩坑。以前在 IE 浏览器的垃圾回收器上会导致一个 bug(或者说是浏览器设计上的问题)。旧版本的 IE 浏览器不会发现 DOM 节点和 JavaScript 代码之间的循环引用。这是一种观察者的典型情况,观察者通常保留着一个被观察者的引用(正如上述例子中描述的那样)。换句话说,在 IE 浏览器中,每当一个观察者被添加到一个节点上时,就会发生一次内存泄漏。这也就是开发者在节点或者空的引用被添加到观察者中之前显式移除处理方法的原因。目前,现代的浏览器(包括 IE 和 Microsoft Edge)都使用了可以发现这些循环引用并正确的处理它们的现代化垃圾回收算法。换言之,严格地讲,在废弃一个节点之前调用 removeEventListener 不再是必要的操作。

像是 jQuery 这样的框架和库(当使用一些特定的 API 时候)都在废弃一个结点之前移除了 listener 。它们在内部就已经处理了这些事情,并且保证不会产生内存泄露,即便程序运行在那些问题很多的浏览器中,比如老版本的 IE。

现代浏览器在废弃一个节点之前调用 removeEventListener 不再是必要的操作。

3 DOM 之外的引用

有些情况下将 DOM 结点存储到数据结构中会十分有用。假设你想要快速地更新一个表格中的几行,如果你把每一行的引用都存储在一个字典或者数组里面会起到很大作用。如果你这么做了,程序中将会保留同一个结点的两个引用:一个引用存在于 DOM 树中,另一个被保留在字典中。如果在未来的某个时刻你决定要将这些行移除,则需要将所有的引用清除。

var elements = { button: document.getElementById('button'), image: document.getElementById('image'), text: document.getElementById('text') };

function doStuff() { image.src = 'http://some.url/image'; button.click(); console.log(text.innerHTML); // Much more logic }

function removeButton() { // The button is a direct child of body. document.body.removeChild(document.getElementById('button'));

// At this point, we still have a reference to #button in the global
// elements dictionary. In other words, the button element is still in
// memory and cannot be collected by the GC.

}

还需要考虑另一种情况,就是对 DOM 树子节点的引用。假设你在 JavaScript 代码中保留了一个表格中特定单元格(一个 <td> 标签)的引用。在将来你决定将这个表格从 DOM 中移除,但是仍旧保留这个单元格的引用。凭直觉,你可能会认为 GC 会回收除了这个单元格之外所有的东西,但是实际上这并不会发生:单元格是表格的一个子节点且所有子节点都保留着它们父节点的引用。换句话说,JavaScript 代码中对单元格的引用导致整个表格被保留在内存中。所以当你想要保留 DOM 元素的引用时,要仔细的考虑清除这一点。

4 闭包

JavaScript 开发中一个重要的内容就是闭包,它是可以获取父级作用域的匿名函数。Meteor 的开发者发现在一种特殊情况下有可能会以一种很微妙的方式产生内存泄漏,这取决于 JavaScript 运行时的实现细节。

var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log(someMessage); } }; }; setInterval(replaceThing, 1000);

这段代码做了一件事:每次调用 replaceThing 时,theThing 都会得到新的包含一个大数组和新的闭包(someMethod)的对象。同时,没有用到的那个变量持有一个引用了 originalThing(replaceThing 调用之前的 theThing)闭包。哈,是不是已经有点晕了?关键的问题是每当在同一个父作用域下创建闭包作用域的时候,这个作用域是被共享的。在这种情况下,someMethod 的闭包作用域和 unused 的作用域是共享的。unused 持有一个 originalThing 的引用。尽管 unused 从来没有被使用过,someMethod 可以在 theThing 之外被访问。而且 someMethod 和 unused 共享了闭包作用域,即便 unused 从来都没有被使用过,它对 originalThing 的引用还是强制它保持活跃状态(阻止它被回收)。当这段代码重复运行时,将可以观察到内存消耗稳定地上涨,并且不会因为 GC 的存在而下降。本质上来讲,创建了一个闭包链表(根节点是 theThing 形式的变量),而且每个闭包作用域都持有一个对大数组的间接引用,这导致了一个巨大的内存泄露。

这是一种人为的实现方式。可以想到一个能够解决这个问题的不同的闭包实现,就像 Metero 的博客里面说的那样。 垃圾收集器的直观行为

尽管垃圾收集器是便利的,但是使用它们也需要有一些利弊权衡。其中之一就是不确定性。也就是说,GC 的行为是不可预测的。通常情况下都不能确定什么时候会发生垃圾回收。这意味着在一些情形下,程序会使用比实际需要更多的内存。有些的情况下,在很敏感的应用中可以观察到明显的卡顿。尽管不确定性意味着你无法确定什么时候垃圾回收会发生,不过绝大多数的 GC 实现都会在内存分配时遵从通用的垃圾回收过程模式。如果没有内存分配发生,大部分的 GC 都会保持静默。考虑以下的情形:

大量内存分配发生时。 大部分(或者全部)的元素都被标记为不可达(假设我们讲一个指向无用缓存的引用置 null 的时候)。 没有进一步的内存分配发生。 这个情形下,GC 将不会运行任何进一步的回收过程。也就是说,尽管有不可达的引用可以触发回收,但是收集器并不要求回收它们。严格的说这些不是内存泄露,但仍然导致高于正常情况的内存空间使用。