文章全面阐述了 JavaScript 内存泄漏的排查方法。首先,定义了内存泄漏概念并介绍了 Chrome DevTools 中 Memory 工具的核心功能,如快照视图(摘要与对比)、类筛选器及各项指标(浅层大小、保留大小)。接着,详细讲解了堆快照的分析技巧,包括如何复现操作、抓取快照、筛选可疑引用链(优先关注内存增量大、Detached 元素、常见泄漏类型如事件监听和定时器,以及频繁出现的引用链)。最后,通过一个具体的监听器泄漏案例,展示了从发现问题到定位代码的完整排查流程,为前端开发者提供了实用的内存优化指南。
src="https://api.eyabc.cn/api/picture/scenery/?k=b354d634&u=https%3A%2F%2Fmmbiz.qpic.cn%2Fsz_mmbiz_jpg%2F5EcwYhllQOhLSxlCuXiaQ0Yo7CtQqoq5RUSACyF4vIOoCDIHAh6jCticzRLAlIQrpeZicoiaOB9gs5SW6Z8SUaJkeA%2F0%3Fwx_fmt%3Djpeg">
一、概述
本文主要介绍了如何通过 Devtools 的 Memory 内存工具排查 JavaScript 内存泄漏问题。先介绍了一些相关概念,说明了 Memory 内存工具的使用方式,然后介绍了堆快照的分析方式,说明如何通过分析堆快照找到泄漏的 JavaScript 代码,最后列举了一些 JavaScript 内存泄漏的排查案例。

二、概念说明
1、内存泄漏
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢,甚至系统崩溃等严重后果。
简单来说就是,按照业务逻辑,本该被回收的对象,可能因为某些代码的实现不合理,导致对象没有被及时回收,进而对象占用的内存无法释放,导致内存的浪费。
2、Memory 常用功能

-
快照查看方式:主要有摘要和对比
-
类筛选器:可以过滤构造函数,但是不能过滤对象名
例如,要查看包含video的构造函数名:

3、堆快照视图
3.1 摘要视图

-
构造函数:JS构造函数,以及由JS引擎、框架或库创建的构造函数
-
距离 (distance):与 GC root 之间的距离,如果某节点没有 distance,通常说明该节点即将被 gc 回收
-
卷影大小/浅层大小 (shadow size):对象本身的大小
浅层大小可以直观地看出内存具体地分配给哪些对象了
-
保留的大小 (retained size):对象释放后可以回收的内存大小(参考chrome的定义)
-
保留的大小说明了哪些对象导致了内存占用高,但不一定是这个对象本身内存高,可能是因为它引用的对象占用内存高。
-
有时候某个对象的 retained size,不等于其所有属性的 retained size 之和。因为该对象的多个属性都被回收之后,才能让这多个属性引用的对象回收,所以这些被引用的对象的大小不会计入此对象中。例如这里的 DOMTimer@1199111936,保留的大小为 1468B ,但其引用的一个context@2160207 保留的大小为 124520B,已经大于了 DOMTimer 的保留大小了。因为这个context不止被这个 DOMTimer 引用,还被其他的 DOMTimer 引用,需要这些 DOMTimer 全部被回收之后,才能把这个 context 回收,因此它的大小不计入 DOMTimer 中。换句话说,如果只有 DOMTimer 引用这个 context,那 context 的保留大小就会计入 DOMTimer 中。

3.2 对比视图
-
新建:快照查看方式选择比较后,若是新建列中有一个点,则表示是在两个快照之间新建的对象

-
已删除:同理,若在已删除列有一个点,表示在两个快照之间删除的对象
-
增量:指对象增加的数量
-
分配大小:在两个快照之间分配的大小
-
已释放:在两个快照之间释放的内存大小
-
大小增量:在两个快照之间增长的浅层大小
4、构造函数和对象
4.1 构造函数
-
在上半部分的构造函数这一列中,第一层都是构造函数,后面的数字表示这个类的对象数。例如下图中,当前的Object数量为89160
-
展开某一个对象后,子元素表示该对象的属性,例如这里Object@207683的属性包含aweme_list、map等
-
对象名的::之后的即对象所属的类,@符号之后的表示对象id
4.2 对象
在下半部分的对象这一列中,节点之间的关系为:当前节点被子节点引用。例如前3行,aweme_list被一个Object类型的对象H引用,H被一个Context类型的对象context引用,context被一个函数类型的对象get $$引用。
蓝色的链接可以跳转到源代码,源代码中会以下划线标注某段代码:

表示在这段代码中,当前对象引用了下一个对象。例如下图中的4784.0ec58630.js:1的某段代码中,$$()函数的上下文context引用了H

4.3 查看对象信息
通过鼠标悬停在对象上,可以查看对象信息,不过并不是所有对象都能查看到信息,显示"预览不可用"的对象可能已经被回收了。

4.4 在页面访问 dom
-
如果对象的右边有个窗口图标,则表示可以在窗口访问这个元素,鼠标悬停在对象上,可以查看信息,同时会在页面高亮该对象,例如这个video标签是当前视频所在video标签

-
有时候即使有这个窗口图标,页面中也不会高亮这个元素(可能已经是Detached状态了),或者有些元素没有这个窗口图标。这个时候如果还想知道这个是什么元素,可以查看其信息,找到其对应的class,然后在“元素”中搜索
例如这里的Detached HTMLImageElement@2407721对象,它实际上就是页面中的“抢”标签:


-
如果查看元素对象的信息时,显示"预览不可用",则暂时没有办法找到该元素。此时可以看看它引用了哪些元素或者被哪些元素引用,看看是否能在页面中查看这些元素,如果可以,再以此推测之前的元素。
5、堆快照常见对象类型
5.1 Detached DOM
-
如果删除了某个dom节点,但仍有变量对此节点存在引用关系,则这个dom节点就会变成游离状态,也就是不存在于document上了。
-
简单来说就是,dom节点已经不存在于页面中了,但仍然被JS对象引用着。
5.2 DOM Timer
定时器。setInterval()和setTimeout()函数会创建。是最容易出现泄漏的对象之一,写代码时,很容易出现创建了定时器但是没有销毁的情况,这样就会导致定时器引用的对象泄漏。
5.3 Context
通常指函数的上下文
-
例如以下代码中,会自动为inlineTestFunc函数创建一个context对象,该context对象会引用variable
function testFunc(){const variable = 'I am refereced by inlineTestFunc()'const inlineTestFunc = function () {}return inlineTestFunc}window.testFunc = testFunc()
-
如果在其他地方引用了inlineTestFunc()函数,那么variable变量也会同时被引用。
5.4 Closure
闭包:函数以及其捆绑的周边环境状态
5.5 Compiled code
运行代码占用的内存,通常不会出现内存泄漏
5.6 InternalNode
浏览器内置对象,通常不需要关注,一方面是因为导致内存泄漏的一般是JS对象,而不是内部对象。另一方面是因为它造成的内存泄漏在前端不好解决。如果确实需要获得InternalNode的具体对象名,来排查内存泄漏,可以通过在编译chrome时添加特定参数来实现,可以参考:
三、排查步骤
1、Devtools手动排查
使用 Devtools 的 Memory 内存工具来对 JS 内存泄漏进行排查分析

1.1 复现和堆快照抓取
-
确定疑似有内存泄漏的操作,例如抖音PC客户端中切换视频、发送评论、发送弹幕等。复现操作要尽可能的小,并且最好尽可能地排除其他变量的干扰,这对后续的问题定位有很大的影响。
-
访问不压缩代码的页面(可选)
-
手动或用js脚本写 puppeteer 在浏览器复现
-
GC 后拍摄快照;执行疑似导致内存泄漏的操作;GC 后拍摄快照
a. 执行疑似泄漏的操作时,建议重复执行多次,让上涨的内存大于 30MB 以上,根据过往经验,最好在100MB~200MB左右(太大的话抓快照太慢了),这样会比较容易观察,否则上涨的内存太小,不容易发现是哪些对象增长。通常情况下,这个快照大小几乎可以直接看出哪些对象异常。例如这里切换了 50 个视频,可以明显地看出 Video 元素的异常:

1.2 筛选泄漏的引用链
比较执行泄漏操作前和执行泄漏操作后的快照,筛选疑似泄漏的引用链
1.2.1 筛选方法
手动筛选时,往往很难 100% 确定哪些对象是泄漏的,一般来说只能是怀疑某些对象泄漏,有了怀疑的对象后,按照下一步的方法来分析引用链是否合理,如果没有找到可疑的引用链,那么就需要反复进行1.2和1.3步骤,才能更容易找到泄漏的对象。如果说看了好多对象,或者看了几分钟都没看出来有哪些异常,那么可能是测试得到堆快照对比的增量大小太小了,不容易直接看出来,或者是观察的数据类型不好找到泄漏,比如Object、Array就很难筛选出泄漏的对象,这时可以考虑看其他数据类型。
1.2.2 优先看内存增量大的对象
优先看内存增量比较高的数据,例如某些对象的大小增量为 100M,而其他的就只有 10M 不到,那么优先看大小增量为 100M 的。如果都内存增长都差不多,可以继续按下面步骤进行排查。
如果某些对象大小增量比较高,说明它们最有可能是泄漏的对象。它们为什么没有被回收?可以通过点开对象查看引用的信息,分析该对象整个引用跟踪链路,来找到其中某个不合理的引用。
1.2.3 注意内存占用大的 Detached 元素
如果没有内存占用相对较高的对象,或者有好几种数据大小增长都差不多,可以从Detached元素入手,Detached元素出现内存泄漏的概率比较大,可以观察内存占用相对比较高的Detached元素,原因有两个:
这里需要注意的是,Detached元素的浅层大小通常是很小的,而前面提到过,通过堆快照对比得出的“大小增量”指的是浅层大小增量,所以在堆快照的对比视图里,Detached元素的大小增量一般都会比较小,它实际造成的泄漏是大于“大小增量”这个值的,那么怎么知道它造成了多少泄漏?可以点开某个元素,可以看到它的保留的大小,这个就反应出了它造成的内存泄漏大小。
-
一方面是因为object、array之类的数据,通常对象数量非常大(几万个是比较常见的),并且有很多是正常对象,而泄漏的对象混杂在其中很难分辨哪些是泄漏的、哪些是正常的,除非泄漏的对象非常多,占据它们数量的大部分,或者有个别对象占用的内存特别大时,才比较容易直接观察到。
-
另一方面是Detached元素,通常对象数较少,并且比较容易出现相似的引用链,如果这里面有新的泄漏对象出现,很容易发现这些新增的泄漏对象

1.2.4 注意常见泄漏类型
事件监听(EventListener)、定时器(DOMTimer)、数组(Array),这是最容易导致内存泄漏的几种数据类型,比如监听事件之后没有及时取消监听,定时器开启之后没有销毁,数组元素无限增加,可以专门针对这几种类型进行排查,并对用到这类对象的代码格外注意。
-
可通过多次复现泄漏操作的方式来确定某些对象或者数组是否存在泄漏。先抓取快照,鼠标悬停到疑似泄漏的数组上,查看信息,记录下数组长度,然后执行一遍疑似导致内存泄漏的操作,再查看数组长度(这里查看到的对象信息是实时的,所以无需抓快照也能看到当前的数组详情),如果数组增长了,那么就有可能是泄漏的,接下来可以多重复几次导致泄漏的操作,看看是否按预期增长,如果按预期增长,那么基本可以确定是泄漏对象。(为了确保准确性,可以在记录长度前先进行 GC)
-
下图中通过查看数组信息,可以看到数组长度为 986,执行一次疑似泄漏操作后,长度变为了 989。因此推测这里每执行一次疑似泄漏操作会 +3 个元素,所以推测执行 10 次疑似泄漏操作后,会增加 30 个,这里经过验证确实是增加 30 个,并且经过一段时间后,依旧没有减少,所以基本可以确定这个数组为泄漏对象

1.2.5 注意频繁出现的引用链
内存泄漏通常会引起很多类似的对象无法被销毁,因此很容易会出现很多对象的引用链是一样的,所以可以在点开某种数据类型后(如DOM元素、object、string),多观察几个对象的引用链,如果某一条类似的引用链频繁出现,那么很有可能该引用链中出现了泄漏。
-
例如下面的图中,10 个Object对象出现了 8 个相同的引用链,因此这条引用链中很可能存在泄漏,通过不断测试可以确定引用链中的InternalNode是无限增长的,到此可以认为这里是存在内存泄漏的,再对HTMLVideoElement进行分析,最终确定是MediaElement和VideoElement存在内存泄漏。


