一个后台管理系统的页面,来回切几十次路由之后开始卡,任务管理器里那个标签页占了一两个 G。刷新一下又好了,第二天用户继续来提工单。这类问题的定位难度不在于修,而在于你根本不知道该从哪儿看起。
要看懂内存泄漏,得先看懂 JS 引擎是按什么规则回收内存的。这篇从标记清除和引用计数两种算法讲起,把「什么样的对象算垃圾」这条判定线说清楚,再回过头看那些经典的泄漏写法为什么会逃过回收,最后给一条能落地的 Chrome DevTools 排查路径。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 内存泄漏的准确定义,以及它和内存占用高的区别
- JS 为什么必须有垃圾回收机制
- 标记清除算法的判定依据是可达性而不是引用数
- 引用计数的工作方式与它天生的缺陷
- 循环引用在现代引擎里到底还会不会泄漏
- 四类高频泄漏写法:全局变量、定时器、闭包、游离 DOM
- 用 Chrome DevTools 的三次快照法定位泄漏源
- 面试被问到这块该怎么组织回答
一、内存泄漏到底指什么
程序的运行需要内存,只要程序提出要求,操作系统或者运行时就必须供给内存。
对于持续运行的服务进程,必须及时释放内存,否则内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。不再用到的内存没有及时释放,就叫做内存泄漏。
这里有个容易混淆的点。内存占用高不等于内存泄漏。你打开一个渲染了十万行表格的页面,占几百 M 很正常,只要关掉页面内存能降下来,那就不是泄漏。泄漏的判定标准是「这块内存已经没有任何代码路径能再用到它,但引擎认为它还有用,所以不敢回收」。
有些语言(比如 C 语言)必须手动释放内存,程序员负责内存管理。这很麻烦,所以大多数语言提供自动内存管理,减轻程序员的负担,这被称为垃圾回收机制。
JavaScript 的垃圾回收机制会定期(周期性)找出那些不再用到的内存,然后释放掉。注意是「定期」,不是「立刻」。你把一个变量置成 null 的那一瞬间内存并不会马上降,得等下一轮 GC 跑起来。这一点在你盯着 DevTools 的内存曲线做验证时特别容易误判。
二、为什么必须有垃圾回收
字符串、对象和数组没有固定大小,只有当它们的大小已知时,才能对它们进行动态的存储分配。JavaScript 程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。
只要像这样动态地分配了内存,最终都要释放这些内存以便它们能够被再用,否则 JavaScript 引擎将会消耗完系统中所有可用的内存,造成系统崩溃。
JS 不像 C/C++,它有自己的一套垃圾回收机制(Garbage Collection)。引擎可以检测到何时程序不再使用一个对象,当它确定一个对象是无用的时候,就可以把它所占用的内存释放掉。例如:
var a = "before"; |
这段代码运行之后,"before" 这个字符串失去了引用(之前是被 a 引用的),引擎检测到这个事实之后,就会释放该字符串的存储空间以便这些空间可以被再利用。
顺着上面聊,这段用的是 var,是 2021 年前后很常见的写法。现在一般会用 const 加解构来替代,一方面 const 能在编译期就拦住重复声明(上面那个 var a 声明两次在 const 下直接报错),另一方面块级作用域会让变量更早离开作用域,也就更早变成可回收状态。原理没变,只是作用域粒度更细了。
三、策略一:标记清除
这是 JavaScript 中最常用的垃圾回收方式。
当变量进入执行环境时,就标记这个变量为「进入环境」。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。当变量离开环境时,则将其标记为「离开环境」。
整个过程可以拆成三步:
- 垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记
- 去掉环境中的变量、以及被环境中的变量引用的变量的标记
- 此后仍然带着标记的变量将被视为准备删除的变量,因为环境中的变量已经无法访问到它们了
最后垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收它们所占用的内存空间。
这套算法真正的判定依据是可达性,不是引用数。
引擎内部维护了一组「根」(GC roots),包括全局对象、当前调用栈上的局部变量、以及一部分内置对象。从这些根出发能沿着引用链走到的对象就是活的,走不到的就是垃圾。这个区别在下一节看引用计数的时候会变得很关键。
四、策略二:引用计数
语言引擎有一张引用表,保存了内存里面所有资源(通常是各种值)的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到了,因此可以将这块内存释放。

上图中,左下角的两个值没有任何引用,所以可以释放。
const arr = [1, 2, 3, 4]; |
上面的代码中,数组 [1,2,3,4] 是一个值,会占用内存。变量 arr 是仅有的对这个值的引用,因此引用次数为 1。尽管后面的代码没有再用到 arr,它还是会持续占用内存。
如果增加一行代码,解除 arr 对 [1,2,3,4] 的引用,这块内存就可以被垃圾回收机制释放了。
let arr = [1, 2, 3, 4]; |
上面代码中,arr 重置为 null,就解除了对 [1,2,3,4] 的引用,引用次数变成 0,内存就可以释放出来了。
所以并不是有了垃圾回收机制,程序员就可以完全不管内存。那些很占空间的值,一旦不再用到,你还是需要检查是否存在对它们的引用,必要时手动解除。
引用计数的缺陷
引用计数的问题在于,它只看「有几个人指着我」,不看「这些人自己还活不活着」。
function problem() { |
在这个例子中,objA 和 objB 通过各自的属性相互引用,这两个对象的引用次数都是 2。在采用引用计数的策略下,函数执行完成之后这两个对象虽然离开了作用域,但它们的引用次数永远不会降到 0,于是永远不会被回收。这样的相互引用如果大量存在,就会导致明显的内存泄漏。
手动切断循环引用可以解决这个问题:
function problem() { |
这里要补一句原文没说清楚的事。上面这段手动断开的写法,在今天的主流浏览器里其实已经不需要了。
V8、SpiderMonkey、JavaScriptCore 这些现代引擎用的都是标记清除(配合分代回收、增量标记等优化),判定依据是从 GC roots 出发的可达性。objA 和 objB 虽然互相指着对方,但函数返回后没有任何根能走到它们,整个环会被当成一坨垃圾一起清掉。
引用计数导致循环引用泄漏,主要是 IE 6/7 时代的历史问题。当时 IE 的 DOM 和 BOM 对象由 COM 组件实现,走的是独立于 JS 引擎的引用计数,DOM 节点和 JS 对象互相引用就会两边都不敢回收。这个坑在 IE 9 之后基本就没有了。
回到我们要解决的问题上,既然循环引用不再是主要成因,那今天的内存泄漏到底从哪儿来?
五、四类高频泄漏写法
答案是,泄漏几乎都来自「你以为它已经没人用了,但实际上还有一条引用链挂在根上」。
5.1 意外的全局变量
function foo() { |
非严格模式下,给未声明的变量赋值会挂到 window 上。window 是 GC root,挂上去的东西这辈子都不会被回收。开启严格模式('use strict')或者用打包工具默认的 ESM 模块作用域,这类问题基本就绝迹了。
5.2 没清掉的定时器与事件监听
// 组件里起了定时器,卸载时忘了清 |
setInterval 的回调被浏览器的定时器队列持有,只要不 clearInterval,回调以及它闭包里引用的所有东西都是可达的。SPA 里这是最高频的泄漏来源,路由切走了组件销毁了,定时器还在跑,跑一次就多留一份数据。
addEventListener 同理。绑在 window 或 document 上的监听尤其危险,因为宿主对象的生命周期跟页面一样长。
5.3 闭包意外持有大对象
function createHandler() { |
返回的函数只用了一个 length,但闭包捕获的是整个变量。V8 在部分场景下会做优化只保留用到的变量,但你不能指望这个优化在所有写法下都生效。稳妥的做法是在闭包里只捕获真正需要的那个值。
5.4 游离 DOM 节点
const cache = []; |
节点从 DOM 树上摘下来了,视觉上已经消失,但因为 cache 数组还引用着它,整棵子树连同上面绑的事件、数据都还留在内存里。DevTools 里管这种叫 detached DOM node,是排查时最容易一眼认出来的泄漏类型。
这块可以用 WeakMap、WeakSet 来缓解,它们持有的是弱引用,不会阻止键对象被回收。关于弱引用集合的具体用法,可以看看 Set WeakSet Map WeakMap 梳理。
六、用 DevTools 把泄漏抓出来
知道了成因,还得有办法定位到具体哪一行。Chrome DevTools 的 Memory 面板提供了一套很成熟的流程,我自己常用的是三次快照法。
第一步,打开 Memory 面板,在页面加载完、还没做任何操作时拍一张 Heap snapshot,作为基线。
第二步,把你怀疑泄漏的操作重复做十次以上。比如反复进出某个路由、反复打开关闭某个弹窗。次数多是有讲究的,重复次数越多,泄漏对象的数量差异越明显,越容易从噪音里被挑出来。做完之后再拍第二张快照。
第三步,手动点一下面板上的垃圾桶图标强制触发一次 GC,再拍第三张。
然后把快照的对比模式切成 Comparison,用第三张对比第一张,按 Delta 排序。正常情况下大部分对象的增量应该接近 0,那些增量恰好等于你操作次数(或者它的整数倍)的构造函数,基本就是泄漏源。点开之后看下方的 Retainers 面板,它会告诉你这个对象是被谁一路引用到根上的,顺着这条链往上找就能定位到代码。
排查游离节点还有个更快的入口。在 Memory 面板的过滤框里输入 Detached,能直接筛出所有游离的 DOM 节点。Chrome 后来还加了专门的 Detached Elements 面板,比翻快照直观不少。
如果只是想先确认「到底有没有泄漏」,不用这么麻烦。Performance 面板勾上 Memory,录一段你重复操作的过程,看 JS Heap 那条线。健康的曲线应该是锯齿形,涨一段被 GC 削一次,整体维持在一个水平线上;泄漏的曲线是每次 GC 之后的谷底都比上一次高,整条线阶梯式往上爬。
说实话我也没完全跑通过所有场景,有些泄漏藏在第三方库里,Retainers 链一路指到压缩后的变量名,这时候只能靠源码映射和二分注释法慢慢缩范围。这块我还在摸索。想了解更多前端性能相关的排查手段,可以参考 前端性能优化梳理。
七、面试怎么答
什么是垃圾
一般来说没有被引用的对象就是垃圾,需要被清除。这里有个常被追问的例外,如果几个对象互相引用形成一个环,但从根出发访问不到它们,这几个对象同样是垃圾,也要被清除。
能把这个例外主动说出来,比背完标记清除三步走要加分得多,因为它说明你理解的判定依据是可达性而不是引用数。
如何检测垃圾
主流答案是标记清除算法。展开一点可以提到 V8 的分代回收:新生代用 Scavenge(复制算法,空间换时间,适合存活率低的对象),老生代用标记清除加标记整理,配合增量标记和并发标记把 GC 造成的主线程停顿切碎,避免一次长停顿导致掉帧。
遇到内存泄漏怎么排查
按上一节那套流程讲就行。先用 Performance 面板的 JS Heap 曲线确认是不是真泄漏,再用 Memory 面板的三次快照对比找出增量异常的构造函数,最后看 Retainers 链定位到代码。
总结
内存泄漏的判定线其实只有一条,就是从 GC roots 出发能不能走到这个对象。走得到就是活的,引擎不敢动;走不到就是垃圾,迟早会被清掉。
标记清除和引用计数的差别也在这条线上。引用计数只数指着自己的人有几个,所以被循环引用坑死;标记清除看的是可达性,一个孤立的引用环整体都是不可达的,会被一起清掉。现代浏览器用的都是标记清除,循环引用早已不是主要威胁。
今天真正制造泄漏的是那些「无意中挂到长生命周期对象上」的引用,定时器、事件监听、闭包、缓存住的游离 DOM,这四类占了绝大多数。写代码时记住一件事就够了,凡是往全局、往宿主对象、往长期存活的容器里放东西,都要在对应的位置写好清理逻辑。
排查的时候别急着看代码,先拿三次快照法把嫌疑对象锁定到具体的构造函数,再顺着 Retainers 链回到源码,比凭直觉猜要快得多。