JavaScript 中 4 种常见的内存泄露陷阱

了解 JavaScript 的内存泄露和解决方式!node

在这篇文章中咱们将要探索客户端 JavaScript 代码中常见的一些内存泄漏的状况,而且学习如何使用 Chrome 的开发工具来发现他们。读一读吧!程序员

介绍

内存泄露是每一个开发者最终都不得不面对的问题。即使使用自动内存管理的语言,你仍是会碰到一些内存泄漏的状况。内存泄露会致使一系列问题,好比:运行缓慢,崩溃,高延迟,甚至一些与其余应用相关的问题。算法

什么是内存泄漏

本质上来说,内存泄露是当一块内存再也不被应用程序使用的时候,因为某种缘由,这块内存没有返还给操做系统或者空闲内存池的现象。编程语言使用不一样的方式来管理内存。这些方式可能会减小内存泄露的机会。然而,某一块具体的内存是否被使用其实是一个不可断定问题(undecidable problem)。换句话说,只有开发者能够搞清楚一块内存是否应该被操做系统回收。某些编程语言提供了帮助开发者来处理这件事情的特性。而其它的编程语言须要开发者明确知道内存的使用状况。维基百科上有几篇写的不错的讲述手动 和自动内存管理的文章。编程

Javascript 的内存管理

Javascript 是那些被称做垃圾回收语言当中的一员。垃圾回收语言经过周期性地检查那些以前被分配出去的内存是否能够从应用的其余部分访问来帮助开发者管理内存。换句话说,垃圾回收语言将内存管理的问题从“什么样的内存是仍然被使用的?”简化成为“什么样的内存仍然能够从应用程序的其余部分访问?”。二者的区别是细微的,可是很重要:开发者只须要知道一块已分配的内存是否会在未来被使用,而不可访问的内存能够经过算法肯定并标记以便返还给操做系统。数组

非垃圾回收语言一般使用其余的技术来管理内存,包括:显式内存管理,程序员显式地告诉编译器在什么时候再也不须要某块内存;引用计数,一个计数器关联着每一个内存块(当计数器的计数变为0的时候,这块内存就被操做系统回收)。这些技术都有它们的折中考虑(也就是说都有潜在的内存泄漏风险)。浏览器

Javascript 中的内存泄露

引发垃圾收集语言内存泄露的主要缘由是没必要要的引用。想要理解什么是没必要要的引用,首先咱们须要理解垃圾收集器是怎样肯定一块内存可否被访问的。缓存

Mark-and-sweep

大多数的垃圾收集器(简称 GC)使用一个叫作 mark-and-sweep 的算法。这个算法由如下的几个步骤组成:数据结构

垃圾收集器创建了一个“根节点”列表。根节点一般是那些引用被保留在代码中的全局变量。对于 Javascript 而言,“Window” 对象就是一个能做为根节点的全局变量例子。window 对象是一直都存在的(即:不是垃圾)。全部根节点都是检查过的而且被标记为活动的(即:不是垃圾)。全部的子节点也都被递归地检查过。每块能够从根节点访问的内存都不会被视为垃圾。 全部没有被标记为垃圾的内存如今能够被当作垃圾,而垃圾收集器也能够释放这些内存并将它们返还给操做系统。现代垃圾收集器使用不一样的方式来改进这些算法,可是它们都有相同的本质:能够访问的内存块被标记为非垃圾而其他的就被视为垃圾。闭包

没必要要的引用就是那些程序员知道这块内存已经没用了,可是出于某种缘由这块内存依然存在于活跃的根节点发出的节点树中。在 Javascript 的环境中,没必要要的引用是某些再也不被使用的代码中的变量。这些变量指向了一块原本能够被释放的内存。一些人认为这是程序员的失误。app

因此想要理解什么是 Javascript 中最多见的内存泄露,咱们须要知道在什么状况下会出现没必要要的引用。

3 种常见的 Javascript 内存泄露

1: 意外的全局变量

Javascript 语言的设计目标之一是开发一种相似于 Java 可是对初学者十分友好的语言。体现 JavaScript 宽容性的一点表如今它处理未声明变量的方式上:一个未声明变量的引用会在全局对象中建立一个新的变量。在浏览器的环境下,全局对象就是 window,也就是说:

function foo(arg) {

    bar = "this is a hidden global variable";

}

 

其实是:

