乐趣区

关于内存:如何避免JS内存泄漏

简介:很多开发者可能平时并不关怀本人保护的页面是否存在内存透露,起因可能是刚开始简略的页面内存透露的速度很迟缓,在造成重大卡顿之前可能就被用户刷新了,问题也就被暗藏了,然而随着页面越来越简单,尤其当你的页面是 SAP 形式交互时,内存透露的隐患便越来越重大,直到忽然有一天用户反馈说:“操作一会儿页面就卡住不动了,也不晓得为什么,以前不这样的呀”。这篇文章通过一些简略的例子介绍内存透露的考察办法、总结内存透露呈现的起因和常见状况,并针对每种状况总结如何防止内存透露。心愿能对大家有所帮忙。

作者 | 木及
起源 | 阿里技术公众号

很多开发者可能平时并不关怀本人保护的页面是否存在内存透露,起因可能是刚开始简略的页面内存透露的速度很迟缓,在造成重大卡顿之前可能就被用户刷新了,问题也就被暗藏了,然而随着页面越来越简单,尤其当你的页面是 SAP 形式交互时,内存透露的隐患便越来越重大,直到忽然有一天用户反馈说:“操作一会儿页面就卡住不动了,也不晓得为什么,以前不这样的呀”。

这篇文章通过一些简略的例子介绍内存透露的考察办法、总结内存透露呈现的起因和常见状况,并针对每种状况总结如何防止内存透露。心愿能对大家有所帮忙。

一 一个简略的例子

先看一个简略的例子,上面是这个例子对应的代码:



代码 1

代码 1 的逻辑很简略:点击“add date”按钮时会向 dateAry 数组中 push 3000 个 new Date 对象,点击“clear”按钮时将 dateAry 清空。很显著,“add date”操作会造成内存占用一直增长,如果将这个逻辑用在理论利用中便会造成内存透露(不思考成心将代码逻辑设计成这样的状况),上面咱们看一下如何考察这种内存增长呈现的起因以及如何找出内存透露点。

1 heap snapshot

为了防止浏览器插件的烦扰,咱们在 chrome 中新建一个无痕窗口关上上述代码。而后在 chrome 的 devtools 中的 Memory 工具中找到“Heap Snapshot”工具,点击左上角的录制按钮录制一个 Snapshot,而后点击“add date”按钮,在手动触发 GC(Garbage Collect)之后,再次录制一个 Snapshot,重复执行上述操作若干次,像图 1 中操作的那样,失去一系列的 Snapshot。


图 1 录制 Snapshot

图 2 是咱们刚刚失去的 Snapshot 组,其中的第一个是页面初始加载的时候录制的,不难发现,从第二个开始,每个 Snapshot 相比于上一个其大小都减少了约 200KB,咱们点击抉择 Snapshot 2,在 class filter 输入框中处输出 date,能够失去 Snapshot 2 中所有被 Date 结构器结构进去的 JS 对象,也就是 Date 对象,这里看到的结构器跟浏览器外部的实现无关,不用跟 JS 的对象对应。

选中一个 Date 对象,在上面的面板中能够看到所选对象的持有链以及相干持有对象的内存的保留大小(Retained Size),从图中能够看出选中的 Date 对象是 Array 的第 1 个元素(index 从 0 开始),而这个 Array 的持有者是 system/Context 上下文中的 dateAry,system/Context 上下文就是代码中 script 标签的上下文,咱们能够看到在这个 dataAry 的保留大小是 197KB,咱们再切到 Snapshot 3,用雷同的形式查看内存持有和大小,能够发现 Snapshot 3 中的 dataAry 的保留大小变成了 386KB,相比于 Snapshot 2 增涨了约 200KB!逐个比拟前面的 Snapshot 4、5 后也能失去雷同的比照后果,即下一个 Snapshot 中的 dateAry 比上一个的保留大小大概 200KB。

图 2 录制的 Snapshot 组

参考【代码 1】咱们能够晓得,“add date”按钮在被点击时,会向 dateAry 数组中 push 3000 个新的 Date 对象,而在图 2 中的 Date 结构器的右侧能够看到这 3000 个 Date 对象(Date x 3000),它对应的正式咱们的循环创立的那 3000 个 Date 对象。综合下面的操作咱们能够晓得,chorome devtools 中的 Memroy 的 Heap Snapshot 工具能够录制某一个时刻的所有内存对象,也就是一个“快照”,快照中按“结构器”分组,展现了所有被记录下来的 JS 对象。

如果这个页面是一个理论服务于用户的网站的某个页面话(用户可能十分频繁的点击“add date”按钮,作者可能想记录用户点击的次数?兴许吧,尽管我也不晓得他什么要这么做)随着用户应用工夫的增长,“add date”按钮的反馈就会越来越慢,整体页面也随之越来越卡,起因除了零碎的内存资源被占用之外,还有 GC 的频率和时长增长,如图 3 所示,因为 GC 执行的过程中 JS 的执行是被暂停的,所以页面就会呈现出越来越卡的样子。


图 3 Performance 录制的 GC 占比


图 4 chrome 的工作管理器

最终:

图 5 内存占用过高导致浏览器解体

那么,在这个“理论”的场景下,如何找出那“作怪”的 3000 个 Date 对象呢?咱们首先想到的应该是就是:之前不是录制了好多个 Snapshot 吗?可不可以把它们做比照找到“差别”呢,从差别中找到增长的中央不就行了?思路十分正确,在此之前咱们再剖析一下这几个 Snapshot:每次点击“add date”按钮、手动触发 GC、失去的 Snapshot 的大小相比上一次都有所增加,如果这种内存的增长景象不合乎“预期”的话(显然在这个“理论”的例子中是不合乎预期的),那么这里就有很大的嫌疑存在内存透露。

这个时候咱们选中 Snapshot 2,在图 2 所示的 ” Summary” 处抉择“Comparison”,在右侧的 “All objects” 处抉择 Snapshot 1,这样一来,Constructor 里展现便是 Snapshot 1 和 Snapshot 2 的比照,通过观察不难发现,图中的 +144KB 最值得狐疑,于是咱们选中它的结构器 Date,开展选中任意子项看详情,发现其是被 Array 结构器结构进去的 dateAry 持有的(即 dateAry 中的一员), 并且 dateAry 被三个中央持有,其中零碎外部的 array 咱们不必理睬,图 6 中写有 “context in ()” 中央给了咱们持有 dateAry 的 context 所在的地位,点击便能够跳到代码所在的地位了,整个操作如图 6 所示:


图 6 定位代码地位

这里有一个值得注意的中央,图 6 中的“context in () @449305”中的 “()”,这里之所以展现为了 “()” 是因为代码中用了“匿名函数”(代码 2 中第 2 行的箭头函数):

//【写入 date】pushDate.addEventListener("click", () => {dateCount.innerHTML = `${++dateNum}`;

    for (let j = 0; j < 3000; ++j) {dateAry.push(new Date());
    }
});

代码 2 匿名函数

然而如果咱们给函数起一个名字,如上面的代码所示,也就是如果咱们应用具名函数(代码 3 第 2 行函数 add)或者将函数赋值给一个变量并应用这个变量(第 10 和 18 行的行为)的时候,devtools 中都能够看到相应的函数的名字,这也就能够帮忙咱们更好的定位代码,如图 7 所示。

//【写入 date】pushDate.addEventListener("click", function add() {dateCount.innerHTML = `${++dateNum}`;

    for (let j = 0; j < 3000; ++j) {dateAry.push(new Date());
    }
});

const clear = document.querySelector(".clear");

const doClear = function () {dateAry = [];
    dateCount.innerHTML = "0";
};

//【回收内存】clear.addEventListener("click", doClear);

代码 3 具名函数


图 7 具名函数不便定位

这样咱们便找到了代码可疑的中央,只须要将代码的作者抓过去对着他一顿“剖析”这个内存透露的问题根本就上不着天; 下不着地了。

其实,Snapshot 除了“Comparison”之外还有一个更便捷的用于比照的入口,在这里间接能够看到在录制 Snapshot 1 和 Snapshot 2 两个工夫点之间被调配进去的内存,用这种形式也能够定位到那个可疑的 Date x 3000:


图 8 Snapshot 比拟器

上文件介绍的是用 Heap Snapshot 寻找内存透露点的办法,这个办法的长处:能够录制多个 Snapshot,而后不便的两两比拟,并且能看到 Snapshot 中的全量内存,这一点是下文要讲的“Allocation instrumentation on timeline”办法不具备的,并且这种办法能够更加不便地查找前面会讲的因 Detached Dom 导致的内存透露。

2 Allocation instrumentation on timeline

然而,不晓得你有没有感觉,这种高频率地录制 Snapshot、比照、再比照的形式有点儿麻烦?我须要一直的去点击“add date”,而后鼠标又要跑过来点击手动 GC、录制 Snapshot、期待录制结束,再去操作,再去录制。有没有简略一些的形式来查找内存透露?这个时候咱们回到 Memory 最初始的界面,你忽然发现“Heap snapshot”上面还有一个 radio:“Allocation instrumentation on timeline”,并且这个 radio 上面的介绍文案的最初写着:“Use this profile type to isolate memory leaks”,原来这是一个专门用于考察内存透露的工具!于是,咱们选中这个 radio,点击开始录制按钮,而后将注意力放在页面上,而后你发现当点击“add date”按钮时,右面录制的 timeline 便会多出一个心跳:


