导航
导航
文章目录󰁋
  1. 一、内存泄漏到底指什么
  2. 二、为什么必须有垃圾回收
  3. 三、策略一:标记清除
  4. 四、策略二:引用计数
    1. 引用计数的缺陷
  5. 五、四类高频泄漏写法
    1. 5.1 意外的全局变量
    2. 5.2 没清掉的定时器与事件监听
    3. 5.3 闭包意外持有大对象
    4. 5.4 游离 DOM 节点
  6. 六、用 DevTools 把泄漏抓出来
  7. 七、面试怎么答
    1. 什么是垃圾
    2. 如何检测垃圾
    3. 遇到内存泄漏怎么排查
  8. 总结
  9. 参考
NEW
🚀

前端系统进阶指南

系统化学习前端知识

关注公众号

公众号:前端进阶之旅

JS内存泄漏与垃圾回收机制完全梳理

一个后台管理系统的页面,来回切几十次路由之后开始卡,任务管理器里那个标签页占了一两个 G。刷新一下又好了,第二天用户继续来提工单。这类问题的定位难度不在于修,而在于你根本不知道该从哪儿看起。

要看懂内存泄漏,得先看懂 JS 引擎是按什么规则回收内存的。这篇从标记清除和引用计数两种算法讲起,把「什么样的对象算垃圾」这条判定线说清楚,再回过头看那些经典的泄漏写法为什么会逃过回收,最后给一条能落地的 Chrome DevTools 排查路径。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 内存泄漏的准确定义,以及它和内存占用高的区别
  • JS 为什么必须有垃圾回收机制
  • 标记清除算法的判定依据是可达性而不是引用数
  • 引用计数的工作方式与它天生的缺陷
  • 循环引用在现代引擎里到底还会不会泄漏
  • 四类高频泄漏写法:全局变量、定时器、闭包、游离 DOM
  • 用 Chrome DevTools 的三次快照法定位泄漏源
  • 面试被问到这块该怎么组织回答

一、内存泄漏到底指什么

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

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

这里有个容易混淆的点。内存占用高不等于内存泄漏。你打开一个渲染了十万行表格的页面,占几百 M 很正常,只要关掉页面内存能降下来,那就不是泄漏。泄漏的判定标准是「这块内存已经没有任何代码路径能再用到它,但引擎认为它还有用,所以不敢回收」。

有些语言(比如 C 语言)必须手动释放内存,程序员负责内存管理。这很麻烦,所以大多数语言提供自动内存管理,减轻程序员的负担,这被称为垃圾回收机制。

JavaScript 的垃圾回收机制会定期(周期性)找出那些不再用到的内存,然后释放掉。注意是「定期」,不是「立刻」。你把一个变量置成 null 的那一瞬间内存并不会马上降,得等下一轮 GC 跑起来。这一点在你盯着 DevTools 的内存曲线做验证时特别容易误判。

二、为什么必须有垃圾回收

字符串、对象和数组没有固定大小,只有当它们的大小已知时,才能对它们进行动态的存储分配。JavaScript 程序每次创建字符串、数组或对象时,解释器都必须分配内存来存储那个实体。

只要像这样动态地分配了内存,最终都要释放这些内存以便它们能够被再用,否则 JavaScript 引擎将会消耗完系统中所有可用的内存,造成系统崩溃。

JS 不像 C/C++,它有自己的一套垃圾回收机制(Garbage Collection)。引擎可以检测到何时程序不再使用一个对象,当它确定一个对象是无用的时候,就可以把它所占用的内存释放掉。例如:

var a = "before";
var b = "override a";
var a = b; // 重写 a

这段代码运行之后,"before" 这个字符串失去了引用(之前是被 a 引用的),引擎检测到这个事实之后,就会释放该字符串的存储空间以便这些空间可以被再利用。

顺着上面聊,这段用的是 var,是 2021 年前后很常见的写法。现在一般会用 const 加解构来替代,一方面 const 能在编译期就拦住重复声明(上面那个 var a 声明两次在 const 下直接报错),另一方面块级作用域会让变量更早离开作用域,也就更早变成可回收状态。原理没变,只是作用域粒度更细了。

三、策略一:标记清除

这是 JavaScript 中最常用的垃圾回收方式。

当变量进入执行环境时,就标记这个变量为「进入环境」。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。当变量离开环境时,则将其标记为「离开环境」。

整个过程可以拆成三步:

  • 垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记
  • 去掉环境中的变量、以及被环境中的变量引用的变量的标记
  • 此后仍然带着标记的变量将被视为准备删除的变量,因为环境中的变量已经无法访问到它们了

最后垃圾收集器完成内存清除工作,销毁那些带标记的值,并回收它们所占用的内存空间。

这套算法真正的判定依据是可达性,不是引用数。

引擎内部维护了一组「根」(GC roots),包括全局对象、当前调用栈上的局部变量、以及一部分内置对象。从这些根出发能沿着引用链走到的对象就是活的,走不到的就是垃圾。这个区别在下一节看引用计数的时候会变得很关键。

四、策略二:引用计数

语言引擎有一张引用表,保存了内存里面所有资源(通常是各种值)的引用次数。如果一个值的引用次数是 0,就表示这个值不再用到了,因此可以将这块内存释放。

