JavaScript 内存泄漏场景、监控与分析详解

作者:闭包旅人时间:2026-10-25 05:03:52浏览:442

本文深入解析JavaScript中内存泄漏的定义与成因,详细列举意外全局变量、遗忘定时器、不当闭包及未清理DOM等常见泄漏场景。通过Chrome开发者工具的Memory面板,演示如何抓取内存快照、对比对象差异及分析堆栈,结合具体代码案例,指导开发者如何定位并修复由闭包共享导致的内存泄漏问题。

前言

Q:什么是内存泄漏?

从字面理解,内存泄漏指的是程序申请了内存空间后,未能及时释放回收,导致这部分内存长期占用且无法被重新利用。

Q:为什么会发生内存泄漏?

尽管前端环境具备自动垃圾回收机制,但当某些不再使用的内存块未被垃圾回收器正确识别为“垃圾”时,内存泄漏便由此产生。

通常,垃圾回收机制采用标志清除策略,即通过判断从根节点开始的引用链是否可达,来确定对象是否为垃圾。

上述机制是内存泄漏的根本原因,而直接诱因则是:当两个生命周期不同的对象相互通信时,若一方生命周期结束应当被回收,却被另一方持续持有引用,便会导致内存无法释放。

因此,下文将具体探讨哪些代码场景会导致内存泄漏。

哪些情况会引起内存泄漏

1. 意外的全局变量

全局变量的生命周期贯穿整个页面运行期,直至页面关闭前都不会被回收。因此,挂载在全局对象上的内存将一直存在。

若全局变量使用不当,例如未及时手动赋值为 null,或因拼写错误意外将局部变量挂载到全局对象上,都会导致内存泄漏。

2. 遗忘的定时器

setTimeout 和 setInterval 由浏览器专用线程管理生命周期。若在页面中使用定时器,而在页面销毁时未手动清除这些定时器,它们仍会存活。

这意味着定时器的生命周期独立于页面。若在当前页面的 JavaScript 代码中通过定时器注册了回调函数,且该回调函数持有了当前页面的变量或 DOM 元素,即使页面销毁,由于定时器仍持有这些引用,页面也无法被正常回收,从而导致内存泄漏。

此时再次打开同一页面,内存中实际上会存在双份页面数据。若多次关闭和打开页面,内存泄漏问题将愈发严重。这种场景极易发生,因为开发者容易遗忘清除定时器。

3. 使用不当的闭包

函数本身会持有其定义时所在词法环境的引用。通常情况下,函数执行完毕后,其所申请的内存会被回收。

然而,当函数内部返回另一个函数时,由于返回的函数持有外部函数的词法环境,且该返回函数又被其他长生命周期对象持有,导致外部函数虽已执行完毕,但其内存却无法被回收。

因此,返回函数的生命周期应尽量短,以便闭包能及时被回收。

严格来说,闭包本身并非内存泄漏,因为持有外部函数词法环境是闭包的固有特性,旨在保留未来可能需要的数据。

但这无疑会增加内存消耗,因此应避免滥用闭包。

4. 遗漏的 DOM 元素

DOM 元素的正常生命周期取决于其是否挂载在 DOM 树上。当从 DOM 树中移除时,即可被销毁和回收。

但如果 JavaScript 代码中也持有了该 DOM 元素的引用,其生命周期将由 JavaScript 引用和 DOM 树挂载状态共同决定。在移除时,需同时清理两处引用才能正常回收。

5. 网络回调

在某些场景中,页面发起网络请求并注册回调,且回调函数内持有该页面的部分内容。当页面销毁时,若未注销网络回调,由于网络请求仍持有页面部分内容,会导致页面部分数据无法被回收。

如何监控内存泄漏

内存泄漏可分为两类:一类是严重泄漏,内存一旦分配便无法回收;另一类是轻微泄漏,因未及时清理导致,但一段时间后仍可被清理。

无论哪种情况,利用开发者工具抓取的内存图通常会显示一段时间内内存占用呈直线式下降,这是不断发生垃圾回收(GC)的结果。

针对较严重的泄漏,会发现即使不断发生 GC,所使用的内存总量仍在持续增长。

此外,内存不足会触发频繁 GC,而 GC 过程会阻塞主线程,影响页面性能并造成卡顿,因此内存泄漏问题不容忽视。

