title: Javascript内存泄露
categories: Javascript
tag:

  • 内存泄露
    date: 2021-12-13 16:39:34

内存泄露

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

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

引起内存泄露的原因

1、意外的全局变量

由于 js 对未声明变量的处理方式是在全局对象上创建该变量的引用。如果在浏览器中,全局对象就是 window 对象。变量在窗口关闭或重新刷新页面之前都不会被释放,如果未声明的变量缓存大量的数据,就会导致内存泄露。

  • 未声明变量
  1. function fn() {
  2. a = 'global variable'
  3. }
  4. fn()
  5. 复制代码
  • 使用 this 创建的变量(this 的指向是 window)。
  1. function fn() {
  2. this.a = 'global variable'
  3. }
  4. fn()

解决方法:

  • 避免创建全局变量
  • 使用严格模式,在 JavaScript 文件头部或者函数的顶部加上 use strict

2、闭包引起的内存泄漏

原因:闭包可以读取函数内部的变量,然后让这些变量始终保存在内存中。如果在使用结束后没有将局部变量清除,就可能导致内存泄露。

  1. function fn() {
  2. var a = "I'm a"
  3. return function () {
  4. console.log(a)
  5. }
  6. }

解决:将事件处理函数定义在外部,解除闭包,或者在定义事件处理函数的外部函数中。

比如:在循环中的函数表达式,能复用最好放到循环外面。

  1. // bad
  2. for (var k = 0; k < 10; k++) {
  3. var t = function (a) {
  4. // 创建了10次 函数对象。
  5. console.log(a)
  6. }
  7. t(k)
  8. }
  9. // good
  10. function t(a) {
  11. console.log(a)
  12. }
  13. for (var k = 0; k < 10; k++) {
  14. t(k)
  15. }
  16. t = null

3、没有清理的 DOM 元素引用

原因:虽然别的地方删除了,但是对象中还存在对 dom 的引用。

  1. // 在对象中引用DOM
  2. var elements = {
  3. btn: document.getElementById('btn')
  4. }
  5. function doSomeThing() {
  6. elements.btn.click()
  7. }
  8. function removeBtn() {
  9. // 将body中的btn移除, 也就是移除 DOM树中的btn
  10. document.body.removeChild(document.getElementById('button'))
  11. // 但是此时全局变量elements还是保留了对btn的引用, btn还是存在于内存中,不能被GC回收
  12. }

解决方法:手动删除,elements.btn = null

4、被遗忘的定时器或者回调

定时器中有 dom 的引用,即使 dom 删除了,但是定时器还在,所以内存中还是有这个 dom。

  1. // 定时器
  2. var serverData = loadData()
  3. setInterval(function () {
  4. var renderer = document.getElementById('renderer')
  5. if (renderer) {
  6. renderer.innerHTML = JSON.stringify(serverData)
  7. }
  8. }, 5000)
  9. // 观察者模式
  10. var btn = document.getElementById('btn')
  11. function onClick(element) {
  12. element.innerHTMl = "I'm innerHTML"
  13. }
  14. btn.addEventListener('click', onClick)

解决方法:

  • 手动删除定时器和 dom。
  • removeEventListener 移除事件监听

内存泄露的识别方法

怎样可以观察到内存泄漏呢?

经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。这就要求实时查看内存占用。

1 浏览器

Chrome 浏览器查看内存占用,按照以下步骤操作。

  • 打开开发者工具 Performance
  • 勾选 Screenshots 和 memory
  • 左上角小圆点开始录制(record)
  • 停止录制

31_Javascript内存泄露 - 图1

  1. 打开开发者工具,选择 Timeline 面板
  2. 在顶部的Capture字段里面勾选 Memory
  3. 点击左上角的录制按钮。
  4. 在页面上进行各种操作,模拟用户的使用情况。
  5. 一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况。

如果内存占用基本平稳,接近水平,就说明不存在内存泄漏。

31_Javascript内存泄露 - 图2

反之,就是内存泄漏了。

31_Javascript内存泄露 - 图3

2 命令行

