了解 JavaScript 应用程序中的内存泄漏

简介

当处理 JavaScript 这样的脚本语言时,很容易忘记每个对象、类、字符串、数字和方法都需要分配和保留内存。语言和运行时的垃圾回收器隐藏了内存分配和释放的具体细节。

许多功能无需考虑内存管理即可实现,但却忽略了它可能在程序中带来重大的问题。不当清理的对象可能会存在比预期要长得多的时间。这些对象继续响应事件和消耗资源。它们可强制浏览器从一个虚拟磁盘驱动器分配内存页,这显著影响了计算机的速度(在极端的情形中,会导致浏览器崩溃)。

内存泄漏指任何对象在您不再拥有或需要它之后仍然存在。在最近几年中,许多浏览器都改善了在页面加载过程中从 JavaScript 回收内存的能力。但是,并不是所有浏览器都具有相同的运行方式。Firefox 和旧版的 Internet Explorer 都存在过内存泄漏,而且内存泄露一直持续到浏览器关闭。

过去导致内存泄漏的许多经典模式在现代浏览器中以不再导致泄漏内存。但是,如今有一种不同的趋势影响着内存泄漏。许多人正设计用于在没有硬页面刷新的单页中运行的 Web 应用程序。在那样的单页中,从应用程序的一个状态到另一个状态时,很容易保留不再需要或不相关的内存。

在本文中,了解对象的基本生命周期,垃圾回收如何确定一个对象是否被释放,以及如何评估潜在的泄漏行为。另外,学习如何使用 Google Chrome 中的 Heap Profiler 来诊断内存问题。一些示例展示了如何解决闭包、控制台日志和循环带来的内存泄漏。

对象生命周期

要了解如何预防内存泄漏,需要了解对象的基本生命周期。当创建一个对象时,JavaScript 会自动为该对象分配适当的内存。从这一刻起,垃圾回收器就会不断对该对象进行评估,以查看它是否仍是有效的对象。

垃圾回收器定期扫描对象,并计算引用了每个对象的其他对象的数量。如果一个对象的引用数量为 0(没有其他对象引用过该对象),或对该对象的惟一引用是循环的,那么该对象的内存即可回收。图 1 显示了垃圾回收器回收内存的一个示例。

图 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 文件合并到单个文件中通常是一种更好的做法。出于本示例的用途,将逻辑放在独立的文件中更容易。

您可以过滤 Heap Profiler 来仅显示特殊类的实例。为了利用该功能,创建一个新类来封装泄漏对象的行为,而且这个类很容易在 Heap Profiler 中找到,如清单 2 所示。

清单 2. assets/scripts/leaker.js
var Leaker = function(){};
Leaker.prototype = {
    init:function(){

    }
};

绑定 Start 按钮以初始化 Leaker 对象,并将它分配给全局命名空间中的一个变量。还需要将 Destroy 按钮绑定到一个应清理 Leaker 对象的方法,并让它为垃圾收集做好准备,如清单 3 所示。

清单 3. assets/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();

现在,您已准备好创建一个对象,在内存中查看它,然后释放它。

  1. 在 Chrome 中加载索引页面。

    因为您是直接从 Google 加载 jQuery,所以需要连接互联网来运行该样例。

  2. 打开开发人员工具,方法是打开 View 菜单并选择 Develop 子菜单。选择 Developer Tools 命令。
  3. 转到 Profiles 选项卡并获取一个堆快照,如图 2 所示。
    图 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 对象就不会被垃圾回收。

要解决此问题,可在清空所存储的 leaker 对象引用之前,触发添加到该对象的 destroy 方法,方法是更新 Destroy 按钮的单击处理程序,如清单 5 所示。

清单 5. assets/scripts/main.js
$("#destroy_button").click(function(){
    leak.destroy();
    leak = null;
});

销毁对象和对象所有权

一种不错的做法是,创建一个标准方法来负责让一个对象有资格被垃圾回收。destroy 功能的主要用途是,集中清理该对象完成的具有以下后果的操作的职责:

  • 阻止它的引用计数下降到 0(例如,删除存在问题的事件侦听器和回调,并从任何服务取消注册)。
  • 使用不必要的 CPU 周期,比如间隔或动画。

destroy 方法常常是清理一个对象的必要步骤,但在大多数情况下它还不够。在理论上,在销毁相关实例后,保留对已销毁对象的引用的其他对象可调用自身之上的方法。因为这种情形可能会产生不可预测的结果,所以仅在对象即将无用时调用 destroy 方法,这至关重要。

一般而言,destroy 方法最佳使用是在一个对象有一个明确的所有者来负责它的生命周期时。此情形常常存在于分层系统中,比如 MVC 框架中的视图或控制器,或者一个画布呈现系统的场景图。

内存泄漏 2:控制台日志

一种将对象保留在内存中的不太明显的方式是将它记录到控制台中。清单 6 更新了 Leaker 类,显示了此方式的一个示例。

清单 6. assets/scripts/leaker.js
var Leaker = function(){};

Leaker.prototype = {
    init:function(){
        console.log("Leaking an object: %o", this);
    },

    destroy: function(){

    }
};

可采取以下步骤来演示控制台的影响。

  1. 登录到索引页面。
  2. 单击 Start
  3. 转到控制台并确认 Leaking 对象已被跟踪。
  4. 单击 Destroy
  5. 回到控制台并键入 leak,以记录全局变量当前的内容。此刻该值应为空。
  6. 获取另一个堆快照并过滤 Leaker 对象。

    您应留下一个 Leaker 对象。
  7. 回到控制台并清除它。
  8. 创建另一个堆配置文件。

    在清理控制台后,保留 leaker 的配置文件应已清除。

控制台日志记录对总体内存配置文件的影响可能是许多开发人员都未想到的极其重大的问题。记录错误的对象可以将大量数据保留在内存中。注意,这也适用于:

  • 在用户键入 JavaScript 时,在控制台中的一个交互式会话期间记录的对象。
  • 由 console.log 和 console.dir 方法记录的对象。

内存泄漏 3:循环

在两个对象彼此引用且彼此保留时,就会产生一个循环,如图 4 所示。

图 4. 创建一个循环的引用

清单 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 所示。

清单 8. assets/scripts/main.js
leak = new Leaker();
leak.init("leaker 1", null);

如果在创建和销毁对象后执行一次堆分析,您应该会看到垃圾收集器检测到了这个循环引用,并在您选择 Destroy 按钮时释放了内存。

但是,如果引入了第三个保留该子对象的对象,该循环会导致内存泄漏。例如,创建一个 registry 对象,如清单 9 所示。

清单 9. assets/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 类是让其他对象向它注册,然后从注册表中删除自身的对象的简单示例。尽管这个特殊的类与注册表毫无关联,但这是事件调度程序和通知系统中的一种常见模式。

将该类导入 index.html 页面中,放在 leaker.js 之前,如清单 10 所示。

清单 10. index.html
<script src="assets/scripts/registry.js" type="text/javascript"
charset="utf-8"></script>

更新 Leaker 对象,以向注册表对象注册该对象本身(可能用于有关一些未实现事件的通知)。这创建了一个来自要保留的 leaker 子对象的 root 节点备用路径,但由于该循环,父对象也将保留,如清单 11 所示。

清单 11. assets/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. assets/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();

现在,当执行堆分析时,您应看到每次选择 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);
}

有时,两个没有足够紧密关系的对象之间也会存在循环,其中一个对象管理另一个对象的生命周期。在这样的情况下,在这两个对象之间建立关系的对象应负责在自己被销毁时中断循环。

结束语

即使 JavaScript 已被垃圾回收,仍然会有许多方式会将不需要的对象保留在内存中。目前大部分浏览器都已改进了内存清理功能,但评估您应用程序内存堆的工具仍然有限(除了使用 Google Chrome)。通过从简单的测试案例开始,很容易评估潜在的泄漏行为并确定是否存在泄漏。

不经过测试,就不可能准确度量内存使用。很容易使循环引用占据对象曲线图中的大部分区域。Chrome 的 Heap Profiler 是一个诊断内存问题的宝贵工具,在开发时定期使用它也是一个不错的选择。在预测对象曲线图中要释放的具体资源时请设定具体的预期,然后进行验证。任何时候当您看到不想要的结果时,请仔细调查。

在创建对象时要计划该对象的清理工作,这比在以后将一个清理阶段移植到应用程序中要容易得多。常常要计划删除事件侦听器,并停止您创建的间隔。如果认识到了您应用程序中的内存使用,您将得到更可靠且性能更高的应用程序。

参考资料

学习

时间: 2024-10-09 09:14:16

了解 JavaScript 应用程序中的内存泄漏的相关文章

[Swift通天遁地]七、数据与安全-(11)如何检测应用程序中的内存泄露

本文将演示使用Instruments Allocations工具检测应用程序中的内存泄漏问题. 内存溢出 out of memory:是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory:比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出. 内存泄露 memory leak:是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光. 在项目文件夹[DemoApp]上点击鼠标

JavaScript中的内存泄漏以及如何处理

随着现在的编程语言功能越来越成熟.复杂,内存管理也容易被大家忽略.本文将会讨论JavaScript中的内存泄漏以及如何处理,方便大家在使用JavaScript编码时,更好的应对内存泄漏带来的问题. 概述 像C语言这样的编程语言,具有简单的内存管理功能函数,例如malloc( )和free( ).开发人员可以使用这些功能函数来显式地分配和释放系统的内存. 当创建对象和字符串等时,JavaScript就会分配内存,并在不再使用时自动释放内存,这种机制被称为垃圾收集.这种释放资源看似是"自动"

JavaScript 中的内存泄漏

JavaScript 是一种垃圾收集式语言,这就是说,内存是根据对象的创建分配给该对象的,并会在没有对该对象的引用时由浏览器收回.JavaScript 的垃圾收集机制本身并没有问题,但浏览器在为 DOM 对象分配和恢复内存的方式上却有些出入. Internet Explorer 和 Mozilla Firefox 均使用引用计数来为 DOM 对象处理内存.在引用计数系统,每个所引用的对象都会保留一个计数,以获悉有多少对象正在引用它.如果计数为零,该对象就会被销毁,其占用的内存也会返回 给堆.虽然

Java中的内存泄漏

[转]介绍Java中的内存泄漏 1. 什么是内存泄漏? 内存泄漏的定义:对象已经没有被应用程序使用,但是垃圾回收器没办法移除它们,因为还在被引用着. 要想理解这个定义,我们需要先了解一下对象在内存中的状态.下面的这张图就解释了什么是无用对象以及什么是未被引用对象. 2. 为什么会发生内存泄漏? 来先看看下面的例子,为什么会发生内存泄漏.下面这个例子中,A对象引用B对象,A对象的生命周期(t1-t4)比B对象的生命周期(t2-t3)长的多.当B对象没有被应用程序使用之后,A对象仍然在引用着B对象.

安卓中的内存泄漏

因为安卓是基于java语言的,所以我们先来看一看java中的内存泄漏,然后在此基础上来谈谈安卓中的内存泄漏. 一java中的内存泄漏: java中的内存泄漏主要是指在堆中分配的内存,明明已经不需要的时候,还仍然保留着访问它的引用,导致GC回收不能及时回收(关于GC回收不做过多赘述),导致这种情况出现的最主要原因是长生命周期的对象持有短生命周期对象的引用,导致短生命周期的对象明明已经不需要却无法被GC回收,从而导致内存泄漏.主要包括以下几种情况: 1在一个类中创建了一个非静态内部类的静态实例,如下

在 JNI 编程中避免内存泄漏

JAVA 中的内存泄漏 JAVA 编程中的内存泄漏,从泄漏的内存位置角度可以分为两种:JVM 中 Java Heap 的内存泄漏:JVM 内存中 native memory 的内存泄漏. Java Heap 的内存泄漏 Java 对象存储在 JVM 进程空间中的 Java Heap 中,Java Heap 可以在 JVM 运行过程中动态变化.如果 Java 对象越来越多,占据 Java Heap 的空间也越来越大,JVM 会在运行时扩充 Java Heap 的容量.如果 Java Heap 容量

Java中的内存泄漏问题

今天来谈谈Java语言中的内存泄漏问题,可能还有人不知道什么是内存泄漏,先来说下内存泄漏的概念. 内存泄漏:比较正式的说法是,不再使用的对象,却不能被Java垃圾回收机回收.用我的话来说,就是Java垃圾回收不能回收的空间. 产生的条件:从概念可以看出来产生内存泄漏必须满足两个必要条件: 一是对象还存在引用,但是在以后的程序中不会再被使用 二是这些对象不能被垃圾回收掉(原因是对象还有引用). 解决办法:这就需要我们在编程时保持好的编程习惯, 1.对于不再使用的对象,需要及时为他赋值为null 2

Java中关于内存泄漏出现的原因以及如何避免内存泄漏(超详细版)

Android 内存泄漏总结 内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题.内存泄漏大家都不陌生了,简单粗俗的讲,就是该被释放的对象没有释放,一直被某个或某些实例所持有却不再被使用导致 GC 不能回收.最近自己阅读了大量相关的文档资料,打算做个 总结 沉淀下来跟大家一起分享和学习,也给自己一个警示,以后 coding 时怎么避免这些情况,提高应用的体验和质量. 我会从 java 内存泄漏的基础知识开始,并通过具体例子来说明 Android 引起内存泄漏的各种原因,以

VS中检测内存泄漏的方法

vs中检测内存泄漏的方法 分类: MFC2013-03-08 21:44 2764人阅读 评论(0) 收藏 举报 使用vs的内存检测有以下几种方法. 在debug模式下以F5运行: 方法一: [html] view plaincopy #define CRTDBG_MAP_ALLOC #include <stdlib.h> #include <crtdbg.h> //在入口函数中包含 _CrtDumpMemoryLeaks(); //即可检测到内存泄露 //以如下测试函数为例: i