在本文中,我们将探讨客户端JavaScript代码中常见的内存泄漏类型。我们还将学习如何使用Chrome开发工具找到它们。继续阅读!
介绍
内存泄漏是每个开发人员最终都必须面对的问题。即使使用内存管理的语言,在某些情况下也会泄漏内存。泄漏是整个问题的根源:减速,崩溃,高延迟,甚至其他应用程序也有问题。
什么是内存泄漏?
本质上,内存泄漏可以定义为由于某种原因未返回到操作系统或空闲内存池的应用程序不再需要的内存。编程语言支持不同的内存管理方式。这些方法可以减少内存泄漏的机会。但是,某个内存是否未使用实际上是无法确定的问题。换句话说,只有开发人员才能弄清楚是否可以将一块内存返回给操作系统。某些编程语言提供的功能可帮助开发人员执行此操作。其他人则希望开发人员完全清楚何时未使用内存。维基百科上有很多有关手动和自动内存管理的文章。
JavaScript中的内存管理
JavaScript是所谓的垃圾收集语言之一。垃圾收集的语言通过定期检查仍可以从应用程序的其他部分“访问”哪些先前分配的内存,来帮助开发人员管理内存。换句话说,垃圾收集语言减少了“仍然需要什么内存”管理内存的问题。“仍然可以从应用程序的其他部分访问什么内存?”。差异是细微的,但很重要:虽然只有开发人员才能知道将来是否需要分配的内存,但是可以通过算法确定无法访问的内存并将其标记为返回操作系统。
非垃圾收集语言通常采用其他技术来管理内存:显式管理,其中开发人员在不需要内存的情况下显式告知编译器;和引用计数,其中使用计数与每个内存块相关联(当计数达到零时,它将返回到OS)。这些技术都有其自身的权衡(以及潜在的泄漏原因)。
JavaScript泄漏
垃圾收集语言泄漏的主要原因是不需要的引用。要了解什么是不需要的引用,首先我们需要了解垃圾回收器如何确定是否可以访问内存。
标记并清扫
大多数垃圾收集器使用称为标记清除的算法。该算法包括以下步骤:
- 垃圾收集器构建“根”列表。根通常是在代码中保留引用的全局变量。在JavaScript中,“窗口”对象是可以充当根的全局变量的示例。窗口对象始终存在,因此垃圾收集器可以认为它及其所有子对象始终存在(即,不是垃圾)。
- 检查所有根并将其标记为活动(即不是垃圾)。还对所有儿童进行递归检查。从根可以访问的所有内容均不视为垃圾。
- 现在可以将所有未标记为活动的内存视为垃圾。收集器现在可以释放该内存并将其返回给OS。
现代垃圾收集器以不同的方式改进了该算法,但是本质是相同的:可访问的内存被标记为此类,其余的被视为垃圾。
不需要的引用是对开发人员知道他或她不再需要的内存片段的引用,但是由于某种原因它们被保留在活动根目录树中。在JavaScript的上下文中,不需要的引用是保留在代码中某个位置的变量,这些变量将不再使用,并指向原本可以释放的内存。有人会说这些是开发人员的错误。
因此,要了解JavaScript中最常见的泄漏,我们需要知道通常忘记引用的方式。
常见的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它来声明它,则会创建意外的全局变量。在这个例子中,泄漏一个简单的字符串不会造成太大的伤害,但是肯定会更糟。
可以创建意外全局变量的另一种方法是this:
function foo() {
this.variable = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();
为防止发生这些错误,请
use strict
;在JavaScript文件的开头添加。这将启用更严格的JavaScript解析模式,以防止意外的全局变量。
关于全局变量的注释
即使我们谈论了不受怀疑的全局变量,但仍然有很多代码被显式全局变量所困扰。根据定义,这些是不可收集的(除非为null或重新分配)。特别是,用于临时存储和处理大量信息的全局变量值得关注。如果必须使用全局变量来存储大量数据,请确保在处理完数据后将其清空或重新分配。与全局变量相关的内存消耗增加的一个常见原因是[cache(](https://en.wikipedia.org/wiki/Cache_(computing))。缓存存储重复使用的数据。为使此有效,高速缓存必须为其大小设置上限。无限增长的高速缓存会导致高内存消耗,因为无法收集其内容。
2:忘记了计时器或回调
setInterval
在JavaScript中,的使用非常普遍。其他库提供观察者和接受回调的其他工具。这些库中的大多数都在使自己的实例也无法访问之后,使对回调的任何引用都无法访问。但是,在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);
此示例说明了悬挂计时器可能发生的情况:悬挂计时器引用不再需要的节点或数据。node将来可能会删除由表示的对象,从而使间隔处理程序内的整个块不再需要。但是,由于间隔仍处于活动状态,因此无法收集处理程序(需要停止间隔才能发生)。如果无法收集间隔处理程序,则也不能收集其依赖项。这意味着someResource大概存储了大量数据的,也无法收集。
对于观察者而言,一旦不再需要它们(或使关联的对象将变得不可访问)时,进行显式调用以将其删除很重要。在过去,这尤其重要,因为某些浏览器(Internet Explorer 6)无法很好地管理循环引用(有关此信息,请参见下文)。如今,即使未显式删除侦听器,一旦观察到的对象变得不可访问,大多数浏览器都可以并且将收集观察器处理程序。但是,仍然好的做法是在处理对象之前明确删除这些观察者。例如:
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开发人员的祸根。这是由于Internet Explorer的垃圾收集器中的错误(或设计决策)造成的。Internet Explorer的旧版本无法检测DOM节点和JavaScript代码之间的循环引用。这是观察者的典型情况,通常会引用可观察对象(如上例所示)。换句话说,每次将观察者添加到Internet Explorer中的节点时,都会导致泄漏。这就是开发人员开始在节点之前或在观察者内部使引用为空之前显式删除处理程序的原因。如今,现代的浏览器(包括Internet Explorer和Microsoft Edge)使用现代的垃圾回收算法,可以检测这些周期并正确处理它们。换句话说,不必严格要求removeEventListener 在使节点不可访问之前。
诸如jQuery之类的框架和库在处置节点之前(当为此使用它们的特定API时)确实删除了侦听器。这是由库内部处理的,并确保即使在有问题的浏览器(例如旧的Internet Explorer)下运行时也不会泄漏
3: 超出DOM引用
有时将DOM节点存储在数据结构中可能很有用。假设您要快速更新表中几行的内容。将对每个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树内部的内部或叶节点的引用有关。假设您<td>在JavaScript代码中保留了对表(标签)的特定单元格的引用。在将来的某个时候,您决定从DOM中删除表,但保留对该单元的引用。凭直觉,人们可能会认为GC将收集除那个细胞以外的所有东西。实际上,这不会发生:单元格是该表的子节点,子代保留对其父代的引用。换句话说,JavaScript代码对表格单元的引用导致整个表格保留在内存中。保留对DOM元素的引用时,请仔细考虑这一点。
4:闭包
JavaScript开发的一个关键方面是闭包:闭包:从父作用域捕获变量的匿名函数。流星开发人员发现了一种特殊情况,由于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)的新对象。同时,该变量unused包含一个闭包,该闭包引用了originalThing(theThing从上一次调用到replaceThing)。已经有些混乱了,是吧?重要的是,一旦为同一父作用域中的闭包创建了作用域,就将共享该作用域。在这种情况下,为闭包创建的作用域由someMethod共享unused。unused有参考originalThing。即使unused从未使用过,someMethod也可以通过使用theThing。并且由于与someMethod共享闭包范围unused,即使unused从未使用过,其对的引用originalThing强制其保持活动状态(阻止其收集)。当此代码段重复运行时,可以观察
垃圾收集器的不直观行为
尽管垃圾收集器很方便,但是它们还是有自己的权衡。这些折衷之一是不确定性。换句话说,GC是不可预测的。通常无法确定何时进行收集。这意味着在某些情况下,正在使用的内存超过了程序实际需要的内存。在其他情况下,短暂停可能在特别敏感的应用中很明显。尽管不确定性意味着无法确定何时执行收集,但是大多数GC实现在分配过程中共享执行收集遍历的通用模式。如果不执行分配,则大多数GC保持静止。请考虑以下情形:
- 执行大量分配。
- 这些元素中的大多数(或所有元素)都标记为不可访问(假设我们将指向不再需要的缓存的引用无效)。
- 不再执行任何分配。
在这种情况下,大多数GC将不再运行任何进一步的收集过程。换句话说,即使有不可达的引用可用于收集,收集器也不会主张这些引用。这些并不是严格的泄漏,但仍会导致内存使用率高于正常水平。
Chrome内存分析工具概述
Chrome提供了一组不错的工具来分析JavaScript代码的内存使用情况。有两个与内存相关的基本视图:时间轴视图和配置文件视图。
时间线视图
时间线视图对于发现代码中的异常内存模式至关重要。万一我们正在寻找大的泄漏,周期性跳跃的收缩幅度不如收集后增长的幅度大。在此屏幕截图中,我们可以看到泄漏对象稳定增长的样子。即使在结束大集合之后,使用的内存总量也比开始时高。节点数也更高。这些都是代码中DOM节点泄漏的迹象。
个人资料视图这是您将大部分时间用于查看的视图。使用配置文件视图,您可以获取快照并比较JavaScript代码的内存使用情况快照。它还允许您记录时间的分配。在每个结果视图中,都可以使用不同类型的列表,但是与我们的任务最相关的是摘要列表和比较列表。
摘要视图为我们概述了分配的不同对象类型及其聚集大小:浅大小(特定类型的所有对象的总和)和保留大小(浅大小加上由于该对象而保留的其他对象的大小) )。它还为我们提供了一个对象相对于其GC根(距离)的距离的概念。
比较列表为我们提供了相同的信息,但允许我们比较不同的快照。这对于发现泄漏特别有用。
参考
4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them
网友评论