深入理解JavaScript内存管理机制与内存泄漏排查

作者:闭包旅人时间:2026-10-24 01:17:50浏览:471

本文深入解析JavaScript的内存生命周期,详细对比引用计数与标记清除两种垃圾回收算法的原理及缺陷。重点剖析循环引用导致的内存泄漏场景,并提供Chrome开发者工具与Node.js process.memoryUsage的具体排查方法,帮助开发者识别并避免意外全局变量、定时器及闭包引发的内存浪费问题。

JavaScript内存生命周期概述

前言

在C语言等底层编程语言中,通常提供底层的内存管理接口,例如通过 malloc() 和 free() 来手动分配和释放内存。

相比之下,JavaScript在创建变量(如对象、字符串等)时会自动分配内存,并在这些变量不再被使用时“自动”释放内存。这一自动释放内存的过程被称为垃圾回收(Garbage Collection)。

由于存在自动垃圾回收机制,许多JavaScript开发者认为无需关注内存管理,这往往导致在某些情况下出现内存泄漏问题。

内存生命周期

JavaScript内存生命周期概述

JavaScript环境中分配的内存遵循以下生命周期: 1. 内存分配:当声明变量、函数或对象时,系统会自动为其分配内存。 2. 内存使用:即对内存进行读写操作,例如使用变量或调用函数。 3. 内存回收:使用完毕后,由垃圾回收机制自动回收不再使用的内存。

JS 的内存分配

为了减轻程序员的负担,JavaScript在定义变量时便完成了内存分配。


var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
 a: 1,
 b: null
}; // 给对象及其包含的值分配内存

// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"]; 

function f(a){
 return a + 2;
} // 给函数(可调用的对象)分配内存

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
 someElement.style.backgroundColor = 'blue';
}, false);

某些函数调用会返回对象并分配内存:


var d = new Date(); // 分配一个 Date 对象

var e = document.createElement('div'); // 分配一个 DOM 元素

某些方法会分配新变量或新对象:


var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组有四个元素,是 a 连接 a2 的结果

JS 的内存使用

使用值的过程实质上是对已分配内存进行读取与写入的操作。

这种读写操作可能表现为写入变量或对象的属性值,甚至包括传递函数参数。


var a = 10; // 分配内存
console.log(a); // 对内存的使用

垃圾回收机制与算法原理

JS 的内存回收

JavaScript具备自动垃圾回收机制,其核心原理是找出那些不再继续使用的值,并释放其占用的内存。

大多数内存管理问题集中在此阶段。 此阶段最艰巨的任务是准确识别哪些变量已不再需要。

不再需要的变量即生命周期结束的变量,通常指局部变量。局部变量仅在函数执行期间存在, 当函数运行结束且没有其他引用(如闭包)时,该变量会被标记为可回收。

全局变量的生命周期直至浏览器卸载页面才会结束,因此全局变量通常不会被当作垃圾回收。

由于自动垃圾回收机制的存在,开发人员往往不关心也不注意内存释放的相关问题,但对无用内存的释放是客观存在的。 不幸的是,即使不考虑垃圾回收对性能的影响,目前最新的垃圾回收算法也无法智能回收所有的极端情况。

接下来我们来探究一下 JS 垃圾回收的机制。

垃圾回收

引用

垃圾回收算法主要依赖于引用的概念。

在内存管理的环境中,如果一个对象拥有访问另一个对象的权限(无论是隐式还是显式),则称该对象引用了另一个对象。

例如,一个JavaScript对象具有对其原型的引用(隐式引用)和对其属性的引用(显式引用)。

这里的“对象”概念不仅特指JavaScript对象,还包括函数作用域(或者全局词法作用域)。

引用计数垃圾收集

这是最原始的垃圾回收算法。

引用计数算法定义“内存不再使用”的标准很简单,即判断一个对象是否有指向它的引用。

如果没有其他对象指向它了,说明该对象已经不再需要。


var o = { 
 a: {
  b:2
 }
}; 
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1;   // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
      // 他可以被垃圾回收了
      // 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
      // 它可以被垃圾回收了

由上述分析可知,引用计数算法是一个简单有效的算法。但它存在一个致命的问题:循环引用。

如果两个对象相互引用,尽管它们已不再被外部使用,垃圾回收器仍不会进行回收,从而导致内存泄漏。

来看一个循环引用的例子:


function f(){
 var o = {};
 var o2 = {};
 o.a = o2; // o 引用 o2
 o2.a = o; // o2 引用 o 这里

 return "azerty";
}

f();

上面我们申明了一个函数 f ,其中包含两个相互引用的对象。

在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。

但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。

再来看一个实际的例子:


var div = document.createElement("div");
div.onclick = function() {
  console.log("click");
};

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。

此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。

一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。

为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。

标记清除算法