我们假设一种场景,并使用开发者工具查看内存泄漏情况:

场景一:在某个函数内申请一块内存,然后该函数在短时间内不断被调用


// 点击按钮,就执行一次函数,申请一块内存
startBtn.addEventListener("click", function() {
	var a = new Array(100000).fill(1);
	var b = new Array(20000).fill(1);
});

前言

页面可用内存有限,当内存不足时,会触发垃圾回收机制回收无用内存。

函数内部使用的变量均为局部变量,函数执行完毕后,这部分内存即可被回收。

因此,当短时间内不断调用该函数时,若发现内存不足,垃圾回收机制会工作,回收上一个函数申请的内存,因为上一个函数已结束执行,其内存已无用可回收。

图中呈现的内存使用量图表表现为一条横线,中间出现多处竖线。这表示内存清空后再申请,反复进行,每个竖线的位置对应垃圾回收机制工作及函数执行再次申请的时机。

场景二:在某个函数内申请一块内存,然后该函数在短时间内不断被调用,但每次申请的内存,有一部分被外部持有


// 点击按钮,就执行一次函数,申请一块内存
var arr = [];
startBtn.addEventListener("click", function() {
	var a = new Array(100000).fill(1);
	var b = new Array(20000).fill(1);
    arr.push(b);
});

前言

观察其与第一张图片的区别。

图表不再是一条横线,且横线中每个竖线的底部并非处于同一水平线。

这实际上就是内存泄漏的表现。

我们在函数内申请了两个数组内存,但其中一个数组被外部持有。因此,即使每次函数执行完毕,这部分被外部持有的数组内存依旧无法回收,导致每次只能回收部分内存。

随着函数调用次数增加,无法回收的内存越来越多,内存泄漏加剧,导致内存使用量持续增长。此外,还可以使用 Performance Monitor 工具,在开发者工具中找到更多按钮并打开此功能面板。这是一个可以实时监控 CPU、内存等使用情况的工具,比仅抓取一段时间内的工具更直观。

前言

图中呈梯状上升的部分表示发生了内存泄漏,每次函数调用都有一部分数据被外部持有导致无法回收;而后面平滑状的部分则表示每次使用完均可正常回收。

需注意这张图,第一个红框末尾出现直线式下滑,这是因为修改了代码,去掉了外部持有函数内申请数组的那行代码,然后刷新页面并手动点击 GC 触发的效果。否则,无论点击多少次 GC,有部分内存一直无法回收,无法达到此效果图。

以上介绍了监控内存泄漏的一些工具,但下一步才是关键:既然发现内存泄漏,该如何定位?如何知道是哪部分数据未回收导致的?

如何分析内存泄漏,找出有问题的代码

分析内存泄漏原因仍需借助开发者工具的 Memory 功能。该功能可以抓取内存快照,也可以抓取一段时间内内存分配的情况,以及触发内存分配的各函数情况。

前言

利用这些工具,我们可以分析出某个时刻是由哪个函数操作导致内存分配,并分析出大量重复且未被回收的对象是什么。

这样一来,嫌疑函数和嫌疑对象都明确了,再去代码中分析该函数中的该对象是否就是内存泄漏的元凶,即可解决问题。

先举个简单例子,再举个实际内存泄漏的例子:

场景一:在某个函数内申请一块内存,然后该函数在短时间内不断被调用,但每次申请的内存,有一部分被外部持有


// 每次点击按钮,就有一部分内存无法回收,因为被外部 arr 持有了
var arr = [];
startBtn.addEventListener("click", function() {
	var a = new Array(100000).fill(1);
	var b = new Array(20000).fill(1);
    arr.push(b);
});

内存快照

前言

可以抓取两份快照,在两份快照中间进行内存泄漏操作,最后再比对两份快照的区别,查看增加的对象是什么,回收的对象又是哪些,如上图。

也可以单独查看某个时刻快照,从内存占用比例来查看占据大量内存的是什么对象,如下图:

前言

还可以从垃圾回收机制角度出发,查看从 GC root 根节点出发,可达的对象里,哪些对象占用大量内存:

前言

从上面这些方式入手,都可以查看到当前占用大量内存的对象是什么,一般来说,这个就是嫌疑犯。

当然,也不一定。当有嫌疑对象时,可以利用多次内存快照间比对,中间手动强制 GC 下,看下该回收的对象有没有被回收,这是一种思路。

