垃圾回收解放了咱们,它让咱们可将精力集中在应用程序逻辑(而不是内存管理)上。可是,垃圾收集并不神奇。了解它的工做原理,以及如何使它保留本应在好久之前释放的内存,就能够实现更快更可靠的应用程序。在本文中,学习一种定位 JavaScript 应用程序中内存泄漏的系统方法、几种常见的泄漏模式,以及解决这些泄漏的适当方法。javascript
当处理 JavaScript 这样的脚本语言时,很容易忘记每一个对象、类、字符串、数字和方法都须要分配和保留内存。语言和运行时的垃圾回收器隐藏了内存分配和释放的具体细节。html
许多功能无需考虑内存管理便可实现,但却忽略了它可能在程序中带来重大的问题。不当清理的对象可能会存在比预期要长得多的时间。这些对象继续响应事件和消耗资源。它们可强制浏览器从一个虚拟磁盘驱动器分配内存页,这显著影响了计算机的速度(在极端的情形中,会致使浏览器崩溃)。java
内存泄漏指任何对象在您再也不拥有或须要它以后仍然存在。在最近几年中,许多浏览器都改善了在页面加载过程当中从 JavaScript 回收内存的能力。可是,并非全部浏览器都具备相同的运行方式。Firefox 和旧版的 Internet Explorer 都存在过内存泄漏,并且内存泄露一直持续到浏览器关闭。jquery
过去致使内存泄漏的许多经典模式在现代浏览器中以再也不致使泄漏内存。可是,现在有一种不一样的趋势影响着内存泄漏。许多人正设计用于在没有硬页面刷新的单页中运行的 web 应用程序。在那样的单页中,从应用程序的一个状态到另外一个状态时,很容易保留再也不须要或不相关的内存。web
在本文中,了解对象的基本生命周期,垃圾回收如何肯定一个对象是否被释放,以及如何评估潜在的泄漏行为。另外,学习如何使用 Google Chrome 中的 Heap Profiler 来诊断内存问题。一些示例展现了如何解决闭包、控制台日志和循环带来的内存泄漏。ajax
要了解如何预防内存泄漏,须要了解对象的基本生命周期。当建立一个对象时,JavaScript 会自动为该对象分配适当的内存。从这一刻起,垃圾回收器就会不断对该对象进行评估,以查看它是否还是有效的对象。api
垃圾回收器按期扫描对象,并计算引用了每一个对象的其余对象的数量。若是一个对象的引用数量为 0(没有其余对象引用过该对象),或对该对象的唯一引用是循环的,那么该对象的内存便可回收。图 1 显示了垃圾回收器回收内存的一个示例。浏览器
看到该系统的实际应用会颇有帮助,但提供此功能的工具颇有限。了解您的 JavaScript 应用程序占用了多少内存的一种方式是使用系统工具查看浏览器的内存分配。有多个工具可为您提供当前的使用,并描绘一个进程的内存使用量随时间变化的趋势图。闭包
例如,若是在 Mac OSX 上安装了 XCode,您能够启动 Instruments 应用程序,并将它的活动监视器工具附加到您的浏览器上,以进行实时分析。在 Windows® 上,您可使用任务管理器。若是在您使用应用程序的过程当中,发现内存使用量随时间变化的曲线稳步上升,那么您就知道存在内存泄漏。架构
观察浏览器的内存占用只能很是粗略地显示 JavaScript 应用程序的实际内存使用。浏览器数据不会告诉您哪些对象发生了泄漏,也没法保证数据与您应用程序的真正内存占用确实匹配。并且,因为一些浏览器中存在实现问题,DOM 元素(或备用的应用程序级对象)可能不会在页面中销毁相应元素时释放。视频标记尤其如此,视频标记须要浏览器实现一种更加精细的基础架构。
人们曾屡次尝试在客户端 JavaScript 库中添加对内存分配的跟踪。不幸的是,全部尝试都不是特别可靠。例如,流行的 stats.js 包因为不许确性而没法支持。通常而言,尝试从客户端维护或肯定此信息存在必定的问题,是由于它会在应用程序中引入开销且没法可靠地终止。
理想的解决方案是浏览器供应商在浏览器中提供一组工具,帮助您监视内存使用,识别泄漏的对象,以及肯定为何一个特殊对象仍标记为保留。
目前,只有 Google Chrome(提供了 Heap Profile)实现了一个内存管理工具做为它的开发人员工具。我在本文中使用 Heap Profiler 测试和演示 JavaScript 运行时如何处理内存。
在建立内存泄漏以前,请查看一次适当收集内存的简单交互。首先建立一个包含两个按钮的简单 HTML 页面,如清单 1 所示。
<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 文件合并到单个文件中一般是一种更好的作法。出于本示例的用途,将逻辑放在独立的文件中更容易。
您能够过滤 Heap Profiler 来仅显示特殊类的实例。为了利用该功能,建立一个新类来封装泄漏对象的行为,并且这个类很容易在 Heap Profiler 中找到,如清单 2 所示。
var Leaker = function(){}; Leaker.prototype = { init:function(){ } };
绑定 Start 按钮以初始化 Leaker 对象,并将它分配给全局命名空间中的一个变量。还须要将 Destroy 按钮绑定到一个应清理 Leaker 对象的方法,并让它为垃圾收集作好准备,如清单 3 所示。
$("#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();
如今,您已准备好建立一个对象,在内存中查看它,而后释放它。
万岁!垃圾回收有效的。如今是时候破坏它了。
一种预防一个对象被垃圾回收的简单方式是设置一个在回调中引用该对象的间隔或超时。要查看实际应用,可更新 leaker.js 类,如清单 4 所示。
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 步时,您应在第三个快照中看到,Leaker 对象被持久化,而且该间隔会永远继续运行。那么发生了什么?在一个闭包中引用的任何局部变量都会被该闭包保留,只要该闭包存在就永远保留。要确保对 setInterval 方法的回调在访问 Leaker 实例的范围时执行,须要将 this 变量分配给局部变量 self,这个变量用于从闭包内触发 onInterval。当 onInterval 触发时,它就可以访问Leaker 对象中的任何实例变量(包括它自身)。可是,只要事件侦听器存在,Leaker 对象就不会被垃圾回收。
要解决此问题,可在清空所存储的 leaker 对象引用以前,触发添加到该对象的 destroy 方法,方法是更新 Destroy 按钮的单击处理程序,如清单 5 所示。
$("#destroy_button").click(function(){ leak.destroy(); leak = null; });
一种不错的作法是,建立一个标准方法来负责让一个对象有资格被垃圾回收。destroy 功能的主要用途是,集中清理该对象完成的具备如下后果的操做的职责:
destroy 方法经常是清理一个对象的必要步骤,但在大多数状况下它还不够。在理论上,在销毁相关实例后,保留对已销毁对象的引用的其余对象可调用自身之上的方法。由于这种情形可能会产生不可预测的结果,因此仅在对象即将无用时调用 destroy 方法,这相当重要。
通常而言,destroy 方法最佳使用是在一个对象有一个明确的全部者来负责它的生命周期时。此情形经常存在于分层系统中,好比 MVC 框架中的视图或控制器,或者一个画布呈现系统的场景图。
一种将对象保留在内存中的不太明显的方式是将它记录到控制台中。清单 6 更新了 Leaker 类,显示了此方式的一个示例。
var Leaker = function(){}; Leaker.prototype = { init:function(){ console.log("Leaking an object: %o", this); }, destroy: function(){ } };
可采起如下步骤来演示控制台的影响。
控制台日志记录对整体内存配置文件的影响多是许多开发人员都未想到的极其重大的问题。记录错误的对象能够将大量数据保留在内存中。注意,这也适用于:
在两个对象彼此引用且彼此保留时,就会产生一个循环,如图 4 所示。
清单 7 显示了一个简单的代码示例。
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(){ } };
Root 对象的实例化能够修改,如清单 8 所示。
leak = new Leaker(); leak.init("leaker 1", null);
若是在建立和销毁对象后执行一次堆分析,您应该会看到垃圾收集器检测到了这个循环引用,并在您选择 Destroy 按钮时释放了内存。
可是,若是引入了第三个保留该子对象的对象,该循环会致使内存泄漏。例如,建立一个 registry 对象,如清单 9 所示。
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 类是让其余对象向它注册,而后从注册表中删除自身的对象的简单示例。尽管这个特殊的类与注册表毫无关联,但这是事件调度程序和通知系统中的一种常见模式。
将该类导入 index.html 页面中,放在 leaker.js 以前,如清单 10 所示。
<script src="assets/scripts/registry.js" type="text/javascript" charset="utf-8"></script>
更新 Leaker 对象,以向注册表对象注册该对象自己(可能用于有关一些未实现事件的通知)。这建立了一个来自要保留的 leaker 子对象的 root 节点备用路径,但因为该循环,父对象也将保留,如清单 11 所示。
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 所示。
$("#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();
如今,当执行堆分析时,您应看到每次选择 Start 按钮时,会建立并保留 Leaker 对象的两个新实例。图 5 显示了对象引用的流程。
从表面上看,它像一个不天然的示例,但它实际上很是常见。更加经典的面向对象框架中的事件侦听器经常遵循相似图 5 的模式。这种类型的模式也可能与闭包和控制台日志致使的问题相关联。
尽管有多种方式来解决此类问题,但在此状况下,最简单的方式是更新 Leaker 类,以在销毁它时销毁它的子对象。对于本示例,更新destroy 方法(如清单 13 所示)就足够了。
destroy: function(){ if(this._child !== null){ this._child.destroy(); } this._registry.remove(this); }
有时,两个没有足够紧密关系的对象之间也会存在循环,其中一个对象管理另外一个对象的生命周期。在这样的状况下,在这两个对象之间创建关系的对象应负责在本身被销毁时中断循环。
即便 JavaScript 已被垃圾回收,仍然会有许多方式会将不须要的对象保留在内存中。目前大部分浏览器都已改进了内存清理功能,但评估您应用程序内存堆的工具仍然有限(除了使用 Google Chrome)。经过从简单的测试案例开始,很容易评估潜在的泄漏行为并肯定是否存在泄漏。
不通过测试,就不可能准确度量内存使用。很容易使循环引用占据对象曲线图中的大部分区域。Chrome 的 Heap Profiler 是一个诊断内存问题的宝贵工具,在开发时按期使用它也是一个不错的选择。在预测对象曲线图中要释放的具体资源时请设定具体的预期,而后进行验证。任什么时候候当您看到不想要的结果时,请仔细调查。
在建立对象时要计划该对象的清理工做,这比在之后将一个清理阶段移植到应用程序中要容易得多。经常要计划删除事件侦听器,并中止您建立的间隔。若是认识到了您应用程序中的内存使用,您将获得更可靠且性能更高的应用程序。