JavaScript内存泄漏

JavaScript垃圾回收

和C#、Java一样JavaScript有自动垃圾回收机制,也就是说执行环境会负责管理代码执行过程中使用的内存,在开发过程中就无需考虑内存分配及无用内存的回收问题了。JavaScript垃圾回收的机制很简单:找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是时时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

变量生命周期

什么是不再使用的变量?不再使用的变量也就是生命周期结束的变量,当然只可能是局部变量,全局变量的生命周期直至浏览器卸载页面才会结束。局部变量只在函数的执行过程中存在,而在这个过程中会为局部变量在栈或堆上分配相应的空间,以存储它们的值,然后再函数中使用这些变量,直至函数结束(闭包中由于内部函数的原因,外部函数并不能算是结束)。一旦函数结束,局部变量就没有存在必要了,可以释放它们占用的内存。貌似很简单的工作,为什么会有很大开销呢?这仅仅是垃圾回收的冰山一角,就像刚刚提到的闭包,貌似函数结束了,其实还没有,垃圾回收器必须知道哪个变量有用,哪个变量没用,对于不再有用的变量打上标记,以备将来回收。用于标记无用的策略有很多,常见的有两种方式

标记清除(mark and sweep)

这是JavaScript最常见的垃圾回收方式,当变量进入执行环境的时候,比如函数中声明一个变量,垃圾回收器将其标记为“进入环境”,当变量离开环境的时候(函数执行结束)将其标记为“离开环境”。至于怎么标记有很多种方式,比如特殊位的反转、维护一个列表等,这些并不重要,重要的是使用什么策略,原则上讲不能够释放进入环境的变量所占的内存,它们随时可能会被调用的到。

垃圾回收器会在运行的时候给存储在内存中的所有变量加上标记,然后去掉环境中的变量以及被环境中变量所引用的变量(闭包),在这些完成之后仍存在标记的就是要删除的变量了,因为环境中的变量已经无法访问到这些变量了,然后垃圾回收器相会这些带有标记的变量机器所占空间。

大部分浏览器都是使用这种方式进行垃圾回收,区别在于如何标记及垃圾回收间隔而已,除了低版本IE,不出所料,又是IE。。。

引用计数(reference counting)

在低版本IE中经常会出现内存泄露,很多时候就是因为其采用引用计数方式进行垃圾回收。引用计数的策略是跟踪记录每个值被使用的次数,当声明了一个变量并将一个引用类型赋值给该变量的时候这个值的引用次数就加1,如果该变量的值变成了另外一个,则这个值得引用次数减1,当这个值的引用次数变为0的时候,说明没有变量在使用,这个值没法被访问了,因此可以将其占用的空间回收,这样垃圾回收器会在运行的时候清理掉引用次数为0的值占用的空间。

看起来也不错的方式,为什么很少有浏览器采用,还会带来内存泄露问题呢?主要是因为这种方式没办法解决循环引用问题。比如对象A有一个属性指向对象B,而对象B也有有一个属性指向对象A,这样相互引用

function test(){
    var a={};
    var b={};
    a.prop=b;
    b.prop=a;
}

这样a和b的引用次数都是2,即使在test()执行完成后,两个对象都已经离开环境,在标记清除的策略下是没有问题的,离开环境的就被清除,但是在引用计数策略下不行,因为这两个对象的引用次数仍然是2,不会变成0,所以其占用空间不会被清理,如果这个函数被多次调用,这样就会不断地有空间不会被回收,造成内存泄露。

在IE中虽然JavaScript对象通过标记清除的方式进行垃圾回收,但BOM与DOM对象却是通过引用计数回收垃圾的,也就是说只要涉及BOM及DOM就会出现循环引用问题。

什么时候触发垃圾回收

垃圾回收器周期性运行,如果分配的内存非常多,那么回收工作也会很艰巨,确定垃圾回收时间间隔就变成了一个值得思考的问题。IE6的垃圾回收是根据内存分配量运行的,当环境中存在256个变量、4096个对象、64k的字符串任意一种情况的时候就会触发垃圾回收器工作,看起来很科学,不用按一段时间就调用一次,有时候会没必要,这样按需调用不是很好吗?但是如果环境中就是有这么多变量等一直存在,现在脚本如此复杂,很正常,那么结果就是垃圾回收器一直在工作,这样浏览器就没法儿玩儿了。