function foo(arg) {

    window.bar = "this is an explicit global variable";

}

 

若是 bar 是一个应该指向 foo 函数做用域内变量的引用,可是你忘记使用 var 来声明这个变量,这时一个全局变量就会被建立出来。在这个例子中,一个简单的字符串泄露并不会形成很大的危害,但这无疑是错误的。

另一种偶然建立全局变量的方式以下:

function foo() {

    this.variable = "potential accidental global";

}

// Foo called on its own, this points to the global object (window)

// rather than being undefined.

// 函数自身发生了调用,this 指向全局对象(window),(译者注:这时候会为全局对象 window 添加一个 variable 属性)而不是 undefined。

 

foo();

 

为了防止这种错误的发生,能够在你的 JavaScript 文件开头添加 'use strict'; 语句。这个语句实际上开启了解释 JavaScript 代码的严格模式,这种模式能够避免建立意外的全局变量。

全局变量的注意事项

尽管咱们在讨论那些隐蔽的全局变量,可是也有不少代码被明确的全局变量污染的状况。按照定义来说,这些都是不会被回收的变量(除非设置 null 或者被从新赋值)。特别须要注意的是那些被用来临时存储和处理一些大量的信息的全局变量。若是你必须使用全局变量来存储不少的数据,请确保在使用事后将它设置为 null 或者将它从新赋值。常见的和全局变量相关的引起内存消耗增加的缘由就是缓存。缓存存储着可复用的数据。为了让这种作法更高效,必须为缓存的容量规定一个上界。因为缓存不能被及时回收的缘故,缓存无限制地增加会致使很高的内存消耗。

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。

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)的对象。同时,没有用到的那个变量持有一个引用了 originalThingreplaceThing 调用以前的 theThing)闭包。哈,是否是已经有点晕了?关键的问题是每当在同一个父做用域下建立闭包做用域的时候,这个做用域是被共享的。在这种状况下,someMethod 的闭包做用域和 unused 的做用域是共享的。unused 持有一个 originalThing 的引用。尽管 unused 历来没有被使用过,someMethod 能够在 theThing 以外被访问。并且 someMethod 和 unused 共享了闭包做用域,即使 unused 历来都没有被使用过,它对 originalThing 的引用仍是强制它保持活跃状态(阻止它被回收)。当这段代码重复运行时,将能够观察到内存消耗稳定地上涨,而且不会由于 GC 的存在而降低。本质上来说,建立了一个闭包链表(根节点是 theThing 形式的变量),并且每一个闭包做用域都持有一个对大数组的间接引用,这致使了一个巨大的内存泄露。

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

垃圾收集器的直观行为

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

  1. 大量内存分配发生时。

  2. 大部分(或者所有)的元素都被标记为不可达(假设咱们讲一个指向无用缓存的引用置 null 的时候)。

  3. 没有进一步的内存分配发生。

这个情形下,GC 将不会运行任何进一步的回收过程。也就是说,尽管有不可达的引用能够触发回收,可是收集器并不要求回收它们。严格的说这些不是内存泄露,但仍然致使高于正常状况的内存空间使用。

Google 在它们的 JavaScript 内存分析文档中提供一个关于这个行为的优秀例子,见示例#2.

Chrome 内存分析工具简介

Chrome 提供了一套很好的工具用来分析 JavaScript 的内存适用。这里有两个与内存相关的重要视图:timeline 视图和 profiles 视图。

Timeline view

timeline 视图是咱们用于发现不正常内存模式的必要工具。当咱们寻找严重的内存泄漏时,内存回收发生后产生的周期性的不会消减的内存跳跃式增加会被一面红旗标记。在这个截图里面咱们能够看到,这很像是一个稳定的对象内存泄露。即使最后经历了一个很大的内存回收,它占用的内存依旧比开始时多得多。节点数也比开始要高。这些都是代码中某处 DOM 节点内存泄露的标志。

Profiles 视图

你将会花费大部分的时间在观察这个视图上。profiles 视图让你能够对 JavaScript 代码运行时的内存进行快照,而且能够比较这些内存快照。它还让你能够记录一段时间内的内存分配状况。在每个结果视图中均可以展现不一样类型的列表,可是对咱们的任务最有用的是 summary 列表和 comparison 列表。