引用计数示意图,左下角两个没有任何引用的值可以被释放

上图中,左下角的两个值没有任何引用,所以可以释放。

const arr = [1, 2, 3, 4];
console.log("hello world");

上面的代码中,数组 [1,2,3,4] 是一个值,会占用内存。变量 arr 是仅有的对这个值的引用,因此引用次数为 1。尽管后面的代码没有再用到 arr,它还是会持续占用内存。

如果增加一行代码,解除 arr[1,2,3,4] 的引用,这块内存就可以被垃圾回收机制释放了。

let arr = [1, 2, 3, 4];
console.log("hello world");
arr = null;

上面代码中,arr 重置为 null,就解除了对 [1,2,3,4] 的引用,引用次数变成 0,内存就可以释放出来了。

所以并不是有了垃圾回收机制,程序员就可以完全不管内存。那些很占空间的值,一旦不再用到,你还是需要检查是否存在对它们的引用,必要时手动解除。

引用计数的缺陷

引用计数的问题在于,它只看「有几个人指着我」,不看「这些人自己还活不活着」。

function problem() {
var objA = new Object();
var objB = new Object();

objA.someOtherObject = objB;
objB.anotherObject = objA;
}

在这个例子中,objAobjB 通过各自的属性相互引用,这两个对象的引用次数都是 2。在采用引用计数的策略下,函数执行完成之后这两个对象虽然离开了作用域,但它们的引用次数永远不会降到 0,于是永远不会被回收。这样的相互引用如果大量存在,就会导致明显的内存泄漏。

手动切断循环引用可以解决这个问题:

function problem() {
var objA = new Object();
var objB = new Object();

objA.someOtherObject = objB;
objB.anotherObject = objA;

// 函数结束前手动断开,引用计数立刻归零
objA.someOtherObject = null;
objB.anotherObject = null;
}

这里要补一句原文没说清楚的事。上面这段手动断开的写法,在今天的主流浏览器里其实已经不需要了。

V8、SpiderMonkey、JavaScriptCore 这些现代引擎用的都是标记清除(配合分代回收、增量标记等优化),判定依据是从 GC roots 出发的可达性。objAobjB 虽然互相指着对方,但函数返回后没有任何根能走到它们,整个环会被当成一坨垃圾一起清掉。

引用计数导致循环引用泄漏,主要是 IE 6/7 时代的历史问题。当时 IE 的 DOM 和 BOM 对象由 COM 组件实现,走的是独立于 JS 引擎的引用计数,DOM 节点和 JS 对象互相引用就会两边都不敢回收。这个坑在 IE 9 之后基本就没有了。

回到我们要解决的问题上,既然循环引用不再是主要成因,那今天的内存泄漏到底从哪儿来?

五、四类高频泄漏写法

答案是,泄漏几乎都来自「你以为它已经没人用了,但实际上还有一条引用链挂在根上」。

5.1 意外的全局变量

function foo() {
bar = "这是一个隐式全局变量"; // 少写了 var/let/const
}

非严格模式下,给未声明的变量赋值会挂到 window 上。window 是 GC root,挂上去的东西这辈子都不会被回收。开启严格模式('use strict')或者用打包工具默认的 ESM 模块作用域,这类问题基本就绝迹了。

5.2 没清掉的定时器与事件监听

// 组件里起了定时器,卸载时忘了清
const timer = setInterval(() => {
render(bigData); // bigData 被闭包持有,跟着定时器一起活着
}, 1000);

setInterval 的回调被浏览器的定时器队列持有,只要不 clearInterval,回调以及它闭包里引用的所有东西都是可达的。SPA 里这是最高频的泄漏来源,路由切走了组件销毁了,定时器还在跑,跑一次就多留一份数据。

addEventListener 同理。绑在 windowdocument 上的监听尤其危险,因为宿主对象的生命周期跟页面一样长。

5.3 闭包意外持有大对象

function createHandler() {
const hugeData = new Array(1000000).fill("x");
return function () {
// 这里其实只用到了 hugeData.length
console.log(hugeData.length);
};
}

返回的函数只用了一个 length,但闭包捕获的是整个变量。V8 在部分场景下会做优化只保留用到的变量,但你不能指望这个优化在所有写法下都生效。稳妥的做法是在闭包里只捕获真正需要的那个值。

5.4 游离 DOM 节点

const cache = [];
const el = document.getElementById("panel");
cache.push(el);
document.body.removeChild(el); // 从文档树摘掉了,但 cache 还拿着

节点从 DOM 树上摘下来了,视觉上已经消失,但因为 cache 数组还引用着它,整棵子树连同上面绑的事件、数据都还留在内存里。DevTools 里管这种叫 detached DOM node,是排查时最容易一眼认出来的泄漏类型。

这块可以用 WeakMapWeakSet 来缓解,它们持有的是弱引用,不会阻止键对象被回收。关于弱引用集合的具体用法,可以看看 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 链回到源码,比凭直觉猜要快得多。

参考

支持一下
扫一扫,支持poetries
  • 微信扫一扫
  • 支付宝扫一扫