JavaScript垃圾回收机制与内存泄漏详解

作者:小括号

时间:2026-10-26

浏览:550

本文详解JavaScript的垃圾回收机制,包括标记清除与引用计数原理及循环引用问题。深入分析内存泄漏的常见场景,如全局变量、闭包、定时器和事件监听器,并提供使用Chrome DevTools进行内存排查的实战技巧。

文章目录

1.垃圾回收机制

《JavaScript权威指南(第四版)》指出:字符串、对象和数组没有固定大小,因此只有当它们的大小已知时,才能进行动态存储分配。JavaScript程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储该实体。只要这样动态地分配了内存,最终都要释放这些内存以便它们能够被再用,否则,JavaScript的解释器将会消耗完系统中所有可用的内存,造成系统崩溃。

这解释了JavaScript需要垃圾回收的原因。与C/C++不同,JavaScript内置了自动垃圾回收机制来管理内存。

JavaScript的垃圾回收机制旨在找出不再使用的变量并释放其内存。由于该过程开销较大,垃圾回收器并非实时运行,而是按固定时间间隔周期性执行。

var a = "掘金";
var b = "淘金";
var a = b;

代码运行后,字符串“掘金”失去引用(原由变量a引用)。系统检测到这一事实后,会释放该字符串的存储空间,以便重新利用。

那么,垃圾回收具体是如何进行的呢?

主要有两种方法:标记清除和引用计数。其中,引用计数较少使用,而标记清除更为常见。

1.1 标记清除

这是JavaScript中最常用的垃圾回收方式。当变量进入执行环境时,会被标记为“进入环境”。从逻辑上讲,只要执行流可能用到这些变量,就不能释放其内存。当变量离开环境时,则标记为“离开环境”。

垃圾收集器运行时会为内存中所有变量加上标记,然后去掉环境中的变量及其引用。剩余被标记的变量被视为待删除变量,因为环境已无法访问它们。最后,垃圾收集器销毁这些值并回收内存空间。

通过以下示例解释该方法:

var m = 0,n = 19 // 把 m,n,add() 标记为进入环境。
add(m, n) // 把 a, b, c标记为进入环境。
console.log(n) // a,b,c标记为离开环境,等待垃圾回收。
function add(a, b) {
  a++
  var c = a + b
  return c
} 

1.2 引用计数

“引用计数”指语言引擎维护一张“引用表”,记录内存中所有资源(通常是值)的引用次数。若某值的引用次数为0,表示不再使用,可释放其内存。

若某值不再需要但引用数不为0,垃圾回收机制无法释放该内存,从而导致内存泄漏。

var arr = [1, 2, 3, 4];
arr = [2, 4, 5]
console.log('浪里行舟'); 

上述代码中,数组[1, 2, 3, 4]占用内存。变量arr是唯一引用,引用次数为1。即使后续代码未使用arr,它仍持续占用内存。关于如何释放内存,下文将介绍。

第三行代码中,数组[1, 2, 3, 4]的引用变量arr获得了新值,导致原数组引用次数减1变为0。此时无法再访问该值,因此可回收其占用的内存空间。

然而,引用计数存在最大问题:循环引用。

function func() {
    let obj1 = {};
    let obj2 = {};
    obj1.a = obj2; // obj1 引用 obj2
    obj2.a = obj1; // obj2 引用 obj1
} 

当函数func执行结束后,返回值为undefined,整个函数及内部变量应被回收。但根据引用计数,obj1和obj2的引用次数均不为0,因此不会被回收。

解决循环引用的最佳方法是在不再使用时手动将其设为空。上述示例可如此处理:

obj1 = null;
obj2 = null; 

2.什么是内存泄漏

2.1 JavaScript内存分配和回收的关键词:GC根、作用域

GC根通常指全局且不会被垃圾回收的对象,如window、document或页面上存在的DOM元素。JavaScript垃圾回收算法判断某块对象内存是否是GC根可达(存在由GC根到该对象的引用路径),若不可达,则标记回收。

作用域:在JavaScript的作用域内,我们可以新建对象来分配内存。例如调用函数时创建作用域,若创建的是局部对象,当作用域运行结束后,所有局部对象(GC根无法触及)都会被标记回收。能引起作用域分配的有函数调用、with语句和全局作用域。

浏览器将对象保存在堆内存中,通过索引链访问。GC(Garbage Collector)是JavaScript引擎的后台进程,可鉴别处于无用状态的对象并移除它们,释放占用的内存。

若本该被GC回收的变量被其他对象索引,且可通过root访问,则意味着内存中存在冗余占用,导致应用性能降级,即发生内存泄漏。

总结:内存泄漏简单来说就是不再用到的内存没有得到及时释放。

3.常见的几种内存泄漏的方式

3.1 未被注意的全局变量

全局变量可被root访问,不会被GC回收。非严格模式下的局部变量可能意外变成全局变量,导致内存泄漏。

  • 给未声明的变量赋值
  • this指向全局对象