抓取一段时间内,内存分配情况

前言

前言

这个方式,可以有选择性的查看各个内存分配时刻是由哪个函数发起,且内存存储的是什么对象。

当然,内存分配是正常行为,这里查看到的还需要借助其他数据来判断某个对象是否是嫌疑对象,比如内存占用比例,或结合内存快照等等。

抓取一段时间内函数的内存使用情况

前言

这个能看到的内容很少,比较简单,目的也很明确,就是一段时间内,都有哪些操作在申请内存,且用了多少。

总之,这些工具并没有办法直接给你答复,告诉你 xxx 就是内存泄漏的元凶。如果浏览器层面就能确定了,那它干嘛不回收它,干嘛还会造成内存泄漏。

所以,这些工具,只能给你各种内存使用信息,你需要自己借助这些信息,根据自己代码的逻辑,去分析,哪些嫌疑对象才是内存泄漏的元凶。

实例分析

来个网上很多文章都出现过的内存泄漏例子:


var t = null;
var replaceThing = function() {
  var o = t
  var unused = function() {
    if (o) {
      console.log("hi")
    }        
  }
 
  t = {
        longStr: new Array(100000).fill('*'),
        someMethod: function() {
                       console.log(1)
                    }
      }
}
setInterval(replaceThing, 1000)

也许你还没看出这段代码是不是会发生内存泄漏,原因在哪,不急。

先说说这代码用途,声明了一个全局变量 t 和 replaceThing 函数,函数目的在于会为全局变量赋值一个新对象,然后内部有个变量存储全局变量 t 被替换前的值,最后定时器周期性执行 replaceThing 函数。

  • 发现问题

我们先利用工具看看,是不是会发生内存泄漏:

前言

三种内存监控图表都显示,这发生内存泄漏了:反复执行同个函数,内存却梯状式增长,手动点击 GC 内存也没有下降,说明函数每次执行都有部分内存泄漏了。

这种手动强制垃圾回收都无法将内存降下去的情况是很严重的,长期执行下去,会耗尽可用内存,导致页面卡顿甚至崩掉。

  • 分析问题

既然已经确定有内存泄漏了,那么接下去就该找出内存泄漏的原因了。

前言

首先通过 sampling profile,我们把嫌疑定位到 replaceThing 这个函数上。

接着,我们抓取两份内存快照,比对一下,看看能否得到什么信息:

前言

比对两份快照可以发现,这过程中,数组对象一直在增加,而且这个数组对象来自 replaceThing 函数内部创建的对象的 longStr 属性。

其实这张图信息很多了,尤其是下方那个嵌套图,嵌套关系是反着来,你倒着看的话,就可以发现,从全局对象Window 是如何一步步访问到该数组对象的,垃圾回收机制正是因为有这样一条可达的访问路径,才无法回收。

其实这里就可以分析了,为了多使用些工具,我们换个图来分析吧。

我们直接从第二份内存快照入手,看看:

前言

从第一份快照到第二份快照期间,replaceThing 执行了 7 次,刚好创建了 7 份对象,看来这些对象都没有被回收。

那么为什么不会被回收呢?

replaceThing 函数只是在内部保存了上份对象,但函数执行结束,局部变量不应该是被回收了么。

继续看图,可以看到底下还有个闭包占用很大内存,看看:

前言

为什么每一次 replaceThing 函数调用后,内部创建的对象都无法被回收呢?

因为 replaceThing 的第一次创建,这个对象被全局变量 t 持有,所以回收不了。

后面的每一次调用,这个对象都被上一个 replaceThing 函数内部的 o 局部变量持有而回收不了。

而这个函数内的局部变量 o 在 replaceThing 首次调用时被创建的对象的 someMethod 方法持有,该方法挂载的对象被全局变量 t 持有,所以也回收不了。

这样层层持有,每一次函数的调用,都会持有函数上次调用时内部创建的局部变量,导致函数即使执行结束,这些局部变量也无法回收。

口头说有点懵,盗张图(侵权删),结合垃圾回收机制的标记清除法(俗称可达法)来看,就很明了了:

前言

  • 整理结论