1.3 确定泄漏的对象和代码
在上一步骤中,筛选出一些疑似泄漏的引用链后,开始分析引用链的泄漏对象,确定导致泄漏的某个引用。
通常可以先不看InternalNode对象
1.3.1 从业务逻辑分析哪些是泄漏对象
根据业务逻辑来分析哪些对象是不应该存在的,查找创建或者引用了这个对象的代码片段。可以先搞清楚整个引用链存在的原因,通过引用链上的对象的信息,或者dom元素信息等,来分析这个引用链因为哪一个业务逻辑而存在,根据这个业务逻辑联想可能的泄漏情况,比如常见的事件、定时器是否已清除。
-
例如下图这个引用链,从observerList和domResizeListener这两个对象名可以推测这里使用了监听器模式,domResizeListener是监听器,当改变页面大小的时候,通过调用observerList里的观察者的回调函数,修改某些 dom 的大小。根据这个代码逻辑联想,怀疑某些地方把一些 dom 元素加入到observerList里了,但是 dom 销毁的时候没有取消监听,dom 元素依然存在observerList里,导致无法被回收。

1.3.2 导致泄漏的引用通常“距离”较大
-
导致泄漏的引用比较容易发生在“距离”较大的地方,也就是距离根节点比较远的对象(DOMTimer除外)。距离根节点较远的对象,和业务代码的相关性比较大,距离根节点较近的对象,大多都是一些常驻对象,或者是难以回收的对象;而“距离”较大的对象,往往只会被一个对象引用,因为“距离”大的对象往往是业务代码创建的,而“距离”较小的对象,通常会被很多对象引用,或者是一些底层的框架之类的,往往不容易出现泄漏。
a. 例如下图中,上方的array为泄漏对象,为了避免这个泄漏,从根节点config到data这条引用链路中,回收任意一个对象都能使array被回收,但是如果要回收config,就需要把引用它的player、bound_this、context等都回收才行,一般来说这样距离根节点较近的对象是很难回收的,通常也不是造成泄漏的原因,而这里最终泄漏的原因是,array是缓存的数据,把缓存放进去之后没有及时清理。(引用链中只能看出这个数组被谁引用,看不出是哪里添加的数据,需要根据业务逻辑和代码进行分析)

1.3.3 定位引用所在代码
在对象视图中的代码跳转链接,指出了对象被引用的地方,但并不是说就是这一行代码导致的内存泄漏。例如下图中泄漏的对象是taskCallback,通过代码跳转,指出的代码是a.taskCallback(),说明taskCallback因为a对象的引用而无法被回收。这里经过代码分析得出的结论是:this.intervalTimer没有及时销毁,继而存在引用intervalTimer->a->taskCallback,而导致taskCallback函数及其引用的对象泄漏。


四、排查案例
1、监听器泄漏
-
在抖音PC客户端中,通过自动化测试发现,刷视频会出现持续的内存上涨,所以针对刷视频这个场景进行内存泄漏排查。在刷视频之前和刷视频之后,分别抓取内存快照,选择“比较”对这两个快照进行比较,搜索detached元素,选中一个div查看其引用链

-
引用链表示从引用该div的对象出发,一直到根节点的整个链路。
-
通过观察多个div发现,大部分都存在相同的引用链,即和上图类似。detached元素本身就很可能是泄漏的对象,加上很多detached元素都有相同的引用链,所以这个引用链很可能存在内存泄漏。通过分析这些引用的名字,可以推测使用了监听器,JS代码中比较容易出现泄漏的情况有监听器、定时器,这里怀疑是showDisturbLoginPanel这个对象,它被_events引用,推测这是一个事件,通过鼠标悬停到_events上查看内存,showDisturbLoginPanel是一个数组,里面存放的是函数:


-
展开函数,可以看到函数的代码位置。查看了好几个函数,发现这些函数的位置都是相同的,点开函数:

-
可以看到是监听事件,由此初步推测:每次刷视频,都会监听事件,但是没有及时销毁。
-
有了初步推测之后,接下来再复现一次内存泄漏,然后验证结果是否符合推测:

-
可以看到,再刷一次视频后,数组长度 +3,到这里基本可以确定:showDisturbLoginPanel中引用的函数没有释放而引起泄漏,即事件没有及时销毁。
五、参考文档
1、工具使用
-
前端性能监控实践(二)chrome devtools:
-
Chrome DevTools 之 Profiles,深度性能优化必备:
2、排查方法
-
JS内存泄漏排查方法-Chrome Profiles
-
前端内存泄漏处理 - 掘金
-
使用chrome的devtools查找内存溢出问题 - 掘金
-
JS内存泄漏排查方法
http://www.ayqy.net/blog/js%E5%86%85%E5%AD%98%E6%B3%84%E6%BC%8F%E6%8E%92%E6%9F%A5%E6%96%B9%E6%B3%95/
