前言
“喔唷,崩溃啦!” 再一看提示:out of memory,内存不足!这是怎么回事呢?
一种可能是往内存中塞入了过多的数据,直接导致内存不足。但这种情况是显而易见的,不难定位到原因;
另一种情况,却要隐蔽得多,那便是:内存泄漏。内存泄漏就是说,有一些“无用”的内存得不到应有的释放,其占据的内存空间不断加大,最终导致可用内存空间不足,造成了程序崩溃。
内存是前端领域一个比较复杂的问题,虽然相关的文章、工具不多,但是其重要性却不言而喻。为了“认清”内存泄漏这一问题,我阅读了很多文章,也做了实验。并将我的理解总结为这篇文章。
文章将从三个方面对内存泄漏展开介绍,分别是:理论篇、工具篇和实验篇。
理论篇讲述的是内存泄漏的概念、发生原因、危害和常见场景;
工具篇展开介绍了目前较好的内存分析工具 —— chrome devtools memory 面板的使用;
实验篇将手把手带你研究一个组件的内存泄漏,并定位到内存泄漏原因,最后归纳出一些研究方法。
1. 理论篇:详解内存管理和内存泄漏
1.1 内存泄漏是什么?
先简单入个门,用大白话讲,内存泄漏就是:某块内存虽然不再使用,但是无法释放。
1.2 内存泄漏是怎么产生的?
这得从 JavaScript 的内存管理机制谈起。先看看红宝书中相关的描述:
JavaScript 通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再使用,然后释放它占有的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预订的收集时间)就会自动运行。垃圾回收是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。
——《JavaScript 高级程序设计(第4版)》4.3 垃圾回收
通过上文可知,内存管理机制的主要任务是:区分变量使用情况,并周期性清除无用变量。
我们再深入内存分配的细节。内存是在堆(heap)中进行分配的,堆包含由对象节点构成的网络,称为内存图(memory graph)。
内存图由两部分构成:
- 节点(Nodes):用其构造函数来标识
- 边(Edges):用属性名来标识
比如:下述对象,obj 是一个 Object 节点,sub 是边,指向另一 Object 节点。
const obj = {
sub: {}
}
内存图始于根节点,称为 GC root。从根节点指向各种节点对象,节点对象指向其他节点对象或者原始值,构成了内存图。那些无法被根节点触达的节点,则会被垃圾回收,如下图的9、10节点。
在 web 应用中,常见的根节点包括:
-
全局对象 window
-
文档 DOM 树:遍历文档可触达的所有 DOM 节点
-
Debugger 和 console
-
传递给 console.log 的对象是不能被垃圾回收的,因为在代码运行之后需要开发工具能查看对象信息。所以最好不要在生产环境中 console.log。
-
只被 console.log 引用的对象,不会进行垃圾回收。而当清除了控制台之后,对象可以被回收。
-
弄清楚了内存的基本原理,我们可以推断出:内存泄漏的罪魁祸首,就是错误的引用。 开发者将无用的变量,添加为生存中的变量的引用,导致这“无用的变量”无法被标记回收,对应的内存空间也无法释放。
我们来看一个具体的例子,帮助你更好地理解内存泄漏发生的场景。
如下图所示,正常情况下,页面创建新组件A,window 对象中存在着对组件A的引用。当组件销毁时,对应的引用也被解除,组件A占用的内存空间被标记为可回收,等待下一次垃圾回收释放内存空间。
而下图展示了内存泄漏的场景。组件B正常活动与页面时,除了在内存中被引用,还在事件监听器中被引用。组件B销毁时,window 的引用正常解除,但是事件监听器的引用没有解除。由于组件B还在被引用着,其占用的内存无法回收。
上面的例子讲述了内存泄漏最常见的场景:组件销毁时,忘记移除添加于全局的事件监听器。
1.3 有什么样的危害?
我们再从代码层面进行分析,更深入地理解内存泄漏,也更直观的看到其对于内存空间的影响。下面以 vue2 代码为例:
// 组件代码
export default {
mounted() {
window.adEventListener('keyup', this.message)
},
methods: {
message(msg) {
console.log(msg)
},
}
}
window.addEventListener 将 this.message 添加为事件回调,引用了 this.message,意味着 this 也无法释放,而在 vue2 中,组件示例 this 还包含着组件数据、组件方法、对应的 DOM 元素,子组件等等,这些内容也都因为被引用而无法释放。
解决问题的方法很简单,只需要在销毁组件前,移除事件监听器即可。如下所示:
// 组件代码
export default {
mounted() {
window.addEventListener('keyup', this.message)
},
beforeDestroy () {
window . removeEvnetListener ( 'keyup' , this . message )
},
methods: {
message(msg) {
console.log(msg)
},
}
}
从这个例子,我们可以看到。一个忘记移除的监听器,会导致所有与之关联的内存无法释放。
1.4 常见内存泄漏问题(敲黑板!划重点!)
下文列举了一些常见的内存泄漏的场景(部分引用自Fixing memory leaks in web applications)。检查一下你的代码中,是否对每种可能导致内存泄漏的场景,都做了相应的措施。
类型 | 说明 |
---|---|
addEventListener | 最常见的一种。添加在 window、body 等全局对象上的事件监听器,销毁组件时如果忘记移除,就会导致内存泄漏。而添加在组件 DOM 上的事件监听器则不需要手动销毁,因为 DOM 在销毁的时候,事件监听器也会自行移除。调用 removeEventListener 清除。由于事件监听器移除时,要求 type、listener,useCapture 三个参数都是一致的,也就是事件类型、监听器函数、是否为捕获监听器都需要保持一致。否则移除将会失败,同样无法解决内存泄漏问题。一种好的做法是,使用调用 addEventListener 时配置的参数去调用 removeEventListener。 |
setTimeout / setInterval | 调用 clearTimeout / clearInterval 清除。 |
IntersectionObserver / ResizeObserver / MutationObserver | 在组件内部创建了一个观察器,并绑定到一个全局变量(body,document,footer,header)上,在组件销毁时,你需要调用 disconnect() 来清除。 |
Promise / Observables / EventEmitters | 如果你忘记停止监听,任何用于设置侦听器的编程模型都可能会造成内存泄漏。如,一个 promise 从未执行 resolved 或 rejected,那么它可能造成内存泄漏。在这种情况下,任何一个这个 Promise 对象上的 then 回调都会泄漏。 |
全局对象存储,如 Redux,vuex | 为它追加内存,永远不会被清理 |
无限新增的 DOM | 在没有虚拟化的情况下,实现无限滚动列表,那么 DOM 节点的数量将无限制地增长。 |
console.log | 被 console.log 打印的对象,在没有清空控制台的情况下,是不会进行垃圾回收的。减少生产环境的 console.log,比如可以在 webpack 中配置自动移除 console.log 语句的选项 |
URL.createObjectURl | 在每次调用 createObjectURL() 方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL() 方法来释放。 |
2. 工具篇:chrome Devtools Memory 面板的基本使用
工欲善其事,必先利其器。想更好地了解内存使用情况,排查内存泄漏问题,一个好的工具是必不可少的。内存分析是一个比较困难的事情,相关的工具、研究方法也比较少,一个比较好的选择是 Chrome Devtools Memory 工具。
Memory 工具提供的功能很丰富,但与之相关的介绍、使用示例都比较少,下面我就结合自己的理解和实验,来介绍这个工具的使用。
2.1 进入工具
右键 -> 检查 -> Memory / 内存
2.2 基本使用
三种性能分析类型可供选择:堆快照、时间轴上的分配插桩(可以理解为堆快照的时间连续版本,且可以选择指定的时间范围)、分配采样(性能开销最小,适用于长时间运行的操作)
每种分析类型都有其适配的场景,这里主要讲述堆快照的使用。
(中文界面设置:右上角设置按钮 -> 外观 -> 语言 -> Chinese)
左上角的三个按钮:
分别是:
- 拍摄堆快照:记录当前内存中所有可触达的对象
- 清除所有性能分析数据
-
回收垃圾:强行进行垃圾回收。因为垃圾回收执行时间存在间隔,可能记录快照时,被标记为可清除的对象还没有被清理。建议在执行拍摄堆快照前,点击回收垃圾,可以获得更准确的数据。
2.3 创建内存中的对象
为了更具体地理解对象在堆快照中,我们定义这样一个构造函数,其中包含 arr,name 和 obj 属性,因为其中包含两个大数组,其占用的内存也比较多(约40MB),易于识别。
function TestClass() {
const size = 1000000;
this.arr = new Array(3 * size).fill(100);
this.name = 'TestClass';
this.obj = {
arr: new Array(7 * size).fill(200),
};
}
在代码中,生成一个 TestClass 实例,并使用 window 对象的 test 属性指向它:
mounted() {
window.test = new TestClass()
}
2.4 拍摄堆快照,查看 summary 面板
点击【拍摄堆快照】,得到堆中对象的一些数据,如下:
各个字段代表信息如下:
- Constructor 表示使用此构造函数创建的所有对象,比如红框中的对象便是 window.test,其构造器为 TestClass,数量为 x1(省略了,超过 1 的数量会显示为:x数量)。点击可展开,看到对象中的属性和方法。
- Distance 显示使用节点的最短简单路径时距根节点的距离。例如 TestClass 实例的访问路径为 window.test,与根节点的距离是 2.
- Shallow size 对象浅层大小的总和。浅层大小是指对象自身占用的内存大小。例如 TestClass 实例自身占用的大小是 56B:包含属性的 key,属性的值,而属性的值有基本值如 string,number 等,也有引用值,引用值只包含指针的大小。
-
Retained size 是该对象自己的 shallow size,加上从该对象能直接或间接访问到的对象的 shallow size 的总和。换句话说,retained size 就是该对象被 GC 之后所能回收到的内存总和。例如 TestClass 实例占用的大小是 40,000,360B,约 38MB,包含了上述 shallow size 的大小,也包含了 arr 指向的数组和 obj 指向的对象,obj.arr 指向的数组的 shallow size。
*一些细节
- @ 后面是对象的 Unique ID,用于唯一标识一个对象
- 构造器后边是实例的数量,如 x2 标识该构造器在内存中共有两个实例
2.5 Comparison、containment 和 statics 面板简介
上面展示的是 summary 面板的数据。其实除了 summary,还有 comparison,containment 和 statics 面板。
Summary 面板:按照构造器分类,展示所有可触达对象。
Comparision 面板:对比两次快照之间的内存差异。
Containment 面板:展示出整个堆结构,从根节点开始。类似于一个“鸟瞰图”。
Statics 面板:展示内存中的数据类型的统计结果,用饼状图呈现。
Comparison 面板:将视图切换到 comparison,并选择要与之比较的快照。
各字段的含义如下:
- New 新增的实例数量
- Deleted 删除项的实例数量
- Delta 增量,就是 new - deleted 的值
- Alloc size 分配的内存大小 (只是 shallow size 的增加,而不是 retained size)
- Freed size 释放的内存大小
-
Size delta 内存增量,即 alloc size - freed size 的值
Containment 面板:将视图切换到 containment,这里形象地展示了堆中的数据,数据的起点就是根节点。比如添加在 window 中的 test 对象,就可以在根节点 Window 中找到:
Statics 面板:
3. 实验篇:探究与解决 vue 组件内存泄漏问题
3.1 实验概述
实验工具:chrome 浏览器 devtools memory 工具;vue 框架;
研究对象:组件 IPlayer;
研究问题:组件 IPlayer 是否发生了内存泄漏?是哪个位置发生了内存泄漏?
3.2 实验步骤与数据记录
步骤 1:构建组件销毁和重建场景
我们构建这样一个场景:可手动控制组件 IPlayer 的销毁与重建。在销毁和重建的不断循环中,研究内存使用量的变化。
这里仍旧以 vue 为例:点击按钮,组件会销毁/重建。注意:这里使用的是 v-if,在 vue 中,它背后是真正的条件渲染,组件会进行销毁和重建;而 v-show 则只是通过样式 display: none 来控制组件的显示与隐藏。
<button @click="show = !show">{{ show ? '销毁' : '重建' }}</button>
<IPlayer
v-if="show"
src="https://d2zihajmogu5jn.cloudfront.net/sintel/master.m3u8"
:muted="true"
:step="20"
:controls="true"
:controlsList="['fastforward', 'shot', 'fullscreen', 'loop', 'rate']"
/>
相关的页面展示如下:页面上只有一个 IPlayer 组件和控制其销毁和重建的按钮。
步骤 2:记录组件销毁和重建后的内存变化
操作方法:
1:打开 chrome devtoools,点击 Memory 面板。
2:待页面加载完成后,再点击 take heap snapshot 按钮,开始记录内存状态。测量三次取平均值。
3:点击销毁,再点击重建数次(多次点击是为了放大内存差距,更容易暴露出内存泄漏的问题)。等待组件加载完毕后,重复步骤:2
4:重复步骤 2 和 3 数次
(可以点击垃圾回收按钮,强制进行垃圾回收)
实验数据记录:
销毁与重建次数 | 内存使用量测量 | 平均值 |
---|---|---|
0 | 9.3 MB | |
3 | 13.63 MB | |
6 | 20.1 MB | |
9 | 24.1 MB |
数据分析:在多次重建和销毁之间,内存稳定上升,初步断定 IPlayer 组件内部发生了内存泄漏。
步骤 3:确认发生内存泄漏的具体位置
我们用 summary 面板来对比相关的数据。按照 retained size 来排序。因为内存中的对象繁多,很难一一比对,而占据较大空间的对象,就成了需要重点关注的对象。
观察到 Hls 对象,数量增加了 3,正好与 3 次销毁重建操作对应。基本可以确定结论:Hls 对象在销毁时没有释放,导致了内存泄漏。
步骤 4:修复内存泄漏问题,重新测量内存情况
找到代码中相应的部分,并做了修改。再次测量内存。看到 Hls 实例稳定在 1 个,且内存相对于之前稳定了许多,可以判断 Hls 销毁问题已经解决。
3.3 实验结论
- 组件 IPlayer 内部发生了内存泄漏,是 Hls 实例在组件销毁时没有释放导致的。通过在组件销毁时,调用 Hls 实例的方法,内存变化恢复到正常范围内,解决了内存泄漏的问题。
-
通过 chrome devtools memory 面板可以很好地查看内存数据,其中 summary 面板对于内存泄漏分析有很大的帮助。下面是一些实验方法总结:
-
可以从内存大小入手,找到大小与上涨大小近似的对象
-
对比多次快照,看哪些对象的增加存在异常
-
可以使用特定倍数(如 3 倍、7 倍)的操作,更易识别
-
在 class filter 中可以搜索 detached,查看已经脱离 document 的 DOM 元素
-
参考资料
EventTarget.removeEventListener() - Web API 接口参考 | MDN (mozilla.org)
MutationObserver.observe() - Web APIs | MDN (mozilla.org)
ResizeObserver - Web APIs | MDN (mozilla.org)
nolanlawson.com/2020/02/19/…
使用 chrome-devtools Memory 面板
Java堆:Shallow Size和Retained Size_kingzone_2008的博客-CSDN博客_retained size
developer.chrome.com/docs/devtoo…
developer.chrome.com/docs/devtoo…
developer.chrome.com/docs/devtoo…