summary 视图提供了不一样类型的分配对象以及它们的合计大小:shallow size (一个特定类型的全部对象的总和)和 retained size (shallow size 加上保留此对象的其它对象的大小)。distance 显示了对象到达 GC 根(校者注:最初引用的那块内存,具体内容可自行搜索该术语)的最短距离。

comparison 视图提供了一样的信息可是容许对比不一样的快照。这对于找到泄露颇有帮助。

举例: 使用 Chrome 来发现内存泄露

有两个重要类型的内存泄露:引发内存周期性增加的泄露和只发生一次且不引发更进一步内存增加的泄露。显而易见的是,寻找周期性的内存泄漏是更简单的。这些也是最麻烦的事情:若是内存会按时增加,泄露最终将致使浏览器变慢或者中止执行脚本。很明显的非周期性大量内存泄露能够很容易的在其余内存分配中被发现。可是实际状况并不如此,每每这些泄露都是不足以引发注意的。这种状况下,小的非周期性内存泄露能够被当作一个优化点。然而那些周期性的内存泄露应该被视为 bug 而且必须被修复。

为了举例,咱们将会使用 Chrome 的文档中提供的一个例子。完整的代码在下面能够找到:

var x = [];

 

function createSomeNodes() {

    var div,

        i = 100,

        frag = document.createDocumentFragment();

    for (;i &gt; 0; i--) {

        div = document.createElement("div");

        div.appendChild(document.createTextNode(i + " - "+ new Date().toTimeString()));

        frag.appendChild(div);

    }

    document.getElementById("nodes").appendChild(frag);

}

function grow() {

    x.push(new Array(1000000).join('x'));

    createSomeNodes();

    setTimeout(grow,1000);

}

 

当调用 grow 的时候,它会开始建立 div 节点而且把他们追加到 DOM 上。它将会分配一个大数组并将它追加到一个全局数组中。这将会致使内存的稳定增加,使用上面提到的工具能够观察到这一点。

垃圾收集语言一般表现出内存用量的抖动。若是代码在一个发生分配的循环中运行时,这是很常见的。咱们将要寻找那些在内存分配以后周期性且不会回落的内存增加。

查看内存是否周期性增加

对于这个问题,timeline 视图最合适不过了。在 Chrome 中运行这个例子,打开开发者工具,定位到 timeline,选择内存而且点击记录按钮。而后去到那个页面点击按钮开始内存泄露。一段时间后中止记录,而后观察结果:

这个例子中每秒都会发生一次内存泄露。记录中止后,在 grow 函数中设置一个断点来防止 Chrome 强制关闭这个页面。

在图中有两个明显的标志代表咱们正在泄漏内存。节点的图表(绿色的线)和 JS 堆内存(蓝色的线)。节点数稳定地增加而且从不减小。这是一个明显的警告标志。

JS 堆内存表现出稳定的内存用量增加。因为垃圾回收器的做用,这很难被发现。你能看到一个初始内存的增加的图线,紧接着有一个很大的回落,接着又有一段增加而后出现了一个峰值,接着又是一个回落。这个状况的关键是在于一个事实,即每次内存用量回落时候,堆内存老是比上一次回落后的内存占用量更多。也就是说,尽管垃圾收集器成功地回收了不少的内存,仍是有一部份内存周期性的泄露了。

咱们如今肯定程序中有一个泄露,让咱们一块儿找到它。

 

拍两张快照

为了找到这个内存泄漏,咱们将使用 Chrome 开发者工具红的 profiles 选项卡。为了保证内存的使用在一个可控制的范围内,在作这一步以前刷新一下页面。咱们将使用 Take Heap Snapshot 功能。

刷新页面,在页面加载结束后为堆内存捕获一个快照。咱们将要使用这个快照做为咱们的基准。而后再次点击按钮,等几秒,而后再拍一个快照。拍完照后,推荐的作法是在脚本中设置一个断点来中止它的运行,防止更多的内存泄露。

有两个方法来查看两个快照之间的内存分配状况,其中一种方法须要选择 Summary 而后在右面选取在快照1和快照2之间分配的对象,另外一种方法,选择 Comparison 而不是 Summary。两种方法下,咱们都将会看到一个列表,列表中展现了在两个快照之间分配的对象。

 

本例中,咱们很容易就能够找到内存泄露:它们很明显。看一下(string)构造函数的 Size Delta。58个对象占用了8 MB 内存。这看起来很可疑:新的对象被建立,可是没有被释放致使了8 MB 的内存消耗。