命令行可以使用 Node 提供的[process.memoryUsage](https://nodejs.org/api/process.html#process_process_memoryusage)方法。

  1. console.log(process.memoryUsage())
  2. // { rss: 27709440,
  3. // heapTotal: 5685248,
  4. // heapUsed: 3449392,
  5. // external: 8772 }

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,单位是字节,含义如下。

31_Javascript内存泄露 - 图4

  • rss(resident set size):所有内存占用,包括指令区和堆栈。
  • heapTotal:”堆”占用的内存,包括用到的和没用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎内部的 C++ 对象占用的内存。

判断内存泄漏,以heapUsed字段为准。

排查问题

接下来我们就使用 chrome 浏览器提供的工具来排查内存泄露

Chrome 的开发者工具也就是我们所说的浏览器控制台(Chrome Devtool )功能其实十分强大,通过它可以帮助我们分析程序中像性能、安全、网络等各种东西,也可以让我们快速定位到问题源,只是大多数人并不熟悉其使用而已。

由于此文我们以内存泄漏为主,那我们就默认上述程序已经排查了除内存之外所有项且都没问题,接下来开始排查内存这块。

首先我们开启浏览器的无痕模式,接着打开要检查的网页程序代码,然后打开控制台,整个程序界面非常简单,如下图:

编写代码

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <title>test</title>
  6. </head>
  7. <body>
  8. <button id="click">click</button>
  9. <h1 id="content"></h1>
  10. <script>
  11. let click = document.querySelector('#click')
  12. let content = document.querySelector('#content')
  13. let arr = []
  14. function closures() {
  15. let test = new Array(1000).fill('isboyjc')
  16. return function () {
  17. return test
  18. }
  19. }
  20. click.addEventListener('click', function () {
  21. arr.push(closures())
  22. arr.push(closures())
  23. content.innerHTML = arr.length
  24. })
  25. </script>
  26. </body>
  27. </html>

如上所示,这是一个非常简单的由不正当使用闭包构成的内存泄漏例子。

我们先来简单介绍下,只看 script 中的 JS 代码即可,首先,我们有一个 closures 函数,这是一个闭包函数,最简单的闭包函数想必不用向大家介绍了吧,然后我们为页面中的 button 元素绑定了一个点击事件,每次点击都将执行 2 次闭包函数并将其执行结果 push 到全局数组 arr 中,由于闭包函数执行结果也是一个函数并且存在对原闭包函数内部数组 test 的引用,所以 arr 数组中每一项元素都使得其引用的闭包内部 test 数组对象无法回收,arr 数组有多少元素,也就代表着我们存在多少次闭包引用,所以此程序点击次数越多,push 的越多,内存消耗越大,页面也会越来越卡。

那为了便于后期观察,程序中我们在每次点击按钮后,都把全局数组 arr 的长度数据更新到了页面上,即从 0 开始,每点击一次,页面数值加 2。

当然,这是我们自己写的例子,作为上帝的我们知道是什么原因导致的,那现在,忘掉这些,假设这是我们的一个项目程序。

31_Javascript内存泄露 - 图5

接下来开始操作,在开始之前一定要确认勾选了 Memory 选项也就是上图标记 5 ,这样我们才可以看到内存相关的分析。

点击开始录制(标记 1)进入录制状态,随后先清理一下 GC,也就是点击小垃圾桶(标记 6)。

接着疯狂点击页面中 click 按钮 100 次,这时页面上的数值应该是 200,我们再点击一下小垃圾桶,手动触发一次 GC。

再次疯狂点击页面中 click 按钮 100 次,这时页面上的数值应该是 400,然后停止录制。

我们来观察控制台生成的数据面板,如下图:

31_Javascript内存泄露 - 图6

上面圈红的两块,也就是 Heap 对应的部分表示内存在周期性的回落,简单说就是我们的内存情况。

我们可以很明显的看到,内存数据呈现出一个不断上涨的趋势,可能有人会说这段时间内是不是还没执行 GC 呢?别急,还记得我们在 200 的时候点击了一下小垃圾桶吗,也就是我们中间手动触发垃圾回收一次,我们就可以通过上面的页面快照找出当页面值为 200 的那一刻在哪里,很简单,鼠标移到内存快照上找就行了,如下图:

31_Javascript内存泄露 - 图7

可以看到,即使我们中间手动做了一次垃圾回收操作,但清理后的内存并没有减少很多,由此我们推断,此程序的点击操作可能存在内存泄漏。

OK,排查到问题了,那接下来就是定位泄漏源在哪了。

你可能会说,既然已经找到问题所在就是点击事件了,直接去改不就完了?

要知道,这是我们写的一个简单的例子,我们一下子就可以看出问题在哪,但是真实项目中一个点击事件里就可能存在大量操作,而我们只知道点击事件可能导致了内存泄漏,但不知道具体问题是在点击事件的哪一步骤上,更加细粒度的引起原因和位置我们也不知,这些都还需要我们进一步分析去定位。

分析定位

接下来我们开始分析定位泄漏源

Chrome Devtool 还为我们提供了 Memory 面板,它可以为我们提供更多详细信息,比如记录 JS CPU 执行时间细节、显示 JS 对象和相关的 DOM 节点的内存消耗、记录内存的分配细节等。

其中的 Heap Profiling 可以记录当前的堆内存 heap 的快照,并生成对象的描述文件,该描述文件给出了当下 JS 运行所用的所有对象,以及这些对象所占用的内存大小、引用的层级关系等等,用它就可以定位出引起问题的具体原因以及位置。

注意,可不是 Performance 面板下那个 Memory ,而是与 Performance 面板同级的 Memory 面板,如下图:

31_Javascript内存泄露 - 图8

现在页面值为 400,当然也可以刷新一下页面从 0 开始,这里我们选择继续操作

首先点击一下小垃圾桶(标记 3),触发一下 GC,把没用的东西从内存中干掉

点击开始生成快照(标记 1),生成第一次快照并选中,如下图:

31_Javascript内存泄露 - 图9

简单介绍小上图大概表示的什么意思:

左侧列表中的 Snapshot 1 代表了我们生成的快照 1,也就是刚刚那一刻的内存状态

选中 Snapshot 1 后就是右侧视图表格了,表格左上方有一个下拉框,它有四个值

  • Summary:按照构造函数进行分组,捕获对象和其使用内存的情况,可理解为一个内存摘要,用于跟踪定位 DOM 节点的内存泄漏
  • Comparison:对比某个操作前后的内存快照区别,分析操作前后内存释放情况等,便于确认内存是否存在泄漏及造成原因
  • Containment:探测堆的具体内容,提供一个视图来查看对象结构,有助分析对象引用情况,可分析闭包及更深层次的对象分析
  • Statistics:统计视图

该下拉默认会为我们选择 Summary ,所以下方表格展示的就是快照 1 中数据的内存摘要,简单理解就是快照 1 生成的那一刻,内存中都存了什么,包括占用内存的信息等等。

来简单了解下 Summary 选项数据表格的列都表示什么

  • Constructor:显示所有的构造函数,点击每一个构造函数可以查看由该构造函数创建的所有对象
  • Distance:显示通过最短的节点路径到根节点的距离,引用层级
  • Shallow Size:显示对象所占内存,不包含内部引用的其他对象所占的内存
  • Retained Size:显示对象所占的总内存,包含内部引用的其他对象所占的内存

OK,暂时知道这么多就可以了,我们继续操作,先点击小垃圾桶手动执行一次 GC,然后点击 1 下页面的 click 按钮,最后再次点击生成快照按钮,生成我们的第二次快照。

为了准确无误,我们多来几次操作,如下:

先点击小垃圾桶手动执行一次 GC,然后点击 2 下页面的 click 按钮,最后再次点击生成快照按钮,生成我们的第三次快照

先点击小垃圾桶手动执行一次 GC,然后点击 3 下页面的 click 按钮,最后再次点击生成快照按钮,生成我们的第四次快照

随后,我们选中快照 2,并将其上面的下拉框由默认的 Summary 选项切换为 comparison 选项,也就是对比当前快照与之前一次快照的内存区别,如下图:

31_Javascript内存泄露 - 图10

我们再来看看选择 Comparison 下拉后,下方的表格列代表着什么,这里介绍几个重要的

  • New:新建了多少个对象
  • Deleted:回收了多少个对象
  • Delta:新建的对象数 减去 回收的对象数

诶,到这我们就有点那味儿了,我们需要重点关注 Delta ,只要它是正数就可能存在问题,贴心的控制台都已经给我们排好序了,最上面的几个我们依次看就可以。

当然,我们还需要知道这每一行的数据都代表的是什么,注意力转移到 Constructor 这一列,我们也说过,此列是构造函数,每一个构造函数点击都可以查看由该构造函数创建的所有对象,还是要先介绍下此列中常见的构造函数大致代表什么

  • system、system/Context 表示引擎自己创建的以及上下文创建的一些引用,这些不用太关注,不重要
  • closure 表示一些函数闭包中的对象引用
  • array、string、number、regexp 这一系列也能看出,就是引用了数组、字符串、数字或正则表达式的对象类型
  • HTMLDivElement、HTMLAnchorElement、DocumentFragment 等等这些其实就是你的代码中对元素的引用或者指定的 DOM 对象引用

诶,又清晰了很多,那接下来我们就可以依次对比 1->2 / 2->3 / 3->4 来看到底哪里有问题了。

别着急,想一下现在的我们要怎么做?需要单独的点击一个快照再选中 comparison ,然后看 Delta 列为正数的项再进行分析,这样的操作需要进行 3 次,因为我们有 4 个快照,需要对比分析 3 次,甚至有时候可能生成的快照更多以此来确保准确性。

有没有更简单的方式呢?有的,我们可以直接选中要对比的快照,右侧表格上还有一个弹框我们可以直接选择快照进行对比,并且还会贴心的为我们过滤掉一些没用的信息:

31_Javascript内存泄露 - 图11

我们来进行实际操作,左侧选中快照 2,选择 快照1快照2 进行对比分析,结果如下:

31_Javascript内存泄露 - 图12

可以看到,列表中只剩下对比过滤后的 4 项差异

system/Context 我们无需关心。

closure 上面也说过代表闭包引用,我们点击此项看一下具体的信息:

31_Javascript内存泄露 - 图13

可以看到, closure 下有两个引用,还贴心的为我们指出了在代码的 21 行,点击选中其中一个引用,下方还有更详细的信息展示。

为什么展开后是两个引用?还记得我们在生成 快照2 时的操作吗,手动执行了一次 GC 并点击了一次 click 按钮,触发了一次点击事件,点击事件中我们执行并 push 了两次闭包函数,所以就是 2 条记录。

最后我们看 array ,这里存在数组的引用是完全因为我们案例代码中那个全局数组变量 arr 的存在,毕竟每次点击都 push 数据呢,这也是我们上面提到的为什么要额外关注全局变量的使用、要将它及时清理什么的,就是因为像这种情况你不清理的话这些全局变量在页面关闭前就一直在内存里,可能大家对构造函数列中有 2 项都是数组有疑问,其实没毛病,一项代表的是 arr 本身,一项代表的是闭包内部引用的数组变量 test (忘了的话回顾一下上面案例代码),这点也可以通过 Distance 列中表示的引用层级来 GET,一个层级是 7,一个层级是 8。至于数组引起泄漏的代码位置我们也可以点击展开并选中其引用条目,详情里就可以看到代码位置,同上面闭包一样的操作,这里就不演示了。

诶,那好像就知道具体的泄漏源了,我们再次证实一下,左侧选中快照 4,选择 快照3快照4 进行对比分析,快照 4 前我们做的操作是手动执行了一次 GC 并点击了三次 click 按钮,如果上述结论正确的话,应该同我们上面 快照1快照2 对比结果的数据项一致都是 4 项,但是每项的内部引用会是 6 条,因为这次快照前我们点击了三次按钮,每次执行并 push 了两次闭包函数,来看结果:

31_Javascript内存泄露 - 图14

嗯,到这里一切好像变得清晰明朗了,问题一共有 2 个,一是代码 21 行的闭包引用数组造成的内存泄漏,二是全局变量 arr 的元素不断增多造成的内存泄漏。

分析定位成功,进入下一步骤,修复并再次验证。

修复验证

由于这是临时写的一个案例,没有具体的场景,所以也就没有办法使用针对性的方式来修复,So,此步骤暂时忽略,不过在项目中我们还是要解决的。

比如全局对象一直增大这个问题,全局对象我们无法避免,但是可以限制一下全局对象的大小,根据场景可以超出就清理一部分。

比如闭包引用的问题,不让它引用,或者执行完置空,这都是上面说过的。

总之,一切都需要根据具体场景选择解决方案,解决之后重复上面排查流程看内存即可。

参考

  1. 掘金作者https://juejin.cn/post/6984188410659340324#heading-11