
图 1. 通过垃圾收集回收内存
看到该系统的实际应用会很有帮助,但提供此功能的工具很有限。了解您的 JavaScript 应用程序占用了多少内存的一种方式是使用系统工具查看浏览器的内存分配。有多个工具可为您提供当前的使用,并描绘一个进程的内存使用量随时间变化的趋势图。
例如,如果在 Mac OSX 上安装了 XCode,您可以启动 Instruments 应用程序,并将它的活动监视器工具附加到您的浏览器上,以进行实时分析。在 Windows® 上,您可以使用任务管理器。如果在您使用应用程序的过程中,发现内存使用量随时间变化的曲线稳步上升,那么您就知道存在内存泄漏。
观察浏览器的内存占用只能非常粗略地显示 JavaScript 应用程序的实际内存使用。浏览器数据不会告诉您哪些对象发生了泄漏,也无法保证数据与您应用程序的真正内存占用确实匹配。而且,由于一些浏览器中存在实现问题,DOM 元素(或备用的应用程序级对象)可能不会在页面中销毁相应元素时释放。视频标记尤为如此,视频标记需要浏览器实现一种更加精细的基础架构。
人们曾多次尝试在客户端 JavaScript 库中添加对内存分配的跟踪。不幸的是,所有尝试都不是特别可靠。例如,流行的 stats.js 包由于不准确性而无法支持。一般而言,尝试从客户端维护或确定此信息存在一定的问题,是因为它会在应用程序中引入开销且无法可靠地终止。
理想的解决方案是浏览器供应商在浏览器中提供一组工具,帮助您监视内存使用,识别泄漏的对象,以及确定为什么一个特殊对象仍标记为保留。
目前,只有 Google Chrome(提供了 Heap Profile)实现了一个内存管理工具作为它的开发人员工具。我在本文中使用 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 文件合并到单个文件中通常是一种更好的做法。出于本示例的用途,将逻辑放在独立的文件中更容易。
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();现在,您已准备好创建一个对象,在内存中查看它,然后释放它。
图 2. Profiles 选项卡
4)、将注意力返回到 Web 上,选择 Start。
5)、获取另一个堆快照。
6)、过滤第一个快照,查找 Leaker 类的实例,找不到任何实例。切换到第二个快照,您应该能找到一个实例,如图 3 所示。

图 3. 快照实例
7)、将注意力返回到 Web 上,选择 Destroy。
8)、获取第三个堆快照。
9)、过滤第三个快照,查找 Leaker 类的实例,找不到任何实例。在加载第三个快照时,也可将分析模式从 Summary 切换到 Comparison,并对比第三个和第二个快照。您会看到偏移值 -1(在两次快照之间释放了 Leaker 对象的一个实例)。
万岁!垃圾回收有效的。现在是时候破坏它了。
四、内存泄漏1:闭包
一种预防一个对象被垃圾回收的简单方式是设置一个在回调中引用该对象的间隔或超时。要查看实际应用,可更新 leaker.js 类,如清单 4 所示。
清单 4. assets/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 步时,您应在第三个快照中看到,Leaker 对象被持久化,并且该间隔会永远继续运行。那么发生了什么?在一个闭包中引用的任何局部变量都会被该闭包保留,只要该闭包存在就永远保留。要确保对 setInterval 方法的回调在访问 Leaker 实例的范围时执行,需要将 this 变量分配给局部变量 self,这个变量用于从闭包内触发 onInterval。当 onInterval 触发时,它就能够访问Leaker 对象中的任何实例变量(包括它自身)。但是,只要事件侦听器存在,Leaker 对象就不会被垃圾回收。$("#destroy_button").click(function(){ leak.destroy(); leak = null;});五、销毁对象和对象所有权var Leaker = function(){};Leaker.prototype = { init:function(){console.log("Leaking an object: %o", this); }, destroy: function(){ }};可采取以下步骤来演示控制台的影响。
图 4. 创建一个循环的引用
该图中的一个蓝色 root 节点连接到两个绿色框,显示了它们之间的一个连接
清单 7 显示了一个简单的代码示例。
清单 7. assets/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(){ }};Root 对象的实例化可以修改,如清单 8 所示。leak = new Leaker(); leak.init("leaker 1", null);如果在创建和销毁对象后执行一次堆分析,您应该会看到垃圾收集器检测到了这个循环引用,并在您选择 Destroy 按钮时释放了内存。
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 类是让其他对象向它注册,然后从注册表中删除自身的对象的简单示例。尽管这个特殊的类与注册表毫无关联,但这是事件调度程序和通知系统中的一种常见模式。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. 由于保留引用导致的内存泄漏
从表面上看,它像一个不自然的示例,但它实际上非常常见。更加经典的面向对象框架中的事件侦听器常常遵循类似图 5 的模式。这种类型的模式也可能与闭包和控制台日志导致的问题相关联。
尽管有多种方式来解决此类问题,但在此情况下,最简单的方式是更新 Leaker 类,以在销毁它时销毁它的子对象。对于本示例,更新destroy 方法(如清单 13 所示)就足够了。
清单 13. assets/scripts/leaker.js
destroy: function(){ if(this._child !== null){this._child.destroy();} this._registry.remove(this);}有时,两个没有足够紧密关系的对象之间也会存在循环,其中一个对象管理另一个对象的生命周期。在这样的情况下,在这两个对象之间建立关系的对象应负责在自己被销毁时中断循环。