识别内存泄漏

在许多情况下都用到过词语 “内存泄漏” . 它通常用来描述内存增长. 维基百科的定义是这样:

“计算机程序不正确地管理内存分配”.

这个定义是合理的,但有点模糊.

我们的定义

本指南中, 我们把内存泄露定义为:

在重复一段代码后,内存使用量会不受限制地增长. 代码必须重复执行直到资源“耗尽” (直到需要回收内存的地步),并且代码必须保证已经执行了框架的清理工作.

这有点拗口,所以让我们分解一下这个定义的重要部分:

语言/框架清理工作

根据程序运行的环境, 你需要采取一些行动,来表示你已经完成了某块特定内存的使用. 在 Ext JS 中, 一般是 destroy 函数, 通常用来清理 DOM 元素以及取消绑定事件监听.

在 C# 中, 推荐使用 IDisposable 接口. 不管什么平台, 都必须遵循这些约定,以允许平台释放已分配的资源. 如果不采取清理行动, 将会发生内存泄漏,因为平台是不可能知道这些资源是不是不再需要的.

重复执行直到资源耗尽

我们假设有一台剩余 64Gb 空闲内存的开发机器. 有一部分代码运行5次. 通过检查, 每次运行之后,内存使用量都上升 1Mb 并且不被回收.

这个现象问题不算太大. 该程序只用了一小部分内存. 但如果这段代码重复执行 50,000 次,而且仍然不回收内存, 结果将完全不一样. 底层系统需要达到足够的压力,才会被迫回收内存.

使用量无限制增长

这可能这个定义中最微妙的,也是最重要的一部分. 在许多情况下, 调用 destroy 或其他清理方法可能并不会释放已分配资源. 在 Ext JS 中 this is typically observed in its caches.

比如, Ext.ComponentQuery 类是用一个选择器(一个字符串)来搜索组件的. 本质上, 这个字符串选择器被转换成了一个函数,这个函数可以在候选组件上执行. 构造这个函数的代价比较昂贵, 通常这个搜索函数会被执行很多次. 因为这个复用, 生成的函数保留在了内存中. 这里至关重要的一点是,缓存机制是有界的.

缓存用的是 最近最少使用算法 (LRU, Least Recently Used). LRU 持续跟踪内存集合中的每一项. 如果某一项被访问了, 它会被排到最前面. LRU 缓存有个最大容量. 当添加一个项目后超过了最大容量, 最近最少使用的项目将被移出缓存. 当移除直到缓存容量大小的时候,缓存又恢复正常. 这种性质的东西留在内存中是没有问题的. 而只在资源被无限制使用的时候才变成大问题.

抽象和垃圾回收

开发者使用 Ext JS 的时候还远不是真正的内存管理. 更糟的是, 像 Windows 任务管理器 或 Mac 系统活动监视器 这种工具都准确不提供准确的内存消耗信息. 为了更好地理解这里的因果关系,有必要评估下内存管理层面.

分配

  • 开发者向框架申请资源 (比如, 创建一个组件).
  • 框架向 JavaScript 引擎申请资源 (通常使用 new 操作符 或者 createElement, 等等.).
  • JavaScript 引擎向底层内存管理系统申请资源 (通常是 C++ 内存分配).
  • 底层内存管理系统向操作系统申请资源. 这才是我们可以在任务管理器或者活动监视器中可以观察到的.

清理

  1. 开发者调用 Ext JS 组件或其他资源的销毁动作.
  2. Ext JS 组件的 destroy 函数调用其他清理函数, 设置一堆内部变量的引用位 null, 等等..
  3. JavaScript 垃圾回收器,然后决定何时扫描堆内存并回收内存. 回收动作通常要推迟到当有新的内存请求,而空闲内存又“不足”的时候. 内存管理器可能只是简单地增加堆内存使用量,而不是回收垃圾,因为增加堆内存使用量代价更小, 特别是在应用程序生命周期的初期.
  4. 当 JavaScript 内存管理器决定要回收垃圾了, 它必须决定回收的内存是留着自己以后用还是归还给底层的进程堆.
  5. 根据被 JavaScript 内存管理器使用的底层内存管理器(通常是 C++ 内存管理器),被释放的内存可能被留作以后用,有可能归还给操作系统. 此时才可以看到任务管理器或者活动监视器中发生了更新.

鉴于上述情况, 很明显, JavaScript开发人员几乎不能控制内存管理的大局. 这里面有很多可变因素,而且真正的内存管理只是其中一个很小的部分.

本指南的目的, 我们不会继续深究下去. 足以说 JavaScript 堆内存和垃圾回收只是根据它们自己的意愿进行,强制它们以特定的方式执行是不可能的. 我们能做的顶就是确保对象不再被代码或框架引用.

