65.9K
CodeProject 正在变化。 阅读更多。
Home

Internet Explorer 中的内存泄漏——重访

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (23投票s)

2005年11月12日

9分钟阅读

viewsIcon

406020

在本文中,我们将从一个稍微不同的角度审视 JavaScript 内存泄漏模式,并通过图表和内存使用图来支持它。

引言

如果你正在开发客户端可重用脚本对象,迟早你会发现自己会找出内存泄漏。很有可能你的浏览器会像海绵一样吸走内存,而且你很难找出为什么你的可爱的 DHTML 导航在访问了你网站上的几个页面后响应速度会严重下降的原因。

微软开发者 Justing Rogers他优秀的文章中描述了 IE 泄漏模式。

在本文中,我们将从一个稍微不同的角度审视这些模式,并通过图表和内存使用图来支持它。我们还将介绍几种更微妙的泄漏场景。在开始之前,如果你还没有阅读过那篇文章,我强烈建议你阅读。

为什么会发生内存泄漏?

内存泄漏问题不仅限于 Internet Explorer。几乎任何浏览器(包括但不限于 Mozilla、Netscape 和 Opera)如果你提供足够的条件(而且这样做并不难,我们很快就会看到),都会泄漏内存。但是(在我拙见,见仁见智,等等),Internet Explorer 是泄漏之王。

别误会我的意思。我不属于那种叫嚣“嘿,IE 有内存泄漏,看看这个新工具 [工具链接] 亲自看看”的人群。让我们讨论一下 Internet Explorer 有多糟糕,并掩盖其他浏览器的所有缺陷。

每个浏览器都有其优点和缺点。例如,Mozilla 在初始启动时会消耗过多的内存,它在字符串和数组操作方面表现不佳;如果你编写了一个极其复杂的 DHTML 脚本,可能会导致 Opera 崩溃,因为它会混淆其渲染引擎。

如果你读过我之前的文章,那么你已经知道我是一个可用性、可访问性和标准方面的狂热者。我喜欢 Opera。但这并不意味着我正在与 IE 进行一场圣战。我在这里想要做的是遵循分析路径,检查乍一看可能不太明显的各种泄漏模式。

尽管我们将专注于 Internet Explorer 中的内存泄漏情况,但此讨论同样适用于其他浏览器。

一个简单的开始

让我们从一个简单的例子开始

[Exhibit 1 - Memory leaking insert due to inline script]

<html>
<head>
<script type="text/javascript">
    function LeakMemory(){
        var parentDiv = 
             document.createElement("<div onclick='foo()'>");

        parentDiv.bigString = new Array(1000).join(
                              new Array(2000).join("XXXXX"));
    }
</script>
</head>
<body>
<input type="button" 
       value="Memory Leaking Insert" onclick="LeakMemory()" />
</body>
</html>

第一个赋值 parentDiv=document.createElement(...); 将创建一个 div 元素并为其创建一个临时作用域,其中脚本对象驻留。第二个赋值 parentDiv.bigString=... 将一个大对象附加到 parentDiv。当调用 LeakMemory() 方法时,将在该函数的作用域内创建一个 DOM 元素,一个非常大的对象将作为成员属性附加到它,并且 DOM 元素将在函数退出后立即被释放并从内存中删除,因为它是在函数局部作用域内创建的对象。

当你运行示例并点击按钮几次后,你的内存图表可能会是这样

增加频率

没有明显的泄漏,是吗?如果我们执行几百次而不是二十次,或者几千次呢?结果会一样吗?以下代码一遍又一遍地调用赋值以实现此目标

[Exhibit 2 - Memory leaking insert (frequency increased) ]

<html>
<head>
<script type="text/javascript">
    function LeakMemory(){
        for(i = 0; i < 5000; i++){
            var parentDiv = 
               document.createElement("<div onClick='foo()'>");
        }
    }
</script>
</head>
<body>
<input type="button" 
       value="Memory Leaking Insert" onclick="LeakMemory()" />
</body>
</html>

下面是相应的图表

内存使用量的增加表明存在内存泄漏。斜坡末端的水平线(最后20秒)是刷新页面并加载另一个(about:blank)页面后的内存。这表明泄漏是实际的泄漏,而不是伪泄漏。除非关闭浏览器窗口以及任何其他依赖窗口,否则内存不会被回收。

假设您有十几个页面具有类似的泄漏图表。几个小时后,您可能需要重新启动浏览器(甚至您的电脑),因为它停止响应。这个顽皮的浏览器正在吞噬您所有的资源。然而,这是一个极端情况,因为一旦您的内存消耗达到一定水平,Windows 就会增加虚拟内存大小。

这不是一个好情况。如果您的客户/老板在产品展示/培训/演示过程中发现这种情况,他们会非常不高兴。


细心的人可能已经发现第二个例子中没有 `bigString`。这意味着泄漏仅仅是因为内部脚本对象(即匿名脚本 `onclick='foo()'`)。这个脚本没有被正确地释放。这导致每次迭代都发生内存泄漏。为了证明我们的论点,让我们运行一个稍微不同的测试用例

[Exhibit 3 - Leak test without inline script attached]

<html>
<head>
<script type="text/javascript">
    function LeakMemory(){
        for(i = 0; i < 50000; i++){
            var parentDiv = 
            document.createElement("div");
        }
    }
</script>
</head>
<body>
<input type="button" 
       value="Memory Leaking Insert" onclick="LeakMemory()" />
</body>
</html>

以下是相应的内存图

如你所见,我们进行了五万次迭代而不是五千次,但内存使用量仍然平稳(即没有泄漏)。轻微的斜坡是由于我电脑中的其他一些进程造成的。

让我们以更标准和某种程度上非侵入性的方式(这里不是正确的术语,但找不到更好的)更改我们的代码,不带内联脚本并重新测试。

引入闭包

这是另一段代码。我们不是内联附加脚本,而是外部附加它

[Exhibit 4 - Leak test with a closure]

<html>
<head>
<script type="text/javascript">
    function LeakMemory(){
        var parentDiv = document.createElement("div");
                          parentDiv.onclick=function(){
            foo();
        };

        parentDiv.bigString = 
          new Array(1000).join(new Array(2000).join("XXXXX"));
    }
</script>
</head>
<body>
<input type="button" 
       value="Memory Leaking Insert" onclick="LeakMemory()" />
</body>
</html>

如果你不知道什么是闭包,网上有非常好的参考资料,你可以在那里找到它。闭包是非常有用的模式;你应该学习它们并将它们保存在你的知识库中。

这是显示内存泄漏的图表。这与之前的例子有些不同。分配给 `parentDiv.onclick` 的匿名函数是一个闭包,它封闭了 `parentDiv`,这在 JS 世界和 DOM 之间创建了一个循环引用,并导致了一个众所周知的内存泄漏问题

要在上述场景中产生泄漏,我们应该点击按钮,刷新页面,再次点击按钮,再次刷新页面,依此类推。

不进行后续刷新而点击按钮只会生成一次泄漏。因为,每次点击时,`parentDiv` 的 `onclick` 事件都会被重新分配,并且前一个闭包上的循环引用会被打破。因此,每次页面加载时,只有一个闭包由于循环引用而无法被垃圾回收。其余的都被成功清理了。

更多泄漏模式

下面显示的所有模式都在 Justing 的文章中详细描述。我只是为了完整性而回顾它们

[Exhibit 5 - Circular reference because of expando property]

<html>
<head>
<script type="text/javascript">
    var myGlobalObject;

    function SetupLeak(){
        //Here a reference created from the JS World 
        //to the DOM world.
        myGlobalObject=document.getElementById("LeakedDiv");

        //Here DOM refers back to JS World; 
        //hence a circular reference.
        //The memory will leak if not handled properly.
        document.getElementById("LeakedDiv").expandoProperty=
                                               myGlobalObject;
    }
</script>
</head>
<body onload="SetupLeak()">
<div id="LeakedDiv"></div>
</body>
</html>

这里,全局变量 `myGlobalObject` 引用 DOM 元素 `LeakDiv`;同时 `LeakDiv` 通过其 `expandoProperty` 引用全局对象。情况如下所示

上述模式将由于 DOM 节点和 JS 元素之间创建的循环引用而导致内存泄漏。

由于 JScript 垃圾回收器是标记清除 GC,您可能会认为它会处理循环引用。事实上它确实如此。但是,这个循环引用是在 DOM 和 JS 世界之间。DOM 和 JS 有独立的垃圾回收器。因此,它们无法在上述情况下清理内存。

创建循环引用的另一种方法是将 DOM 元素封装为全局对象的属性

[Exhibit 6 - Circular reference using an Encapsulator pattern]

<html>
<head>
<script type="text/javascript">
function Encapsulator(element){
    //Assign our memeber
    this.elementReference = element;

    // Makea circular reference
    element.expandoProperty = this;
}

function SetupLeak() {
    //This leaks
    new Encapsulator(document.getElementById("LeakedDiv"));
}
</script>
</head>
<body onload="SetupLeak()">
<div id="LeakedDiv"></div>
</body>
</html>

效果如下

然而,闭包在 DOM 节点上的最常见用法是事件附加。以下代码将导致内存泄漏

[Exhibit 7 - Adding an event listener as a closure function]

<html>
<head>
<script type="text/javascript">
window.onload=function(){
    // obj will be gc'ed as soon as 
    // it goes out of scope therefore no leak.
    var obj = document.getElementById("element");
    
    // this creates a closure over "element"
    // and will leak if not handled properly.
    obj.onclick=function(evt){
        ... logic ...
    };
};
</script>
</head>
<body>
<div id="element"></div>
</body>
</html>

这是一张描述闭包的图表,它在 DOM 世界和 JS 世界之间创建了循环引用。

上述模式将由于闭包而泄漏。这里闭包的全局变量 `obj` 引用 DOM 元素。同时,DOM 元素持有对整个闭包的引用。这在 DOMJS 世界之间产生了循环引用。这就是泄漏的原因。

当我们移除闭包时,我们看到泄漏已经消失了。

[Exhibit 8- Leak free event registration - No closures were harmed]

<html>
<head>
<script type="text/javascript">
window.onload=function(){
    // obj will be gc'ed as soon as 
    // it goes out of scope therefore no leak.
    var obj = document.getElementById("element");
    obj.onclick=element_click;
};

//HTML DOM object "element" refers to this function
//externally
function element_click(evt){
    ... logic ...
}
</script>
</head>
<body>
<div id="element"></div>
</body>
</html>

这是上面代码片段的图表

此模式不会泄漏,因为一旦函数 `window.onload` 执行完毕,JS 对象 `obj` 将被标记为垃圾回收。因此,JS 端将不再有对 DOM 节点的引用。

最后但并非最不重要的泄漏模式是“跨页泄漏”

[Exhibit 10 - Cross Page Leak]

<html>
<head>
<script type="text/javascript">
function LeakMemory(){
    var hostElement = document.getElementById("hostElement");
    // Do it a lot, look at Task Manager for memory response
    for(i = 0; i < 5000; i++){
        var parentDiv =
        document.createElement("<div onClick='foo()'>");

        var childDiv =
        document.createElement("<div onClick='foo()'>");

        // This will leak a temporary object
        parentDiv.appendChild(childDiv);
        hostElement.appendChild(parentDiv);
        hostElement.removeChild(parentDiv);
        parentDiv.removeChild(childDiv);
        parentDiv = null;
        childDiv = null;
    }
    hostElement = null;
}
</script>
</head>
<body>
<input type="button" 
       value="Memory Leaking Insert" onclick="LeakMemory()" />
<div id="hostElement"></div>
</body>
</html>

既然我们甚至在**示例1**中都观察到内存泄漏,那么这种模式泄漏也就不足为奇了。以下是发生的情况:当我们将`childDiv`附加到`parentDiv`时,会创建一个从`childDiv`到`parentDiv`的临时作用域,这将导致临时脚本对象泄漏。请注意,`document.createElement("

");是一种非标准的事件附加方法。

仅仅采用“最佳实践”是不够的(正如 Justing 在他的文章中也提到的)。还应该尽可能地遵守标准。否则,他可能对几个小时前完美运行(但突然崩溃)的代码出了什么问题一无所知。

无论如何,让我们重新安排插入顺序。以下代码不会泄漏

[Exhibit 11 - DOM insertion re-ordered - no leaks]

<html>
<head>
<script type="text/javascript">
function LeakMemory(){
    var hostElement = document.getElementById("hostElement");
    // Do it a lot, look at Task Manager for memory response
    for(i = 0; i < 5000; i++){
        var parentDiv =
          document.createElement("<div onClick='foo()'>");

        var childDiv =
          document.createElement("<div onClick='foo()'>");

        hostElement.appendChild(parentDiv);
        parentDiv.appendChild(childDiv);
        parentDiv.removeChild(childDiv);
        hostElement.removeChild(parentDiv);

        parentDiv = null;
        childDiv = null;
    }
    hostElement = null;
}
</script>
</head>
<body>
<input type="button" 
       value="Memory Leaking Insert" onclick="LeakMemory()" />
<div id="hostElement"></div>
</body>
</html>

我们应该记住,尽管 IE 是市场领导者,但它并不是世界上唯一的浏览器。编写 IE 特定的非标准代码是一种糟糕的编码实践。反驳也是如此。我的意思是,说“Mozilla 是最好的浏览器,所以我编写 Mozila 特定的代码;我不在乎其他浏览器会发生什么”也是一种同样糟糕的态度。你应该尽可能地扩大你的视野。作为推论,你应该在可能的情况下,最大限度地编写符合标准的代码。

编写“向后兼容”的代码如今已“过时”。“流行”的是编写“向前兼容”(也称为标准兼容)的代码,它将现在和将来、当前和未来的浏览器、这里和月球上运行。

结论

本文的目的是为了表明并非所有泄漏模式都易于发现。您可能很难注意到其中一些,这可能是由于小的记账对象只有在数千次迭代后才变得明显。了解流程的内部结构是成功的不可否认的关键。了解泄漏模式。与其粗暴地调试您的应用程序,不如查看您的代码片段,检查其中是否有与您的武器库中的泄漏模式匹配的部分。

编写防御性代码并处理所有可能的泄漏问题并非过度优化。简单来说,防泄漏不是开发者可以根据心情选择实现或不实现的功能。它是创建稳定、一致和向前兼容代码的**必要条件**。每个 Web 开发人员都应该了解它。抱歉,没有借口。针对 JS 闭包和 DOM 对象之间的泄漏问题,已经提出了几种解决方案。以下是其中一些:

但是,您应该意识到,总会有一些独特的情况,您可能需要自己想出一个解决方案。目前就这些。尽管本文无意成为“避免内存泄漏的最佳实践”,但我希望它指出了一些有趣的问题。

祝您编码愉快!

历史

  • 2005-11-12:文章创建

许可证

本文未附带明确的许可证,但可能在文章正文或下载文件中包含使用条款。如有疑问,请通过下面的讨论板联系作者。作者可能使用的许可证列表可以在这里找到。

© . All rights reserved.