图 9 Allocation instrumentation on timeline

如图 9 所示,每当咱们点击“add date”按钮时,右面都有一个对应的心跳,当咱们点击“clear”按钮时,方才呈现的所有心跳便全都“缩回”去了,于是咱们得出结论:每一个“心跳”都是一次内存调配,其高度代表内存调配的量,在之后的时间推移过程中,如果方才心跳对应的被调配的内存被 GC 回收了,“心跳”便会跟着变动为回收之后的高度。于是,咱们便解脱了在 Snapshot 中来回操作、录制的困境,只须要将注意力集中在页面的操作上,并察看哪个操作在左边的工夫线变动中是可疑的。

通过一系列操作,咱们发现“add date”这个按钮的点击行为很可疑,因为它调配的内存不会主动被回收,也就是只有点击一次,内存就会增长一点,咱们进行录制,失去了一个 timeline 的 Snapshot,这个时候如果咱们点击某个心跳的话:


图 10 点击某个心跳

相熟的 Date x 3000 又呈现了(图 11),点击一个 Date 对象看持有链,接下来便跟上文 Snapshot 的持有链分析一样了:


图 11 通过 timeline 找到透露点

这个办法的长处上文曾经阐明,能够十分直观、不便的察看内存随可疑操作的调配与回收过程,能够不便的察看每次调配的内存。它的毛病:录制工夫较长时 devtools 收集录制后果的工夫会很长,甚至有时候会卡死浏览器;下文会讲到 detached DOM,这个工具不能比拟出 detached DOM,而 heap snapshot 能够。

3 performance

devtools 中的 Performance 面版中也有一个 Memory 性能,上面看一下它如何应用。咱们把 Memory 勾选上,并录制一个 performance 后果:


图 12 Performance 的录制过程

在图 12 中能够看到,在录制的过程中咱们间断点击“add date”按钮 10 次,而后点击一次“clear”按钮,而后再次点击“add date”10 次,失去的最终后果如图 13 所示:


图 13 Performance 的录制后果

在图 13 中咱们能够失去上面的信息:

  • 整个操作过程中内存的走势:参见图 13 下方的地位,第一轮点击 10 次的过程中内存一直增长,点 clear 之后内存断崖式上涨,第二轮点击 10 次内存又一直增长。这也是这个工具的次要作用:失去可疑操作的内存走势图,如果内存继续走高则有理由狐疑此操作由内存透露的可能。
  • 内存的增长量:参见 JS Heap 地位,鼠标放上去能够看见每个阶梯高低地位的内存增长 / 上涨的量

通过在 timeline 中定位某个“阶梯”,咱们也能找到可疑的代码,如图 14 所示:

图 14 通过 Performance 定位问题代码

这种办法的长处:能够直观得看到内存的总体走势,并且同时失去所有操作过程中的函数调用栈和工夫等信息。毛病:没有具体的内存调配的细节,录制的过程不能实时看到内存调配的过程。

二 内存透露呈现的场景

1 全局

JS 采纳标记打扫法去回收无法访问的内存对象,被挂载在全局对象(在浏览器中即指的是 window 对象,在垃圾回收的角度上称其为根节点,也叫 GC root)上的属性所占用内存是不会被回收的,因为其是始终能够拜访的,这也合乎“全局”的命名含意。

解决方案就是防止用全局对象存储大量的数据。

2 闭包(closure)

咱们把【代码 1】稍加改变便能够失去一个闭包导致内存透露的版本:


代码 3 闭包导致内存透露

将上述代码加载到 chrome 中,并用 timeline 的形式录制一个 Snapshot,失去的后果如图 15 所示:


图 15 闭包的录制后果

咱们选中 index = 2 的心跳,能够看到 Constructor 外面呈现了一个 “(closure)”,咱们开展这个 closure,能够看到外面的 “inner()”,inner() 前面的 “()” 示意 inner 是一个函数,这时候你可能会问:“图中的 Constructor 的 Retained Size 大小都差不多,为什么你要选 (closure)?”,正是因为没有显著占比拟高的 Retained Size 咱们才轻易选一个考察,前面你会发现不论你选了哪一个最初的考察链路都是必由之路的。

