在处理JavaScript之类的脚本语言时,很容易忘记,每个对象,类,字符串,数字和方法都需要分配和保留内存。 语言和运行时的垃圾回收器隐藏了该分配的细节及其释放。
您无需考虑内存管理就可以取得很多成就,但是忽略它会导致程序中的重大问题。 清理不当的物体会比预期的停留时间更长。 这些对象继续响应事件并消耗资源。 它们可以迫使浏览器从虚拟磁盘驱动器分页内存,并显着降低计算机的速度(在极端情况下,会使浏览器崩溃)。
内存泄漏是在您不再使用或需要它之后仍然存在的任何对象。 近年来,许多浏览器在页面加载之间从JavaScript回收内存方面做得更好。 不过,并非所有浏览器的行为方式都相同。 Firefox和较旧版本的Internet Explorer都有内存泄漏的历史记录,该历史记录将一直持续到关闭浏览器为止。
历史上导致内存泄漏的许多经典模式在现代浏览器中不再泄漏。 但是,今天有影响内存泄漏的另一种趋势。 许多人正在设计旨在在没有硬页面刷新的情况下在单个页面的上下文中运行的Web应用程序。 在这种情况下,很容易在不再需要或不相关时将内存从应用程序的一种状态保留到另一种状态。
在本文中,将了解对象的基本生命周期,垃圾回收如何确定对象是否可以释放以及如何评估潜在的泄漏行为。 另外,了解如何使用Google Chrome浏览器中的堆分析器诊断内存问题。 示例显示了如何使用闭包,控制台日志和周期来解决内存泄漏。
您可以下载本文中使用的示例的源代码。
对象生命周期
要了解防止内存泄漏的知识,了解对象的基本生命周期非常重要。 创建对象后,JavaScript会自动为该对象分配适当数量的内存。 从那时起,垃圾回收器会不断评估对象,以查看其是否仍然是有效对象。
垃圾收集器以固定的时间间隔扫过对象图,并计算对每个对象都有引用的其他对象的数量。 如果一个对象的计数为零(没有其他对象对其进行引用),或者对该对象的唯一引用是循环的,则可以回收该对象的内存。 图1显示了垃圾回收器如何回收内存的示例。
图1.通过垃圾回收回收内存
实际查看系统的运行状况会很有帮助,但是这样做的工具有限。 了解您JavaScript应用程序消耗了多少内存的一种方法是使用系统工具观察浏览器的内存分配。 有几种工具可以为您提供当前的使用级别,并可以绘制出一段时间内进程的内存使用情况。
例如,如果您在Mac OSX上安装了XCode,则可以启动Instruments应用程序并将其活动监视器工具附加到浏览器以进行实时分析。 在Windows®上,您可以使用任务管理器。 如果在应用程序中移动时发现随着时间的推移,内存使用情况的图表一直在逐步增加,那么您就知道内存泄漏。
观察浏览器的内存占用情况可以非常直观地了解JavaScript应用程序的实际内存使用情况。 浏览器数据不会告诉您正在泄漏哪些对象,也不能保证数据确实与您的应用程序的实际占用空间相匹配。 并且,由于某些浏览器中的实现问题,当页面中的相应元素被破坏时,可能不会释放DOM元素(或支持应用程序级对象)。 对于视频标签来说尤其如此,因为视频标签需要更加复杂的基础架构才能实现浏览器。
已经进行了几次尝试从客户端JavaScript库中添加对内存分配的跟踪。 不幸的是,这些尝试都没有特别可靠。 例如,流行的stats.js软件包由于不准确而放弃了支持。 通常,尝试从客户端维护或确定此信息是有问题的,因为它在应用程序中造成了开销,并且无法可靠地确定。
对于浏览器供应商而言,理想的解决方案是在浏览器中提供一组工具,以帮助您监视内存使用情况,识别泄漏的对象以及确定为什么仍将特定对象标记为保留的原因。
当前,唯一将内存管理工具作为其开发人员工具的一部分实施的浏览器是Google Chrome,它提供了Heap Profiler。 我在本文中使用堆分析器来测试和说明JavaScript运行时如何处理内存。
分析堆快照
在创建内存泄漏之前,请看一下正确收集内存的简单交互。 首先用两个按钮创建一个简单HTML页面,如清单1所示。
清单1. index.html
<html>
<head>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"
type="text/javascript"></script>
</head>
<body>
<button id="start_button">Start</button>
<button id="destroy_button">Destroy</button>
<script src="assets/scripts/leaker.js" type="text/javascript"
charset="utf-8"></script>
<script src="assets/scripts/main.js" type="text/javascript"
charset="utf-8"></script>
</body>
</html>
包含jQuery是为了确保用于管理事件绑定的简单语法在所有浏览器上都能很好地工作,并且与最常见的开发实践非常相似。 为leaker
类和主要JavaScript方法添加了脚本标签。 在生产中,通常最好将JavaScript文件合并为一个文件。 就本示例而言,将逻辑保存在单独的文件中更加容易。
您可以过滤堆分析器以仅显示特定类的实例。 要利用该功能,请创建一个新类,该类封装泄漏对象的行为,并且可以在探查器中轻松找到它,如清单2所示。
清单2. asset / scripts / leaker.js
var Leaker = function(){};
Leaker.prototype = {
init:function(){
}
};
绑定开始按钮以初始化Leaker
对象,并将其分配给全局名称空间中的变量。 您还将要把destroy按钮绑定到一个方法,该方法应该清理Leaker
对象并使其准备好进行垃圾回收,如清单3所示。
清单3. asset / scripts / main.js
$("#start_button").click(function(){
if(leak !== null || leak !== undefined){
return;
}
leak = new Leaker();
leak.init();
});
$("#destroy_button").click(function(){
leak = null;
});
var leak = new Leaker();
至此,您已经准备好创建一个对象,在内存中观察它,然后释放它。
- 在Chrome中加载索引页面。
由于您是直接从Google加载jQuery,因此需要Internet连接才能运行该示例。
- 通过打开“查看”菜单并选择“开发”子菜单来打开开发人员工具。 选择开发人员工具命令。
- 转到“ 个人档案”选项卡并进行堆快照,如图2所示。
图2. Profiles选项卡
- 将焦点返回到网页,然后选择开始 。
- 拍摄另一个堆快照。
- 筛选第一个快照以查找
Leaker
类的实例。 您应该找不到任何实例。 切换到第二个快照,您将找到一个实例,如图3所示。图3.快照实例
- 将焦点返回到网页,然后选择销毁 。
- 拍摄第三个堆快照。
- 筛选第三个快照以查找
Leaker
类的实例。 您应该找不到任何实例。或者,在加载了第三张快照后,将分析模式从“摘要”切换到“比较”,然后比较第三张和第二张快照。 您应该看到-1的增量(两个快照之间释放了
Leaker
对象的一个实例)。
万岁! 垃圾收集工作。 现在该打破它了。
内存泄漏1:关闭
防止对象被垃圾回收的一种简单方法是使对象在其回调中具有一个间隔或超时。 为了看到这一点,请像清单4所示更新Leaker.js类。
清单4. asset / scripts / leaker.js
var Leaker = function(){};
Leaker.prototype = {
init:function(){
this._interval = null;
this.start();
},
start: function(){
var self = this;
this._interval = setInterval(function(){
self.onInterval();
}, 100);
},
destroy: function(){
if(this._interval !== null){
clearInterval(this._interval);
}
},
onInterval: function(){
console.log("Interval");
}
};
现在,当您重复以上部分中的步骤1至9时,您应该看到在快照3中Leaker
对象已持久并且该间隔将永远运行。 所以发生了什么事? 只要闭包存在,闭包中保留的所有在闭包中引用的局部变量都将保留。 为了确保对setInterval
方法的回调能够访问Leaker实例的范围而执行, this
变量分配给局部变量self
,该局部变量用于从闭包内部触发onInterval
。 当onInterval
触发时,它可以访问Leaker
对象中的所有实例变量,包括self。 但是,只要事件侦听器存在, Leaker
对象进行垃圾收集。
为了解决这个问题,请通过更新destroy按钮的点击处理程序,触发添加到leaker
对象的destroy
方法,然后使存储的leaker
对象的引用为leaker
,如清单5所示。
清单5. asset / scripts / main.js
$("#destroy_button").click(function(){
leak.destroy();
leak = null;
});
销毁对象和对象所有权
有一个标准方法负责使对象有资格进行垃圾回收,这是一个好习惯。 销毁功能的主要目的是集中清理对象已完成的所有工作的责任,这将:
- 防止其引用计数降至0(例如,删除有问题的事件侦听器和回调并从任何服务中注销)。
- 消耗不必要的CPU周期,例如间隔或动画。
destroy
方法通常是清理对象的必要步骤,但很少用。 从理论上讲,保留对销毁对象的引用的其他对象可以在销毁实例之后对其上的方法进行调用。 因为这种情况可能导致非常不可预测的结果,所以仅当对象真正要消失时才调用destroy方法至关重要。
通常,当对象的生命周期明确拥有一个明确的所有者时,销毁方法是最好的。 这种情况在分层系统中经常发生,例如MVC框架中的视图和控制器或画布渲染系统的场景图。
内存泄漏2:控制台日志
将对象保留在内存中的一种特别晦涩的方法是将其记录到控制台。 清单6更新了Leaker
类以显示一个示例。
清单6. asset / scripts / leaker.js
var Leaker = function(){};
Leaker.prototype = {
init:function(){
console.log("Leaking an object: %o", this);
},
destroy: function(){
}
};
您可以通过执行以下步骤来演示控制台的效果。
- 加载索引页面。
- 点击开始 。
- 转到控制台,并验证是否跟踪了泄漏对象。
- 单击销毁 。
- 返回到控制台,然后键入
leak
以记录全局变量的当前内容。 此时该值应为null。 - 拍摄另一个堆快照并过滤Leaker对象。
您应该剩下一个
Leaker
。 - 返回控制台并清除它。
- 再获取一个堆配置文件。
清除控制台后,应该清理掉剩下的一个泄漏器。
控制台日志记录对整体内存配置文件的影响可能是许多开发人员甚至没有考虑的非常重要的问题。 记录错误的对象可以将大量数据保留在内存中。 重要的是要注意,这也适用于:
- 在用户在其中键入JavaScript的控制台中的交互式会话期间记录的对象。
- 由
console.log
和console.dir
方法记录的对象。
内存泄漏3:循环
当两个对象相互引用,使得两个对象相互保留时,就会发生一个循环,如图4所示。
图4.创建循环的参考
清单7显示了一个简单的代码示例。
清单7. asset / scripts / leaker.js
var Leaker = function(){};
Leaker.prototype = {
init:function(name, parent){
this._name = name;
this._parent = parent;
this._child = null;
this.createChildren();
},
createChildren:function(){
if(this._parent !== null){
// Only create a child if this is the root
return;
}
this._child = new Leaker();
this._child.init("leaker 2", this);
},
destroy: function(){
}
};
根对象的实例化将被修改,如清单8所示。
清单8. asset / scripts / main.js
leak = new Leaker();
leak.init("leaker 1", null);
如果在创建和销毁对象之后进行堆分析,则应该看到垃圾检测器在选择销毁按钮时检测到循环引用并释放了内存。
但是,如果引入了保留该子项的第三个对象,则该循环会导致内存泄漏。 例如,如清单9所示创建一个registry
对象。
清单9. asset / scripts / registry.js
var Registry = function(){};
Registry.prototype = {
init:function(){
this._subscribers = [];
},
add:function(subscriber){
if(this._subscribers.indexOf(subscriber) >= 0){
// Already registered so bail out
return;
}
this._subscribers.push(subscriber);
},
remove:function(subscriber){
if(this._subscribers.indexOf(subscriber) < 0){
// Not currently registered so bail out
return;
}
this._subscribers.splice(
this._subscribers.indexOf(subscriber), 1
);
}
};
registry
类是对象的简单示例,它允许其他类向其注册,然后将自身从注册表中删除。 尽管此特定类对注册表没有任何作用,但这是事件分派器和通知系统中的常见模式。
将该类导入到Leoper.js之前的index.html页面中,如清单10所示。
清单10. index.html
<script src="assets/scripts/registry.js" type="text/javascript"
charset="utf-8"></script>
更新Leaker
对象以将其自身注册到注册表对象(大概是为了通知一些未实现的事件)。 这从根节点创建了一条备用路径,以保留子泄漏者,并且由于循环的原因,还将保留父泄漏者,如清单11所示。
清单11. asset / scripts / leaker.js
var Leaker = function(){};
Leaker.prototype = {
init:function(name, parent, registry){
this._name = name;
this._registry = registry;
this._parent = parent;
this._child = null;
this.createChildren();
this.registerCallback();
},
createChildren:function(){
if(this._parent !== null){
// Only create child if this is the root
return;
}
this._child = new Leaker();
this._child.init("leaker 2", this, this._registry);
},
registerCallback:function(){
this._registry.add(this);
},
destroy: function(){
this._registry.remove(this);
}
};
最后,更新main.js来设置注册表,并将对注册表的引用传递给父leaker
对象,如清单12所示。
清单12. asset / scripts / main.js
$("#start_button").click(function(){
var leakExists = !(
window["leak"] === null || window["leak"] === undefined
);
if(leakExists){
return;
}
leak = new Leaker();
leak.init("leaker 1", null, registry);
});
$("#destroy_button").click(function(){
leak.destroy();
leak = null;
});
registry = new Registry();
registry.init();
现在,当您进行堆分析时,应该看到每次选择开始按钮Leaker
创建并保留两个Leaker
对象的新实例。 图5显示了对象引用的流程。
图5.由于保留引用而导致的内存泄漏
从表面上看,这似乎是一个人为的例子,但实际上很普遍。 在更传统的面向对象框架中,事件侦听器通常遵循类似于图5的模式。这种模式还可以与由闭包和控制台日志引起的问题相吻合。
尽管有多种方法可以解决此类问题,但在这种情况下,最简单的更改是对Leaker
类进行更新以在销毁其子对象时将其销毁。 对于示例,更新清单13中的destroy
方法就足够了。
清单13. asset / scripts / leaker.js
destroy: function(){
if(this._child !== null){
this._child.destroy();
}
this._registry.remove(this);
}
有时,两个对象之间存在一个循环,它们之间没有足够牢固的关系,使它们中的一个对另一个对象的生命周期承担责任。 在这种情况下,建立两个对象之间关系的对象应承担破坏销毁周期的责任。
结论
即使JavaScript是垃圾回收的,仍然有许多方法可以将不需要的对象保留在内存中。 大多数现代浏览器在清理内存方面都有改进,但是评估应用程序的内存堆的可用工具仍然有限-Google Chrome除外。 从简单的测试用例开始,很容易评估潜在的泄漏行为并确定是否存在泄漏。
如果不进行测试,就不可能准确地评估内存使用情况。 允许循环引用保留对象图的大部分非常容易。 Chrome的Heap Profiler是诊断内存问题的有用工具; 在开发过程中定期使用它是一个好主意。 对何时需要释放对象图中的特定资源有具体的期望,然后进行验证。 每当您看到意想不到的结果时,请对其进行调查。
创建对象时计划对象的清理要比稍后尝试将清理阶段嫁接到应用程序中容易得多。 始终有计划删除所有事件侦听器,并停止您创建的任何间隔。 了解应用程序中的内存使用情况,您将拥有更可靠,性能更好的应用程序。
翻译自: https://www.ibm.com/developerworks/web/library/wa-jsmemory/index.html