若是咱们打开(string)构造函数分配列表,咱们会注意到在不少小内存分配中掺杂着的几个大量的内存分配。这些状况当即引发了咱们的注意。若是咱们选择它们当中的任意一个,咱们将会在下面的 retainer 选项卡中获得一些有趣的结果。

 

咱们发现咱们选中的内存分配信息是一个数组的一部分。相应地,数组被变量 x 在全局 window 对象内部引用。这给咱们指引了一条从咱们的大对象到不会被回收的根节点(window)的完整的路径。咱们也就找到了潜在的泄漏点以及它在哪里被引用。

到如今为止,一切都很不错。可是咱们的例子太简单了:像例子中这样大的内存分配并非很常见。幸运的是咱们的例子中还存在着细小的 DOM 节点内存泄漏。使用上面的内存快照能够很容易地找到这些节点,可是在更大的站点中,事情变得复杂起来。最近,新的 Chrome 的版本中提供了一个附加的工具,这个工具十分适合咱们的工做,这就是堆内存分配记录(Record Heap Allocations)功能

经过记录堆内存分配来发现内存泄露

取消掉你以前设置的断点让脚本继续运行,而后回到开发者工具的 Profiles 选项卡。如今点击 Record Heap Allocations。当工具运行时候你将注意到图表顶部的蓝色细线。这些表明着内存分配。咱们的代码致使每秒钟都有一个大的内存分配发生。让它运行几秒而后让程序中止(不要忘记在此设置断点来防止 Chrome 吃掉过多的内存)。

在这张图中你能看到这个工具的杀手锏:选择时间线中的一片来观察在这段时间片中内存分配发生在什么地方。咱们将时间片设置的尽可能与蓝色线接近。只有三个构造函数在这个列表中显示出来:一个是与咱们的大泄露有关的(string),一个是和 DOM 节点的内存分配相关的,另外一个是 Text 构造函数(DOM 节点中的文本构造函数)。

从列表中选择一个 HTMLDivElement 构造函数而后选择一个内存分配堆栈。

啊哈!咱们如今知道那些元素在什么地方被分配了(grow -> createSomeNodes)。若是咱们集中精神观察图像中的每一个蓝色线,还会注意到 HTMLDivElement 的构造函数被调用了不少次。若是咱们回到快照 comparison 视图就不难发现这个构造函数分配了不少次内存可是没有从未释放它们。也就是说,它不断地分配内存空间,但却没有容许 GC 回收它们。种种迹象代表这是一个泄露,加上咱们确切地知道这些对象被分配到了什么地方(createSomeNodes 函数)。如今应该去研究代码,并修复这个泄漏。

其余有用的特性

在堆内存分配结果视图中咱们可使用比 Summary 更好的 Allocation 视图。

这个视图为咱们呈现了一个函数的列表,同时也显示了与它们相关的内存分配状况。咱们能当即看到 grow 和 createSomeNodes 凸显了出来。当选择 grow 咱们看到了与它相关的对象构造函数被调用的状况。咱们注意到了(string),HTMLDivElement 和 Text 而如今咱们已经知道是对象的构造函数被泄露了。

这些工具的组合对找到泄漏有很大帮助。和它们一块儿工做。为你的生产环境站点作不一样的分析(最好用没有最小化或混淆的代码)。看看你能不能找到那些比正常状况消耗更多内存的对象吧(提示:这些很难被找到)。

若是要使用 Allocation 视图,须要进入 Dev Tools -> Settings,选中“record heap allocation stack traces”。获取记录以前必需要这么作。

延伸阅读

  • Memory Management – Mozilla Developer Network

  • JScript Memory Leaks – Douglas Crockford (old, in relation to Internet Explorer 6 leaks)

  • JavaScript Memory Profiling – Chrome Developer Docs

  • Memory Diagnosis – Google Developers

  • An Interesting Kind of JavaScript Memory Leak – Meteor blog

  • Grokking V8 closures

结论

在垃圾回收语言中,如 JavaScript,确实会发生内存泄露。一些状况下咱们都不会意识到这些泄露,最终它们将会带来毁灭性的灾难。正是因为这个缘由,使用内存分析工具来发现内存泄露是十分重要的。运行分析工具应该成为开发周期中的一部分,特别是对于中型或大型应用来说。如今就开始这么作,尽量地为你的用户提供最好的体验。动手吧!

相关文章
相关标签/搜索