JavaScript垃圾回收机制与内存泄漏深度解析

作者:半夏时间:2026-10-27 16:56:05浏览:540

本文深入解析JavaScript自动垃圾收集机制,对比标记清除与引用计数两种核心策略,重点剖析循环引用导致的内存泄漏问题及IE浏览器的特殊表现。文章还探讨了垃圾收集器的触发时机、性能瓶颈及通过解除引用管理内存的最佳实践,帮助开发者理解底层原理并优化Web应用性能。

JavaScript垃圾回收机制概述

JavaScript内置了自动垃圾收集机制,执行环境会自动管理代码运行期间使用的内存。这与C或C++等语言不同,后者的开发者必须手动跟踪内存使用情况,这往往是许多错误的根源。在编写JavaScript程序时,开发人员无需关注内存分配和无用内存回收的问题,这些工作已完全自动化。垃圾收集机制的核心原理是识别并释放不再使用的变量所占用的内存。为此,垃圾收集器会按照固定的时间间隔或在代码执行预设的收集点,周期性执行这一清理操作。

接下来分析函数中局部变量的正常生命周期。局部变量仅在函数执行期间存在。在此过程中,系统会在栈(或堆)内存中为局部变量分配空间以存储其值。函数执行期间使用这些变量,直到函数执行结束。此时,局部变量不再具有存在意义,因此可以释放其内存供未来使用。在这种情况下,判断变量是否仍有必要存在相对容易;但并非所有情况都能如此轻易得出结论。垃圾收集器必须跟踪哪些变量有用、哪些无用,并对不再有用的变量进行标记,以便将来回收其占用的内存。用于标识无用变量的策略可能因实现而异,但在浏览器中的具体实现通常有两种主要策略。

标记清除算法详解

标记清除

JavaScript中最常用的垃圾收集方式是标记清除(mark-and-sweep)。当变量进入执行环境(例如在函数中声明一个变量)时,该变量会被标记为“进入环境”。从逻辑上讲,只要执行流进入相应环境,就可能使用这些变量,因此永远不能释放进入环境的变量所占内存。而当变量离开环境时,则将其标记为“离开环境”。

标记变量的具体方式可以灵活选择。例如,可以通过翻转某个特殊位来记录变量何时进入环境,或者维护一个“进入环境的变量列表”和一个“离开环境的变量列表”来跟踪变量状态的变化。归根结底,如何标记变量并不重要,关键在于采取何种回收策略。

垃圾收集器运行时,会给存储在内存中的所有变量加上标记(当然,可以使用任何标记方式)。随后,它会去掉环境中变量以及被环境中的变量引用的变量的标记。在此之后,仍带有标记的变量将被视为准备删除的变量,因为环境中的变量已经无法访问到它们。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

截至2008年,IE、Firefox、Opera、Chrome和Safari的JavaScript实现均使用标记清除式的垃圾收集策略(或类似策略),尽管各浏览器垃圾收集的时间间隔存在差异。

引用计数策略与循环引用

引用计数

另一种较少见的垃圾收集策略称为引用计数(reference counting)。引用计数的核心思想是跟踪记录每个值被引用的次数。当声明一个变量并将引用类型的值赋给该变量时,该值的引用次数即为1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对该值引用的变量获得了另一个值,则该值的引用次数减1。当这个值的引用次数变为0时,说明已无法访问该值,因此可以将其占用的内存空间回收。这样,当垃圾收集器下次运行时,它会释放那些引用次数为零的值所占用的内存。

Netscape Navigator 3.0是最早采用引用计数策略的浏览器,但很快它就遇到了一个严重问题:循环引用。循环引用指的是对象A中包含一个指向对象B的引用,而对象B中也包含一个指向对象A的引用。

代码如下:

function () {
var objectA = new Object();
var objectB = new Object();
   
objectA.someOtherObject = objectB;
objectB.anotherObject = objectA;
}

在此示例中,objectA和objectB通过各自的属性相互引用,即这两个对象的引用次数均为2。在采用标记清除策略的实现中,由于函数执行后这两个对象都离开了作用域,因此这种相互引用并非问题。但在采用引用计数策略的实现中,函数执行完毕后,objectA和objectB仍将继续存在,导致它们的引用次数永远不会归零。如果该函数被重复调用,将导致大量内存无法回收。因此,Netscape在Navigator 4.0中放弃了引用计数方式,转而采用标记清除来实现其垃圾回收机制。然而,引用计数带来的麻烦并未就此终结。

我们知道,IE中有一部分对象并非原生JavaScript对象。例如,BOM和DOM中的对象是使用C++以COM(Component Object Model,组件对象模型)对象的形式实现的,而COM对象的垃圾收集机制采用的就是引用计数策略。因此,即使IE的JavaScript引擎使用标记清除策略来实现,JavaScript访问的COM对象依然基于引用计数策略。换句话说,只要IE中涉及COM对象,就会存在循环引用问题。

以下简单示例展示了使用COM对象导致的循环引用问题:

代码如下:

var element = document.getElementById("some_element");
var myObject = new Object();
myObject.element = element;
element.somObject = myObject;

此示例在DOM元素(element)与原生JavaScript对象(myObject)之间创建了循环引用。其中,变量myObject有一个名为element的属性指向element对象;而变量element也有一个属性名为someObject回指myObject。由于存在这个循环引用,即使将示例中的DOM从页面中移除,它也永远不会被回收。