标记清除算法将“不再使用的对象”定义为“无法达到的对象”。 简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。 凡是能从根部到达的对象,都是还需要使用的。 那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。

从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。 但反之未必成立。

工作流程: 1. 垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。 2. 从根部出发将能触及到的对象的标记清除。 3. 那些还存在标记的变量被视为准备删除的变量。 4. 最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。

JavaScript内存生命周期概述

循环引用不再是问题了

再看之前循环引用的例子:


function f(){
 var o = {};
 var o2 = {};
 o.a = o2; // o 引用 o2
 o2.a = o; // o2 引用 o

 return "azerty";
}

f();

函数调用返回之后,两个循环引用的对象在垃圾收集时从全局对象出发无法再获取他们的引用。 因此,他们将会被垃圾回收器回收。

内存泄漏检测与Node.js监控

内存泄漏

什么是内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。 否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费。

内存泄漏的识别方法

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。

这就要求实时查看内存的占用情况。

在 Chrome 浏览器中,我们可以这样查看内存占用情况 1. 打开开发者工具,选择 Performance 面板 2. 在顶部勾选 Memory 3. 点击左上角的 record 按钮 4. 在页面上进行各种操作,模拟用户的使用情况 5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况

来看一张效果图:

JavaScript内存生命周期概述

我们有两种方式来判定当前是否有内存泄漏: 1. 多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏 2. 某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏

在服务器环境中使用 Node 提供的 process.memoryUsage 方法查看内存情况


console.log(process.memoryUsage());
// { 
//   rss: 27709440,
//   heapTotal: 5685248,
//   heapUsed: 3449392,
//   external: 8772 
// }

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。

该对象包含四个字段,单位是字节,含义如下:

  • rss(resident set size):所有内存占用,包括指令区和堆栈。
  • heapTotal:"堆"占用的内存,包括用到的和没用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎内部的 C++ 对象占用的内存。

判断内存泄漏,以heapUsed字段为准。

常见内存泄漏场景与规避策略

常见的内存泄露案例

意外的全局变量


function foo() {
  bar1 = 'some text'; // 没有声明变量 实际上是全局变量 => window.bar1
  this.bar2 = 'some text' // 全局变量 => window.bar2
}
foo();

在这个例子中,意外的创建了两个全局变量 bar1 和 bar2

被遗忘的定时器和回调函数

在很多库中, 如果使用了观察者模式, 都会提供回调方法, 来调用一些回调函数。

要记得回收这些回调函数。举一个 setInterval的例子:


var serverData = loadData();
setInterval(function() {
  var renderer = document.getElementById('renderer');
  if(renderer) {
    renderer.innerHTML = JSON.stringify(serverData);
  }
}, 5000); // 每 5 秒调用一次

如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。

但如果你没有回收定时器,整个定时器依然有效, 不但定时器无法被内存回收,

定时器函数中的依赖也无法回收。在这个案例中的 serverData 也无法被回收。

闭包

在 JS 开发中,我们会经常用到闭包,一个内部函数,有权访问包含其的外部函数中的变量。

下面这种情况下,闭包也会造成内存泄露:


var theThing = null;
var replaceThing = function () {
 var originalThing = theThing;
 var unused = function () {
  if (originalThing) // 对于 'originalThing'的引用
   console.log("hi");
 };
 theThing = {
  longStr: new Array(1000000).join('*'),
  someMethod: function () {
   console.log("message");
  }
 };
};
setInterval(replaceThing, 1000);

这段代码,每次调用 replaceThing 时,theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象。 同时 unused 是一个引用了 originalThing 的闭包。

这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用,但是 someMethod 可能会被调用,就会导致无法对其内存进行回收。

当这段代码被反复执行时,内存会持续增长。

DOM 引用

很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中。


var elements = {
  image: document.getElementById('image')
};
function doStuff() {
  elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
  document.body.removeChild(document.getElementById('image'));
  // 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收.
}

上述案例中,即使我们对于 image 元素进行了移除,但是仍然有对 image 元素的引用,依然无法对齐进行内存回收。

另外需要注意的一个点是,对于一个 Dom 树的叶子节点的引用。

举个例子: 如果我们引用了一个表格中的td元素,一旦在 Dom 中删除了整个表格,我们直观的觉得内存回收应该回收除了被引用的 td 外的其他元素。

但是事实上,这个 td 元素是整个表格的一个子元素,并保留对于其父元素的引用。

这就会导致对于整个表格,都无法进行内存回收。所以我们要小心处理对于 Dom 元素的引用。

如何避免内存泄漏

记住一个原则:不用的东西,及时归还。 1. 减少不必要的全局变量,使用严格模式避免意外创建全局变量。 2. 在你使用完数据后,及时解除引用(闭包中的变量,dom引用,定时器清除)。 3. 组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章

相关标签: