一篇解决内存泄漏问题

lxf2023-03-13 08:40:01

前言

“喔唷,崩溃啦!” 再一看提示: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…