微软在IE7中做了调整,触发条件不再是固定的,而是动态修改的,初始值和IE6相同,如果垃圾回收器回收的内存分配量低于程序占用内存的15%,说明大部分内存不可被回收,设的垃圾回收触发条件过于敏感,这时候把临街条件翻倍,如果回收的内存高于85%,说明大部分内存早就该清理了,这时候把触发条件置回。这样就使垃圾回收工作职能了很多。

同C# 、Java一样我们可以手工调用垃圾回收程序,但是由于其消耗大量资源,而且我们手工调用的不会比浏览器判断的准确,所以不推荐手工调用垃圾回收。

 

什么是内存泄漏

内存泄漏是指分配给应用的内存不能被重新分配,即使在内存已经不被使用的时候。正常情况下,垃圾回收器在DOM元素和event处理器不被引用或访问的时候回收它们。但是,IE的早些版本(IE7和之前)中内存泄漏是很容易出现的,因为内存管理器不能正确理解Javascript生命周期而且在周期被打破(可以通过赋值为null实现)前不会回收内存。

 

内存泄漏的危害

在大型Web应用程序中内存泄漏是一种常见的意外编程错误。内存泄漏会降低Web应用程序的性能,直到浪费的内存超过了系统所能分配的,应用程序将不能使用。作为一Web开发者,开发一个满足功能要求的应用程序只是第一步,性能要求和Web应用程序的成功是同样重要的,更何况它可能会导致应用程序错误或浏览器崩溃。

 

引发内存泄漏的原因

1)循环引用

一个很简单的例子:一个DOM对象被一个Javascript对象引用,与此同时又引用同一个或其它的Javascript对象,这个DOM对象可能会引发内存泄漏。这个DOM对象的引用将不会在脚本停止的时候被垃圾回收器回收。要想破坏循环引用,引用DOM元素的对象或DOM对象的引用需要被赋值为null。

含有DOM对象的循环引用将导致大部分当前主流浏览器内存泄露 这里有两个简单的概念

引用:a.属性=b,a就引用了b

循环引用:简单来说假如a引用了b,b又引用了a,a和b就构成了循环引用。

//a和b循环引用:
var a = new Object;
var b = new Object;
a.r = b;
b.r = a;
//a循环引用自己:
var a = new Object;
a.r = a;

循环引用很常见且大部分情况下是无害的,但当参与循环引用的对象中有DOM对象或者ActiveX对象时,循环引用将导致内存泄露。我们把例子中的任何一个new Object替换成document.getElementById或者document.createElement就会发生内存泄露了。

尽管这看起来非常容易理解,但是因为有closure的参与而使事情变得复杂,有些closure导致的循环引用很难被察觉。下面是一个非常常见的动态绑定事件:

function bindEvent() 
{ 
    var obj  =document.createElement("XXX"); 
    obj.onclick = function(){ 
        //Even if it's a empty function 
    } 
}

这个bindEvent执行时100%会发生内存泄露,Someone 可能会问,哪里出现了循环引用? 关于closure和scope chain参与的循环引用比较复杂,此处暂不深入讨论。有一个简单的判断方式:函数将间接引用所有它能访问的对象。obj.onclick这个函数中 可以访问外部的变量obj 所以他引用了obj,而obj又引用了它,因此这个事件绑定将会造成内存泄露。在IBM的文章中介绍了2种方式解决类似的问题一个是obj = null,另一个是把onclick的函数写在bindEvent外,简单贴下代码:

function bindEvent() 
{ 
    var obj = document.createElement("XXX"); 
    obj.onclick = onclickHandler; 
} 
function onclickHandler(){ 
    //do something 
}
function bindEvent() 
{ 
    var obj = document.createElement("XXX"); 
    obj.onclick = function(){ 
        //Even if it's a empty function 
    } 
    obj=null; 
}

这两个方法都打断了循环引用,可以解决问题,但是似乎对代码表达能力造成了一定破坏,假设有这么一个问题:

function bindEvent() 
{ 
    var obj = document.createElement("XXX"); 
    var var0 = "OOXX"; //Here is a variable 
    obj.onclick = function(){ 
        alert(var0); //I want to visit var2 here! 
    } 
    return obj; //bindEvent must return obj! 
}

假如我把函数写外面去,var0肯定访问不了,假如我把obj弄成null,还怎么return它呢。这并不是空想的需要,这实际上是一个用JS定制DOM控件的简单抽象:创建DOM元素、设置私有属性、绑定事件。所以,我们必须update一下两个方法。首先,方法1,为了让函数 能访问某些变量,我们可以通过一个Builder函数来订制onclick的外部闭包:

function bindEvent() 
{ 
    var obj = document.createElement("XXX"); 
    var var0 = "OOXX"; //Here is a variable 
    obj.onclick = onclickBuilder(var0); //想访问谁就把谁传进去!! 
    return obj; //bindEvent must return obj! 
} 
function onclickBuilder(var0) //这里跟上面对应上就行了 最好参数名字也对应上 
{ 
    return function(){ 
        alert(var0); 
    } 
}

第二个办法,让obj = null在return 之后执行

function bindEvent() 
{ 
    try{ 
        var obj = document.createElement("XXX"); 
        var var0 = "OOXX"; //Here is a variable 
        obj.onclick = function(){ 
            alert(var0); //I want to visit var2 here! 
        } 
        return obj; //bindEvent must return obj! 
    } finally { 
        obj = null; 
    } 
}

2)Javascript闭包

因为Javascript范围的限制,许多实现依赖Javascript闭包。闭包可以导致内存泄漏是因为内部方法保持一个对外部方法变量的引用,所以尽管方法返回了内部方法还可以继续访问在外部方法中定义的私有变量。对Javascript程序员来说最好的做法是在页面重载前断开所有的事件处理器。

3)DOM插入顺序

当2个不同范围的 DOM 对象连添加到一起的时候一个临时的对象会被创建。这个DOM对象改变范围到document时,那个临时对象就没用了。也就是说, DOM 对象应该按照从当前页面存在的最上面的 DOM 元素开始往下直到剩下的 DOM 元素的顺序添加,这样它们就总是有同样的范围,不会产生临时对象。

这是IE系列的特有问题,简单的来说就是在向不在DOM树上的DOM元素appendChild,可能会发生内存泄露(只是可能,具体原因不明,似乎十分复杂,下面例子中去掉onClick也可以避免泄露)。所以appendChild的顺序可能影响内存泄露,来自微软的例子

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");  
        var childDiv = 
            document.createElement("div"); 
        // 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"); 
        var childDiv = 
            document.createElement("div"); 
        // 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; 
}

而在IE7中,貌似为了改善内存泄露,IE7采用了极端的解决方案:离开页面时回收所有DOM树上的元素,其它一概不管。但是这不仅没起到任何作用,反而 使问题变得更加复杂。对这类问题,除了自觉一点绕开这些恶心的东西,多用innerHTML这种无用的建议之外。我想可以通过覆盖 document.createElement来略为改善:

首先我们定义一个看不见的元素当作垃圾箱,所有新创建的元素都扔进垃圾箱里,这样保证了所有DOM元素都在DOM树上,IE7就可以正确回收了,另一方面也能避免所谓的”appendChild顺序不对导致内存泄露”。

function MemoryFix(){ 
    var garbageBox = document.createElement("div"); 
    garbageBox.style.display = "none"; 
    document.body.appendChild(garbageBox); 
    var createElement = document.createElement; 
    document.createElement = function(){ 
        var obj = Function.prototype.apply.apply(createElement,
            [document,arguments]); 
        garbageBox.appendChild(obj); 
        return obj; 
    } 
}

4)自动类型装箱转换

别不相信,下面的代码在ie系列中会导致内存泄露

var s = "lalala";
alert(s.length);

s本身是一个string而非object,它没有length属性,所以当访问length时,JS引擎会自动创建一个临时String对象封装s,而这个对象一定会泄露。这个bug匪夷所思,所幸解决起来相当容易,记得所有值类型做.运算之前先显式转换一下:

var s = "lalala";
alert(new String(s).length);

5)意外的全局变量

js中如果不用 var 声明变量,该变量将被视为 window 对象(全局对象)的属性,也就是全局变量。

function foo(arg) {
    bar = "this is a hidden global variable";
}
 
// 上面的函数等价于
function foo(arg) {
    window.bar = "this is an explicit global variable";
}

所以,你调用完了函数以后,变量仍然存在,导致泄漏.

如果不注意 this 的话,还可能会这么漏:

function foo() {
    this.variable = "potential accidental global";
}
 
// 没有对象调用foo, 也没有给它绑定this, 所以this是window
foo();

可以通过加上 'use strict' 启用严格模式来避免这类问题, 严格模式会组织你创建意外的全局变量。

 

posted @ 2017-10-26 19:12  风吹麦浪打  阅读(123)  评论(0)    收藏  举报