理解和应对IE内存溢出类型

过去,内存溢出并没有对前端开发工程师造成很大的麻烦。页面都相对简单,并且在一个站点内,页面之间的相互跳转是一个主要的解决溢出内存的方式。就算有溢出,也是小到可以忽略。

但是新的web应用达到了更高的标准。我们可能会在一个页面上停留数个小时,而不会跳转到其他页面并且动态的从web服务器获取数据(AJAX)。Web的实现语言被使用到了极致,复杂的复合性事件机制,面向对象的JS,以及整体用闭包实现的应用。因此,内存溢出,特别是过去由于页面的跳转而被忽略的内存泄漏,就变得尤为需要重视了。

所幸的是,内存溢出点是很容易被定位的。绝大多数的内存溢出类型是指需要一点点额外的工作就可以解决的。就算一些页面可能还是一些小的内存溢出的牺牲品,但是绝大多数严重的溢出是能被方便的移除的。

溢出类型

接下来讨论内存溢出的类型,并且给出对应类型的典型代码。最典型的例子就是使用闭包,而另一个典型就是在事件绑定中使用闭包。如果你对事件绑定很熟悉的话,你就能轻易的解决大多数溢出问题。

现在,我们看看这些类型:

 

  1. 循环引用——对IE的DOM结构和其他的JS解释器而言,如果产生了相互引用,内存就可能泄漏。最明显的溢出类型。
  2. 闭包——闭包是造成了当前web应用内存溢出的最大的类型,同时它也是循环引用的特殊形式。
  3. 跨页溢出——当你在page和page之间切换时,浏览器内部的记录对象造成的非常小的泄漏。
  4. 伪溢出——这并非真正的溢出。如果难以理解的话完全可以忽略。如果以后它真的造成了麻烦,我们会去研究造成这种溢出的脚本对象重写。

 

循环引用

循环引用几乎是所有溢出的根本原因。通常,脚本解释器会通过他们的内存释放机制解决循环引用,但是有些未知的因素会导致不能正确的释放内存。对于IE而言,假如DOM的节点被脚本引用,则有可能溢出内存。因此,基本的原则是:

Figure 1 Basic Circular Reference Pattern

这种类型的溢出是由于DOM的引用计数所造成的。脚本解释器会对DOM节点分配引用的内存,并且会等到对该节点的应用被清除时才释放引用该DOM节点的指针。在IE中,有两种引用:一种是脚本解释器的作用域,另一种是DOM节点的运行时添加的属性。尽管第一种引用会在脚本解释器进程结束时被释放,但是对DOM节点的引用却由于在等待解释器去释放而再也得不到释放。你或许会觉得去解决这种情景的溢出是容易的,但事实上,这种被基本的溢出类型只是冰山一角,或许你会遇到30个object的相互引用链,那就很难去解决了。

我们可以通过使用一个全局变量和一个DOM元素来展示这种溢出类型:

<html>
	<head>
		<script language="JScript">
		var myGlobalObject;
		
		function SetupLeak()
		{
			// First set up the script scope to element reference
			myGlobalObject
			 = document.getElementById("LeakedDiv");
			
			// Next set up the element to script scope reference
			document.getElementById("LeakedDiv").expandoProperty
			 = myGlobalObject;
		}
		
		
		function BreakLeak()
		{
			document.getElementById("LeakedDiv").expandoProperty = null;
		}
		</script>
	</head>
	<body onload="SetupLeak()" onunload="BreakLeak()">
		<div id="LeakedDiv"></div>
	</body>
</html>

想要解决这种溢出类型,你可以使用null的赋值语句。在document unload行为之前赋值为null,解释器就知道在这个节点和解释器内部的object之间再也没有引用关系,就能正确的释放引用,并且释放DOM节点。这样,作为一个前端工程师,你就比JS解释器更加理解你的对象之间的关系。

尽管这是一个基本的溢出类型,有可能在复杂的情况下,找出造成溢出的代码还是困难的。比如,面向对象的JS代码扩展DOM节点的通常做法是,把这些DOM节点封装在JS object的内部。在构造函数执行的过程中,可以把相关的DOM节点作为参数传入,在新实例化的这个object里存储该DOM节点的引用,并且同时在该DOM节点上关联这个新实例化的object。这样的应用模型里,就能保证object的这个实力始终能够访问它所需要的所有节点。问题是,这是一个典型的循环引用,但往往不被注意。解决这种类型的的内存溢出是要复杂一些的,你也可以使用前述的null赋值法。

<html>
	<head>
	    <script language="JScript">
	
	    function Encapsulator(element)
	    {
	        // Set up our element
	        this.elementReference = element;
	
	        // Make our circular reference
	        element.expandoProperty = this;
	    }
	
	    function SetupLeak()
	    {
	        // The leak happens all at once
	        new Encapsulator(document.getElementById("LeakedDiv"));
	    }
	
	    function BreakLeak()
	    {
	        document.getElementById("LeakedDiv").expandoProperty =
	            null;
	    }
	    </script>
	</head>
	<body onload="SetupLeak()" onunload="BreakLeak()">
	    <div id="LeakedDiv"></div>
	</body>
