JS之内存泄漏

从本质上说,内存泄漏可以定义为:不再被应用程序所需要的内存,出于某种原因,它不会返回到操作系统或空闲内存池中。

四种常见的内存泄漏

1.全局变量

JavaScript以一种有趣的方式处理未声明的变量: 对于未声明的变量,会在全局范围中创建一个新的变量来对其进行引用。在浏览器中,全局对象是window。例如:

function foo(args) {
bar = "some text";
}

=>

function foo(args) {
window.bar = "some text";
}

如果 bar 在 foo 函数的作用域内对一个变量进行引用,却忘记使用 var 来声明它,那么将创建一个意想不到的全局变量。

创建一个意料之外的全局变量的另一种方法是使用this:

function foo() {
this.var1 = "potential accidental global";
}
// foo自己调用,它的this指向全局对象(window),而不是未定义。
foo();

可以在JavaScript文件的开头通过添加“use strict”来避免这一切,它将开启一个更严格的JavaScript解析模式,以防止意外创建全局变量。

尽管我们讨论的是未知的全局变量,但仍然有很多代码充斥着显式的全局变量。根据定义,这些是不可收集的(除非被指定为空或重新分配)。用于临时存储和处理大量信息的全局变量特别令人担忧。如果你必须使用一个全局变量来存储大量数据,那么请确保将其指定为null,或者在完成后将其重新赋值。


2.被遗忘的定时器和回调

setInterval为例,因为它在JavaScript中经常使用。

var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); //每五秒会执行一次

上面的代码片段演示了使用定时器时引用不再需要的节点或数据。

renderer 表示的对象可能会在未来的某个时间点被删除,从而导致内部处理程序中的一整块代码都变得不再需要。但是,由于定时器仍然是活动的,所以,处理程序不能被收集,并且其依赖项也无法被收集。这意味着,存储着大量数据的 serverData 也不能被收集

作为开发者时,需要确保在完成它们之后进行显式删除它们(或者对象将无法访问)。

在过去,一些浏览器无法处理这些情况(很好的IE6)。幸运的是,现在大多数现代浏览器会为帮你完成这项工作:一旦观察到的对象变得不可访问,即使忘记删除事件监听器,它们也会自动收集观察者处理程序。然而,我们还是应该在对象被处理之前显式地删除这些观察者。例如:

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
counter++;
element.innerHTML = 'text' + counter;
}

element.addEventListener('click', onClick);
// 逻辑任务
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

如今,现在的浏览器(包括IE和Edge)使用现代的垃圾回收算法,可以立即发现并处理这些循环引用。换句话说,在一个节点删除之前也不是必须要调用removeEventListener。


3.闭包

闭包是javascript开发的一个关键方面,一个内部函数使用了外部(封闭)函数的变量。由于JavaScript运行的细节(静态作用域,作用域链),它可能以下面的方式造成内存泄漏:

下面这个例子相当于是一个闭包的递归引用

var theThing = null;
var replacething= function() {
var originalThing = theThing;
var unused = function() {
if(originalThing) { // 引用了上一个theThing
console.log('hi');
}
};
theThing = {
longStr: new Array(100000).join('*'),
someMethod: function() {
console.log('message');
}
};
};
setInterval(replaceThing, 1000);

每次调用replaceThing的时候,theThing都会得到一个包含一个大数组和一个新闭包(someMethod)的新对象。

同时,变量unused指向一个引用了``originalThing`的闭包。

是不是有点困惑了? 重要的是,一旦具有相同父作用域的多个闭包的作用域被创建,则这个作用域就可以被共享。

在这种情况下,为闭包someMethod而创建的作用域可以被unused共享的。unused内部存在一个对originalThing的引用。即使unused从未使用过,someMethod也可以在replaceThing的作用域之外(例如在全局范围内)通过theThing来被调用。

由于someMethod共享了unused闭包的作用域,那么unused引用包含的originalThing会迫使它保持活动状态(两个闭包之间的整个共享作用域)。这阻止了它被收集。

当这段代码重复运行时,可以观察到内存使用在稳定增长,当GC运行后,内存使用也不会变小。从本质上说,在运行过程中创建了一个闭包链表(它的根是以变量theThing的形式存在),并且每个闭包的作用域都间接引用了了一个大数组,这造成了相当大的内存泄漏。


4.脱离DOM的引用

有时,将DOM节点存储在数据结构中可能会很有用。假设你希望快速地更新表中的几行内容,那么你可以在一个字典或数组中保存每个DOM行的引用。这样,同一个DOM元素就存在两个引用:一个在DOM树中,另一个则在字典中。如果在将来的某个时候你决定删除这些行,那么你需要将这两个引用都设置为不可访问。

var elements = {
button: document.getElementById('button'),
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
document.body.removeChild(document.getElementById('image'));
}

在引用 DOM 树中的内部节点或叶节点时,还需要考虑另外一个问题。如果在代码中保留对表单元格的引用(标记),并决定从 DOM 中删除表,同时保留对该特定单元格的引用,那么可能会出现内存泄漏。

你可能认为垃圾收集器将释放除该单元格之外的所有内容。然而,事实并非如此,由于单元格是表的一个子节点,而子节点保存对父节点的引用,所以对表单元格的这个引用将使整个表保持在内存中,所以在移除有被引用的节点时候要移除其子节点。


内存泄漏的识别方法

怎样可以观察到内存泄漏呢?

如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。这就要求实时查看内存占用。

浏览器

Chrome 浏览器查看内存占用,按照以下步骤操作

  1. 打开开发者工具,选择 Performance 面板
  2. 勾选 Memory
  3. 点击左上角的录制按钮。
  4. 在页面上进行各种操作,模拟用户的使用情况。
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况。

如果内存占用基本平稳,接近水平,就说明不存在内存泄漏。

img

反之,就是内存泄漏了。

img


命令行

命令行可以使用 Node 提供的process.memoryUsage方法。

node –expose-gc

console.log(process.memoryUsage());
// { rss: 27709440,
// heapTotal: 5685248,
// heapUsed: 3449392,
// external: 8772 }

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,单位是字节,含义如下。

img

  • rss(resident set size):所有内存占用,包括指令区和堆栈。
  • heapTotal:”堆”占用的内存,包括用到的和没用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎内部的 C++ 对象占用的内存。