根据利用内存分析工具,可以得到如下信息:

  1. 同一个函数调用,内存占用却呈现梯状式上升,且手动 GC 内存都无法下降,说明内存泄漏了。
  2. 抓取一段时间的内存申请情况,可以确定嫌疑函数是 replaceThing。
  3. 比对内存快照发现,没有回收的是 replaceThing 内部创建的对象(包括存储数组的 longStr 属性和方法 someMethod)。
  4. 进一步分析内存快照发现,之所以不回收,是因为每次函数调用创建的这个对象会被存储在函数上一次调用时内部创建的局部变量 o 上。
  5. 而局部变量 o 在函数执行结束没被回收,是因为,它被创建的对象的 someMethod 方法所持有。

以上,就是结论,但我们还得分析为什么会出现这种情况,是吧。

其实,这就涉及到闭包的知识点了:

MDN 对闭包的解释是,函数块以及函数定义时所在的词法环境两者的结合就称为闭包。

而函数定义时,本身就会有一个作用域的内部属性存储着当前的词法环境,所以,一旦某个函数被比它所在的词法环境还长的生命周期的东西所持有,此时就会造成函数持有的词法环境无法被回收。

简单说,外部持有某个函数内定义的函数时,此时,如果内部函数有使用到外部函数的某些变量,那么这些变量即使外部函数执行结束了,也无法被回收,因为转而被存储在内部函数的属性上了。

还有一个知识点,外部函数里定义的所有函数共享一个闭包,也就是 b 函数使用外部函数 a 变量,即使 c 函数没使用,但 c 函数仍旧会存储 a 变量,这就叫共享闭包。

回到这道题

因为 replaceThing 函数里,手动将内部创建的字面量对象赋值给全局变量,而且这个对象还有个 someMethod 方法,所以 someMethod 方法就因为闭包特性存储着 replaceThing 的变量。

虽然 someMethod 内部并没有使用到什么局部变量,但 replaceThing 内部还有一个 unused 函数啊,这个函数就使用了局部变量 o,因为共享闭包,导致 someMethod 也存储着 o。

而 o 又存着全局变量 t 替换前的值,所以就导致了,每一次函数调用,内部变量 o 都会有人持有它,所以无法回收。想要解决这个内存泄漏,就是要砍断 o 的持有者,让局部变量 o 能够正常被回收。

所以有两个思路:要么让 someMethod 不用存储 o;要么使用完 o 就释放;

如果 unused 函数没有用,那可以直接去掉这个函数,然后看看效果:

前言

这里之所以还会梯状式上升是因为,当前内存还足够,还没有触发垃圾回收机制工作,你可以手动触发 GC,或者运行一段时间等到 GC 工作后查看一下,内存是否下降到初始状态,这表明,这些内存都可以被回收的。

或者拉份内存快照看看,拉快照时,会自动先强制进行 GC 再拉取快照:

前言

是吧,即使周期性调用 replaceThing 函数,函数内的局部变量 o 即使存储着上个全局变量 t 的值,但毕竟是局部变量,函数执行完毕,如果没有外部持有它的引用,也就可以被回收掉了,所以最终内存就只剩下全局变量 t 存储的对象了。


var unused = function() {
    if (o) {
      console.log("hi")
      o = null;
    }        
}

但这种做法,治标不治本,因为在 unused 函数执行前,这堆内存还是一直存在着的,还是一直泄漏无法被回收的,与最开始的区别就在于,至少在 unused 函数执行后,就可以释放掉而已

但这种做法,不治本,因为在 unused 函数执行前,这堆内存还是一直存在着的,还是一直泄漏无法被回收的,与最开始的区别就在于,至少在 unused 函数执行后,就可以释放掉而已。

其实,这里应该考虑的代码有没有问题,为什么需要局部变量存储,为什么需要 unused 函数的存在,这个函数的目的又是什么,如果只是为了在将来某个时刻用来判断上个全局变量 t 是否可用,那么为什么不直接再使用个全局变量来存储,为什么选择了局部变量?所以,当写代码时,当涉及到闭包的场景时,应该要特别注意,如果使用不当,很可能会造成一些严重的内存泄漏场景。应该铭记,闭包会让函数持有外部的词法环境,导致外部词法环境的某些变量无法被回收,还有共享一个闭包这种特性,只有清楚这两点,才能在涉及到闭包使用场景时,正确考虑该如何实现,避免造成严重的内存泄漏。

总结

到此这篇关于js内存泄漏场景、如何监控及分析的文章就介绍到这了,更多相关js内存泄漏场景监控内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

作者最新文章