</html>

对这种类型溢出的稍微复杂一些的方法涉及到使用注册模式,来记录那些需要解绑定的DOM节点或者属性,从而可以在document unload之前清理内存,但是这样通常会导致额外的内存溢出模式产生而并非真正的解决了问题。

闭包

闭包,由于它们通常造成了隐性的循环引用而导致了内存溢出。在闭包函数的执行过程中,父函数的参数和闭包中的局部变量,及其引用会跟全局作用域隔离,直到闭包函数执行完。因此,闭包成为了惯用的编码习惯。(这里有一段我认为的不知所云的废话没有翻译)。

Figure 2 Circular References with Closures

通常在循环引用中,有两个object会互相保持引用,但闭包是不同的,闭包是通过从父作用域中引入变量而产生引用行为。通常,一个function的局部变量和参数的存在周期(life time)为该function的存在周期。对于闭包函数而言,这些局部变量跟参数,只要闭包函数存在,就都也存在。同时,由于闭包函数的存在周期不受父函数的存在周期限制,所以其中的局部变量和参数也会同样如此。在上图例中,Parameter1本应随着函数的执行结束而被释放内存,但是因为闭包的存在,对该参数实际产生了第二次引用,并且只要早该闭包函数执行完之后,第二次引用才会被释放。如果碰巧该闭包函数跟某个事件绑定,那么就必须要解绑定。如果把该闭包函数赋值给某个扩展属性,那么就必须给该扩展属性负null值。

闭包函数每被调用一次就会产生一个闭包,那么就会每次都给传入的参数产生引用。也就很容易造成内存溢出。下面的例子展示了闭包造成内存溢出的情况:

<html>
    <head>
        <script language="JScript">

        function AttachEvents(element)
        {
            // This structure causes element to ref ClickEventHandler
            element.attachEvent("onclick", ClickEventHandler);

            function ClickEventHandler()
            {
                // This closure refs element
            }
        }

        function SetupLeak()
        {
            // The leak happens all at once
            AttachEvents(document.getElementById("LeakedDiv"));
        }

        function BreakLeak()
        {
        }
        </script>
    </head\>

    <body onload="SetupLeak()" onunload="BreakLeak()">
        <div id="LeakedDiv"></div>
    </body>
</html>

闭包造成的内存泄漏是不能像通常的循环引用那样简单解决的。“闭包”可以被看成是在function scope中的一个临时对象。function存在之后,就失去了对对闭包本身的引用。既然这样,怎样才能解除事件绑定呢?Scott Isaacs在他MSN的空间里展示了一个非常有趣的方法。他使用了另外一个闭包来和window 的onUnload事件绑定,同时,由于该闭包拥有同样的作用域,因而可以解除事件绑定,解除自己的事件绑定,并且清理内存。如下例所示:我们还可以把这个用来解决问题的闭包存储在一个扩展属性里,解除绑定,然后再给这个扩展属性赋null值。

<html>
    <head>
        <script language="JScript">
        function AttachEvents(element)
        {
            // In order to remove this we need to put
            // it somewhere. Creates another ref
            element.expandoClick = ClickEventHandler;

            // This structure causes element to ref ClickEventHandler
            element.attachEvent("onclick", element.expandoClick);

            function ClickEventHandler()
            {
                // This closure refs element
            }
        }
        function SetupLeak()
        {
            // The leak happens all at once
            AttachEvents(document.getElementById("LeakedDiv"));
        }
        function BreakLeak()
        {
            document.getElementById("LeakedDiv").detachEvent("onclick",
                document.getElementById("LeakedDiv").expandoClick);
            document.getElementById("LeakedDiv").expandoClick = null;
        }
        </script>
    </head>
    <body onload="SetupLeak()" onunload="BreakLeak()">
        <div id="LeakedDiv"></div>
    </body>
</html>

教科书中,我们通常不推荐使用闭包除非万不得已。在这个例子中,我们其实可以考虑到并没有必要来用闭包作为事件处理函数,而是可以把闭包移到全局作用域里面,当闭包成为一个函数的时候,就再也不会从父函数的作用域中引用参数或者局部变量,也就无需担心基于闭包的的循环引用。很多的问题都可以通过架构一个不依赖于无谓闭包的应用结构。

最后,Eric Lippert,脚本解释器的开发工程师之一,写过很多关于闭包的帖子。他的最终建议也是只在真正必要时使用闭包。然后他的文章中没有提到过关于闭包类型的解决办法,希望我们已经展示一些可以带前端工程师入门的关于闭包内存溢出的信息。

跨页溢出

由DOM节点的插入造成的内存溢出通常是由于中间对象(intermedia object)的未能被及时正确清除所造成的。而这正是动态创建节点然后把这些节点更新到DOM里时所会发生的。最简单的情形是这样的:把两个动态创建的DOM节点el1和el2连接起来成为el3时,产生了一个临时的作用域,在然后的把el3插入到DOM树时,就继承了DOM树的作用域,并且就有一个临时object溢出了内存。下图展示了两种把动态创建的DOM节点添加到document时的方法。第一种方式中,先依次把所有需要添加的DOM节点组成一个DOM子树,最后把这个DOM子树添加到documet树中。这种方式中产生的中间对象通常就会造成泄漏。第二种方式中我们直接依次把动态创建的DOM子节点添加到document树上。这样我们就从来没有产生过临时的作用域,这样就会避免潜在的内存溢出。

Figure 3 DOM Insertion Order Leak Model

下面我们来看一个绝大多数内存溢出算法都没法检测的溢出类型。因为任何一个可见的DOM节点都没有泄漏,而且我们泄漏的那些object是如此之小从而我们往往注意不到。为了让我们的例子形象生动,这个动态创建的DOM节点在行内function中包含了一个脚本指针。这样就会使得我们在把动态创建的节点添加在一起的时候,泄漏一个内部的脚本object。因为这个溢出是很小的,我们必须要运行很多次才会感觉到。事实上,被溢出的object只有几个byte。通过运行这个例子,并且跳转到一个空页面,我们可以通过任务管理器看到内存占用上的差异。我们使用上图中的第一种方式时,我们的内存使用量会稍微多一些。这是一个跨页溢出,并且内存直到关闭整个IE进程才会被清除。如果使用第二种方式,我们就会发现并没有产生跨页溢出。

<html>
	<head>
	    <script language="JScript">
	
	    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;
	    }


	    function CleanMemory()
	    {
	        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()'>");
	
	            // Changing the order is important, this won't leak
	            hostElement.appendChild(parentDiv);
	            parentDiv.appendChild(childDiv);
	            hostElement.removeChild(parentDiv);
	            parentDiv.removeChild(childDiv);
	            parentDiv = null;
	            childDiv = null;
	        }
	        hostElement = null;
	    }
	    </script>
	</head>
	<body>
	    <button onclick="LeakMemory()">Memory Leaking Insert</button>
	    <button onclick="CleanMemory()">Clean Insert</button>
	    <div id="hostElement"></div>
	</body>
</html>

这种溢出是应当被清除的,因为我们的这个应用本身占用的资源是很少的。理解这种溢出的关键在于我们动态创建的DOM节点是带有行内function的。假如我们使用第一种添加DOM节点的方式添加不带行内function的DOM节点时,事实上不会产生泄漏。这就给了我们一个提示,或许我们在需要动态创建大量DOM子节点或者复杂的DOM子树时,可以先创建一个不带行内function的子树,把子树添加到document树中,然后再把脚本function添加到子树中。这样也是内存安全的构建动态DOM节点的方法。同时也要注意不要再犯闭包和循环引用的错误以免造成额外的麻烦。

我非常想指出的这种情况是因为,并非所有的内存泄漏都是很容易排查的。可能需要几千次的迭代才会被发现,并且也可能是很细微的细节,就像是添加DOM节点的顺序导致了内存泄漏。(后面还有一段废话没翻译)

伪溢出

有的时候,有些API的实际行为或者所被期待的行为往往会导致对内存泄漏的误诊断。伪溢出通常是在当前页面上进心动态脚本操作,并且从当前页面跳转到另外一个空白页面之后就很难被发现。这也是为什么我们可以认为这不是一个跨页溢出。我们来用一个text的重写例子来展示伪溢出。

就跟DOM的插入顺序那个情况一样,这种情况也是由于产生了临时对象导致了内存的“溢出”。通过反复重写一个script element里面的script text,就会逐渐溢出被重写的text里面的一些script object。

<html>
	<head>
	    <script language="JScript">
	
	    function LeakMemory()
	    {
	        // Do it a lot, look at Task Manager for memory response
	
	        for(i = 0; i < 5000; i++)
	        {
	            hostElement.text = "function foo() { }";
	        }
	    }
	    </script>
	</head>
	<body>
	    <button onclick="LeakMemory()">Memory Leaking Insert</button>
	    <script id="hostElement">function foo() { }</script>
	</body>
</html>

如果我们运行上述代码并用任务管理器加以观察,当从这个页面跳转到一个空白页面的时候,我们并不能发现有内存溢出。这种内存溢出完全是页内的,只要发生了跳转,溢出的内存就被收回。我们预期在重写script之后,老的script对象应该已经被释放了,但事实上没有。但这也是有意义的,因为这段script有可能已经被某个事件绑定并且有明显的引用计数。你也能看到,这是一个伪溢出,从表面上看多占用的内存是很难看的,但是确实也是有必要的。

总结

每个前端开发工程师都会建立一个自己的那些可能会内存溢出的代码示例列表。这是非常好的,也是我们现在的web应用很少有内存泄漏的原因。如果我们能够从根源上找到内存泄漏的原因而非停留在那些泄漏的代码上,我们就能更好的应对内存溢出。只要我们能够在代码的设计阶段就能够考虑到并避免这些可能造成内存溢出的代码。平时故意写一些造成溢出的代码,并想办法解决他们。(从这往后都是废话,不翻译了。)

 

 

转载自:http://www.likejs.com/demo/IE%20Memory%20Leak.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值