为避免此类循环引用问题,最好在不使用时手工断开原生JavaScript对象与DOM元素之间的连接。例如,可以使用以下代码消除前例创建的循环引用:

代码如下:

myObject.element = null;
element.somObject = null;

将变量设置为null,意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

垃圾收集器的性能问题与优化

性能问题

垃圾收集器周期性运行,如果为变量分配的内存数量相当可观,那么回收工作量也将相当大。在此情况下,确定垃圾收集的时间间隔是一个至关重要的问题。谈及垃圾收集器多久运行一次,不禁让人联想到IE因此声名狼藉的性能问题。IE的垃圾收集器是根据内存分配量运行的,具体来说,当达到256个变量、4096个对象(或数组)字面量和数组元素(slot)或者64KB的字符串中的任何一个临界值时,垃圾收集器就会运行。这种实现的问题在于,如果一个脚本中包含如此多的变量,那么该脚本在其生命周期内很可能一直维持这么多变量。这样一来,垃圾收集器就可能不得不频繁运行。结果,由此引发的严重性能问题促使IE7重写了其垃圾收集例程。

随着IE7的发布,其JavaScript引擎的垃圾收集例程改变了工作方式:触发垃圾收集的变量分配、字面量和(或)数组元素的临界值被调整为动态修正。IE7中的各项临界值在初始化时与IE6相等。如果例程回收的内存分配量低于15%,则变量、字面量和(或)数组元素的临界值就会加倍。如果例程回收了85%的内存分配量,则将各种临界重置为默认值。这一看似简单的调整,极大地提升了IE在运行包含大量JavaScript的页面时的性能。

事实上,在某些浏览器中可以触发垃圾收集过程,但我们不建议读者这样做。在IE中,调用window.CollectGarbage()方法可立即启动垃圾收集;在Opera7及更高版本中,调用window.opera.collect()也会启动垃圾收集例程。

内存管理与解除引用

管理内存

使用具备垃圾收集机制的语言编写程序时,开发人员通常无需操心内存管理问题。然而,JavaScript在进行内存管理及垃圾收集时面临的问题仍有些与众不同。其中最重要的一个问题,就是分配给Web浏览器的可使用内存数量通常要比分配给桌面应用程序的少。这样做的主要目的是出于安全考虑,旨在防止运行JavaScript的网页耗尽全部系统内存而导致系统崩溃。内存限制问题不仅会影响给变量分配内存,同时还会影响调用栈以及在一个线程中能够同时执行的语句数量。

因此,确保占用最少内存可以让页面获得更好的性能。最佳做法是通过将其值设置为null来释放其引用——这个做法叫做解除引用(dereferencing)。这一做法主要用于大多数全局变量和全局对象的属性。局部变量会在它们执行环境结束时自动被解除引用,如下面这个例子所示:

代码如下:

function createPerson (name) {
var localPerson = new Object();
localPerson.name = name;
return localPerson;
};
var gllbalPerson = createPerson("Nicholas");
// 手工解除globalPerson的引用
globalPerson = null;

在此示例中,变量globalPerson取得了createPerson()函数返回的值。在createPerson()函数内部,我们创建了一个对象并将其赋给了局部变量localPerson,然后又为该对象添加了一个名为name的属性。最后,当调用这个函数时,localPerson以函数的形式返回并赋给全局变量globalPerson。由于localPerson在createPerson()函数执行完毕后就离开了其执行环境,因此无需我们显式地去为他解除引用。但是对于全局变量globalPerson而言,则需要我们在不使用它的时候手工为它解除引用,这也正是上面例子中最后一行代码的目的。

不过,解除一个值的引用并不意味着自动回收该值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器下次运行时将其回收。

闭包导致的内存泄漏问题

内存泄漏

由于IE对JScript对象和COM对象使用不同的垃圾收集例程,因此闭包在IE中会导致一些特殊问题。具体来说,如果闭包的作用域链中保存着一个HTML元素,那么就意味着该元素无法被销毁。来看下面的例子:

代码如下:

function assignHandler () {
var element = document.getElementById("someElement");
element.onclick = function () {
alert(element.id);
    };
};

以上代码创建了一个作为element元素事件处理程序的闭包,而这个闭包则创建了一个循环引用。由于匿名函数保存了一个对assignHandler()的活动对象的引用,因此就会导致无法减少element的引用数。只要匿名函数存在,element的引用数至少也是1,因此它所占用的内存就永远不会被回收。不过,这个问题可以通过稍微改写一下代码来解决,如下所示:


代码如下:

function assignHandler () {
var element = document.getElementById("someElement");
var id = element.id;
   
element.onclick = function () {
alert(id);
    };
element = null;
};

在上面代码中,通过把element.id的一个副本保存在一个变量中,并且在闭包中引用该变量消除了循环引用。但仅仅做到这一步,还是不能解决内存泄漏的问题。必须要记住:闭包会引用包含函数活动的整个活动对象,而其中包含element。即使闭包不直接引用element,包含函数的活动对象中也仍然会保存一个引用。因此,有必要把element变量设置为null。这样就能够解除对DOM对象的引用,顺利地减少其引用数,确保正常回收其占用的内存。

说明

1、如果你在另一个window中keep了该window中的object的reference,即使关闭该window,内存也没有释放;

2、更糟糕的是,如果你keep的是一个DOM object的reference,关闭该object所在window,IE会crash,报内存错误(或者要求,重新启动)。

作者最新文章