所以, 用操作系统的任务管理器或监视器来检查内存使用量和增长量是不必要的.

检测内存泄漏

应用程序级别的内存泄漏

当应用程序清理框架资源失败的时候, 可能导致这些对象留在了多个集合中. 这些都是特定于版本的具体细节, 可以检查的地方有:

框架级别的内存泄漏

尽管在框架内部做了很大努力来清理资源, 但总有遗漏的地方. 以往的经验看, 最普遍的是 DOM 元素导致的泄露. 如果怀疑是这种情况, sIEve 这个工具可以在 IE 浏览器上检查内存泄漏.

注意: 我们强烈建议你优先关注所有的应用程序级别的泄漏,然后再关注更低级别的.

常见的引起泄漏的代码和解决方案

下面的代码片段和描述将突出一些可能会引起问题的滥用内存的方式.

阻止了基类的清理工作

为了在派生类中清理资源,基类的清理工作可能会不经意地被绕过。

举个例子:

Ext.define('Foo.bar.CustomButton', {
    extend: 'Ext.button.Button',
    onDestroy: function () {
        // 一些清理工作
    }
});

解决办法: 确保调用了callParent(), 以执行基类的清理工作.

没有移除 DOM 事件监听

一个 DOM 元素附加了一个事件监听函数. 不过,当 DOM 元素被销毁(使用改变父元素的innerHTML)的时候, 事件处理函数仍然存在内存中.

Ext.fly(someElement).on('click', doSomething);

someElement.parentNode.innerHTML = '';

解决办法: 保持对重要的元素的引用,然后在不需要它们的时候调用它们的 destroy 函数.

对某个对象的引用一直存在

花了很多内存创建了一个类的实例. 实例被销毁之后, 仍然在其它地方有对此实例的引用.

Ext.define('MyClass', {

    constructor: function() {
        this.foo = new SomeLargeObject();
    },

    destroy: function() {
        this.foo.destroy();
    }
});

this.o = new MyClass();
o.destroy();

//  `o` 仍然被`this` 引用, `foo`仍然被 `o` 引用.

解决办法: 把引用设为 null ,以保证内存可以被回收. 此处则是在调用 destroy 之后设置 this.foo = null,并设置this.o = null.

在闭包中引用对象

这种情况更加微妙, 但是和上面的比较像. 闭包里面有对重量级对象的引用,只要闭包仍然被使用,这个对象的内存就不会被释放.

function runAsync(val) {
    var o = new SomeLargeObject();
    var x = 42;

    // 其他代码

    return function() {
        return x;  // o 位于闭包作用域内,但其实 o 已经用不到了
    }
}

var f = runAsync(1);

上述现象的原因,一般是因为这个对象位于闭包的外层作用域,但在内层函数中已不再需要. 这种很容易被遗漏, 但是对内存的使用量会产生负面影响.

解决办法: 使用 Ext.Function.bind() 或者标准 JavaScript 提供的方法 bind 来为外部函数创建安全的闭包.

function fn (x) {
    return x;
}

function runAsync(val) {
    var o = new SomeLargeObject();
    var x = 42;

    // 其他代码

    return Ext.Function.bind(fn, null, [x]); // o is not captured
}

var f = runAsync(1);

不断创建实例带来的副作用

创建某些实例可能会有副作用 (比如创建 DOM 元素). 如果不销毁已经有的实例,就去创建新的实例,就会发生内存泄漏.

{
    xtype: 'treepanel',
    listeners: {
        itemclick: function(view, record, item, index, e) {

            // 总是在此处创建新的Menu
            new Ext.menu.Menu({
                items: [record.get('name')]
            }).showAt(e.getXY());
        }
    }
}

解决办法: 获得对已创建 menu 实例的引用,并在不需要它的时候调用它的 destroy 方法.

清理缓存(cache)中所有对对象的引用(原文是registration, 注册)

清理所有对某个对象的引用非常重要. 只把局部变量设置为 null 是不够的. 如果某个全局 单例缓存(singleton cache) 中引用了这个对象, 那么这个引用会一直存在于整个应用程序的生命周期.

var o = new SomeLargeObject();
someCache.register(o);

// 销毁对象并把引用置为 null. someCache 里仍然注册有对该对象的引用
o.destroy();
o = null;

解决办法: 确保在调用了 destroy 之后,清理所有对某个对象的引用.

总结

控制应用程序的内存管理还是比较简单的: 销毁不使用的组件, 把不用的引用置为 null, 并使用 callParent(). 遵从这些建议,可以让你的应用程序可以正确地使用资源,保证应用程序运行流畅.

Last updated