咱们在上面的 Retainers 中看下 inner() 的持有细节:从上面的 Retainers 中能够看出 inner() 这个 closure 是某个 Array 的第 2 项(index 从 0 开始),而这个数组的持有者是 system/Context(即全局)中的 ary,通过观察能够看到 ary 的持有大小(Retained Size)是 961KB 大概等于 192KB 的 5 倍,5 即是咱们点击“add date”按钮的次数,而上面的 5 个 “previous in system/Context” 每个大小都是 192KB,而它们最终都是被某个 inner() 闭包持有,至此咱们便能够得出结论:全局中有一个 ary 数组,它的次要内存是被 inner() 填充的,通过蓝色的 index.html:xx 处的代码入口定位到代码所在地看一下所有就都了然了,原来是 inner() 闭包外部持有了一个大对象,并且所有的 inner() 闭包及其持有的大对象都被 ary 对象持有,而 ary 对象是全局的不会被回收,导致了内存透露(如果这种行为不合乎预期的话)。返回去,如果这个时候你抉择下面提到的 system/Context 结构器,你会看到(见图 16,相熟吧):


图 16 system/Context

也就是你抉择的 system/Context 其实是 inner() 闭包的上下文对象(context),而此上下文持有了 192KB 内存,通过蓝色的 index.html:xx 又能够定位到问题代码了。如果你像图 17 一样抉择了 Date 结构器进行查看的话也能够最终定位到问题,此处将剖析过程留给读者本人进行:


图 17 选中 Date 结构器

3 Detached DOM

咱们先看一下上面的代码,并用 chrome 载入它:

代码 4 Detached Dom

而后咱们采纳 Heap Snapshot 的形式将点击“del”按钮前后的两个 snapshot 录制下来,失去的后果如图 6 所示。咱们选用和 snapshot 1 比照的形式并在 snapshot 2 的过滤器中输出 “detached”。咱们察看失去的筛选后果的 “Delta” 列,其中不为 0 的列如下:

要解释上述表格须要先介绍一个知识点:DOM 对象被回收须要同时满足两个条件,1、DOM 在 DOM 树中被删掉;2、DOM 没有被 JS 对象援用。其中第二点还是比拟容易被忽视的。正如下面的例子所示,Detached HTMLButtonElement +1 代表有一个 button DOM 被从组件树中删掉了,然而仍有 JS 援用之(咱们不思考无意为之的状况)。

类似的,Detached EventListener 也是因为 DOM 被删掉了,然而事件没有解绑,于是 Detached 了,解决方案也很简略:及时解绑事件即可。

于是解决的办法就很简略了:参见代码 5,回掉函数 del 在执行结束时长期变量会被回收,于是两个条件就都同时满足了,DOM 对象就会被回收掉,事件解绑了,Detached EventListener 也就没有了。值得注意的是 table 元素,如果一个 td 元素产生了 detached,则因为其本身援用了本人所在的 table,于是整个 table 就也不会被回收了。

代码 5 Detached DOM 的解决办法


图 18 Detached DOM 的 Snapshot

Performance monitor 工具

DOM/event listener 透露在编写轮播图、弹窗、toast 提醒这种工具的时候还是很容易呈现的,chrome 的 devtools 中有一个 Performance monitor 工具能够用来帮忙咱们考察内存中是否有 DOM/event listener 透露。首先看一下代码 6:

代码 6 一直减少 DOM NODE

依照咱们图 19 的形式关上 Performance monitor 面版:


图 19 关上 Performance monitor 工具

DOM Nodes 右侧的数量是以后内存中的所有 DOM 节点的数量,包含以后 document 中存在的和 detached 的以及计算过程中长期创立的,每当咱们点击一次“add date”按钮,并手动触发 GC 之后 DOM Nodes 的数量就 + 2,这是因为咱们向 document 中减少了一个 button 节点和一个 button 的文字节点,就像图 20 中所示。如果你写的 toast 组件在长期插入到 document 并过一会儿执行了 remove 之后处于了 detached 状态的话,Performance monitor 面版中的 DOM Nodes 数量就会一直减少,联合 snapshot 工具你便能够定位到问题所在了。值得一提的是,有的第三方的库的 toast 便存在这个问题,不晓得你被坑过没有。


图 20 一直减少的 DOM Nodes

4 console

这一点可能有人不会留意到,控制台打印的内容是须要始终保持援用的存在的,这一点也是值得注意的,因为打印过多过大对象的话也是会造成内存透露的,如图 21 所示(配合代码 7)。解决办法便是不要肆意打印对象到控制台中,只打印必要的信息进去。


代码 7 console 导致内存透露


图 21 console 导致的内存透露

三 总结

本文用了几个简略的小例子介绍了内存透露呈现的机会、寻找透露点的办法并将各种办法的优缺点进行了比照,总结了避免出现内存透露的留神点。心愿能对读者有所帮忙。文中如果有自己了解谬误或书写谬误的中央欢送留言斧正。

原文链接
本文为阿里云原创内容,未经容许不得转载。

退出移动版