function createGlobalVariables() {
  leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
  this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

如何避免?使用严格模式。

3.2 闭包

闭包函数执行完成后,作用域中的变量不会被回收,可能导致内存泄漏:

function outer() {
  const potentiallyHugeArray = [];
  return function inner() {
    potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
    console.log('Hello');
  };
};
const sayHello = outer(); // contains definition of the function inner
function repeat(fn, num) {
  for (let i = 0; i < num; i++){
    fn();
  }
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray 
// now imagine repeat(sayHello, 100000)

3.3 定时器

使用setTimeout或setInterval:

function setCallback() {
  const data = {
    counter: 0,
    hugeString: new Array(100000).join('x')
  };
  return function cb() {
    data.counter++; // data object is now part of the callback's scope
    console.log(data.counter);
  }
}
setInterval(setCallback(), 1000); // how do we stop it?

只有当定时器被清理时,其回调函数内部的data才会从内存中清理,否则在应用退出前一直保留。

如何避免?

function setCallback() {
  // 'unpacking' the data object
  let counter = 0;
  const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
  return function cb() {
    counter++; // only counter is part of the callback's scope
    console.log(counter);
  }
}
const timerId = setInterval(setCallback(), 1000); // saving the interval ID
// doing something ...
clearInterval(timerId); // stopping the timer i.e. if button pressed

将定时器赋值给timerId,使用clearInterval(timerId)手动清理。

3.4Event listeners

addEventListener也会一直保留在内存中无法回收,直到使用removeEventListener,或添加监听事件的DOM被移除。

const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
  doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});

如何避免?

function listener() {
  doSomething(hugeString);
}
document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here
// 或者
document.addEventListener('keyup', function listener() {
  doSomething(hugeString);
}, {once: true}); // listener will be removed after running once

4.使用chrome devtools的排查方法

下面通过几个案例展示在chrome devtools中如何查看内存泄漏。

4.1 用全局变量缓存数据

将全局变量作为缓存数据的方式,将后续要用到的数据挂载到全局变量上,用完不手动释放内存(因全局变量引用的对象,垃圾回收机制不会自动回收),全局变量逐渐积累无用对象,导致内存泄漏。

   var x = [];
    function createSomeNodes() {
        var div;
        var i = 10000;
        var frag = document.createDocumentFragment();
        for (; i > 0; i--) {
            div = document.createElement("div");
            div.appendChild(document.createTextNode(i + " - " + new Date().toTimeString()));
            frag.appendChild(div);
        }
        document.getElementById("nodes").appendChild(frag);
    }
    function grow() {
        x.push(new Array(1000000).join('x'));
        createSomeNodes();
        setTimeout(grow, 1000);
    }
    grow()

上述代码贴一张timeline的截图

1.垃圾回收机制

主要看memory区域。通过分析代码可知,页面上的DOM节点不断增加,因此memory里绿色的线(代表dom nodes)不断升高;代表js heap的蓝色的线有升有降,当整体趋势逐渐升高,这是因为js有内存回收机制,每次回收时蓝线下降,但部分内存一直未释放,导致蓝线逐渐升高。

4.2 js错误引用DOM元素

    var nodes = '';
    (function () {
        var item = {
            name:new Array(1000000).join('x')
        }
        nodes = document.getElementById("nodes")
        nodes.item = item
        nodes.parentElement.removeChild(nodes)
    })()

这里的DOM元素虽然已从页面移除,但js中仍保存对该DOM元素的引用。

因这段代码只执行一次,用timeline视图难以分析是否存在内存泄漏,因此可使用chrome dev tool的profile tab里的heap snapshot工具来分析。

上述代码贴一张heap snapshot的summary模式的截图

1.垃圾回收机制

通过constructor的filter功能,找出上述代码中创建的长字符串,可见代码运行结束后,内存中的长字符串依然未被垃圾回收。 顺便提一下右边红框里shadow size和retainer size的含义

  • shadow size指的是对象本地的大小
  • retainer size指的是对象所引用内存的大小,回收该对象时会将其引用的内存一并回收,因此retainer size指代的是回收内存后会释放出来的内存大小

上面我们可以看到长字符串本身的shadow size和retainer size是一样大的,这是引用长字符串没有引用其他的对象,如果有引用其他对象,那shadow size和retainer size将不一致。

4.3 闭包循环引用

(function(){
    var theThing = null
    var replaceThing = function () {
        var originalThing = theThing
        var unused = function () {
            if (originalThing)
                console.log("hi")
        }
        theThing = {
            longStr: new Array(1000000).join('*'),
            someMethod: function someMethod() {
                console.log('someMessage')
            }
        };
    };
    setInterval(replaceThing,100)
})()

首先明确,unused是一个闭包,因为它引用了自由变量originalThing。虽然它未被使用,但v8引擎并不会把它优化掉,因为JavaScript里存在eval函数,所以v8引擎并不会随便优化掉暂时没有使用的函数。

theThing引用了someMethod,someMethod这个函数作用域隐式的和unused这个闭包共享一个闭包上下文。所以someMethod也引用了originalThing这个自由变量。

这里的引用链是:

GCHandler -> replaceThing -> theThing -> someMethod -> originalThing -> someMethod(old) -> originalThing(older)-> someMethod(older)

随着setInterval的不断执行,这条引用链是不会断的,所以内存会不断泄漏,直至程序崩溃。

因为是闭包作用域引起的内存泄漏,这时候最好的选择是使用chrome的heap snapshot的container视图,我们通过container视图能清楚地看到这条不断泄漏内存的引用链

1.垃圾回收机制

到此这篇关于JavaScript面试必备之垃圾回收机制和内存泄漏详解的文章就介绍到这了,更多相关JavaScript垃圾回收 内存泄漏内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章