JS内存泄漏

149 阅读13分钟

内存泄漏

什么是内存泄漏

引擎中的垃圾回收机制主要针对程序中不再使用的对象,对其清理回收释放掉内存

虽然说引擎已经针对垃圾回收做了各种优化,来保证垃圾能够回收,但是我们还是需要主动避免一些不利于引擎做垃圾回收的操作

因为不是所有无用对象内存都可以被回收,所以我们把那些已经没用的,并且没有被回收的对象称为内存泄漏

常见的内存泄漏

不正当的闭包

我们先来看一个闭包的例子:

 function fn1(){
     let test = new Array(1000).fill('0')
     return function(){
         console.log('aaa')
     }
 }
 let fn1Child = fn1()
 fn1Child()

这个闭包并没造成内存泄漏,因为返回的函数并没有对fn1函数内部的引用

那么我们再来看一个例子:

 function fn2(){
     let test = new Array(1000).fill('0')
     return function(){
         console.log(test)
         return test
     }
 }
 let fn2Child = fn2()
 fn2Child()

这个闭包跟上面的不一样,因为其引用了函数fn2中的test变量,所以**test并不会被回收**,所以就造成了内存泄漏

解决这个问题的办法也很简单,只要把外部的引用关系置空即可

 function fn2(){
     let test = new Array(1000).fill('0')
     return function(){
         console.log(test)
         return test
     }
 }
 let fn2Child = fn2()
 fn2Child()
 fn2Child = null

隐式全局变量

函数中的局部变量在函数执行结束后就会进行回收

但是对于全局变量,垃圾回收器很难判断这些变量啥时候才不被需要,所以全局变量通常不会被回收

我们可以使用全局变量,但是要避免隐式的全局变量的产生

 function fn(){
     test1 = new Array(1000).fill('0')
     this.test2 = new Array(1000).fill('1')
 }
 fn()

在这个例子中,由于没有显式声明,所以创建了一个隐式全局变量test1

并且,在调用fn的时候,函数中的this也是指向全局变量的,所以也会在全局创建一个隐式全局变量test2

这种情况我们应该尽量避免,比如使用严格模式或者lint检查来避免,从而降低内存成本

但是,我们也不能不使用全局变量,所以对于全局变量,我们应该及时清理,清理的方式与闭包一样,就是使用null赋值即可,特别是在使用全局变量做大量数据缓存的时候,我们要设置存储上限并及时清理,避免内存压力过大

 var test = new Array(1000)
 test = null

游离DOM引用

我们在代码中通常会使用变量缓存DOM节点的引用,但是移除节点的时候,我们应该同步释放缓存的引用否则游离的子树无法释放

 <div id="root">
     <ul id="ul">
         <li></li>
         <li id="li"></li>
         <li></li>
     </ul>
 </div>
 <script>
     let root = document.querySelector('#root')
     let ul = document.querySelector('#ul')
     let li = document.querySelector('#li')
     root.removeChild(ul)
     ul = null
     li = null
 </script>

在上述代码中,我们使用变量缓存了DOM节点,然后打算将ul节点移除掉,但是由于ul变量的存在,所以实际上ul及其子节点都不能被垃圾回收

接下来,我们打算把ul直接置空以至于回收ul节点,但是这么做也不能回收,因为ul中还有一个节点li也被变量引用着,所以ul元素依然不能被垃圾回收

最后,我们将li置空,由于没有变量引用了,所以此时可以被垃圾回收

上面这个例子也就是说,加入我们将父节点置空,但是被删除的父元素其子节点引用也缓存在变量中,那么会导致整个父DOM节点下的整个游离节点树无法清理,所以就会出现内存泄漏,解决方法就是将其引用子节点的变量置空

游离DOM引用

遗忘的定时器

定时器也是我们经常使用的API,也就是setTimeoutsetInterval

 let someResource = getData()    //获取数据
 setInterval(() => {
     const node = document.getElementById('Node')
     if(node) {
         node.innerHTML = JSON.stringify(someResource))
     }
 }, 1000)

在这个例子中,如果setInterval没有结束,那么回调函数中的变量及回调函数本身都无法被回收

而这里的结束则需要调用claerInterval去回收定时器,如果不回收定时器,则回调函数不会被回收,回调函数所依赖的变量也无法被回收,即someResource无法被回收

同样,setTimeout也一样,调用clearTimeout就可以清除

并且浏览器中的requestAnimationFrame 也存在这个问题,我们需要在不需要的时候用cancelAnimationFrame API 来取消使用

遗忘的事件监听器

事件监听器在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除其中引用的变量或者函数都被认为是需要的而不会进行回收

如果内部引用的变量存储了大量数据,则可能会导致页面占用内存过高,造成意外泄漏

 <template>
     <div></div>
 </template>
 ​
 <script>
     export default {
         created() {
             window.addEventListener("resize", this.doSomething)
         },
         beforeDestroy(){
             window.removeEventListener("resize", this.doSomething)
         },
         methods: {
             doSomething() {
                 // do something
             }
         }
     }
 </script>

遗忘的监听者模式

监听者模式实现一些消息通信都是非常常见的,比如 EventBus. . .

当我们实现了监听者模式并在组件内挂载相关的事件处理函数,而在组件销毁时不主动将其清除时,其中应用的变量或者函数都被认为是需要的而且不会进行回收

如果内部引用的变量存储了大量数据,则可能会导致页面占用内存过高,造成意外泄漏

 <template>
     <div></div>
 </template>
 ​
 <script>
     export default {
         created() {
             eventBus.on("test", this.doSomething)
         },
         beforeDestroy(){
             eventBus.off("test", this.doSomething)
         },
         methods: {
             doSomething() {
                 // do something
             }
         }
     }
 </script>

遗忘的Map、Set对象

使用 MapSet 存储对象时,跟 Object 一样都是强引用,如果不将其主动清除引用,其同样会造成内存不自动进行回收

如果需要使用Map,并且键为对象,则可以采用WeakMap,因为**WeakMap用来保存键值对,对于键是弱引用并且为一个对象,而值可以是任意的对象或者原始值**,由于对对象的弱引用,所以不会干扰JS的垃圾回收机制

Set也是一样,不会干扰JS的垃圾回收机制

这里我们详细了解一下强引用和弱引用

  • 强引用:如果我们持有一个对象的引用,那么垃圾回收机制就不会回收这个对象,这个引用就是强引用
  • 弱引用:如果一个对象只被弱引用所引用,则被认为是不可访问的,因此可能在任何时刻被回收

例子:

 let obj = {id: 1}
 obj = null

在这个例子中,我们通过一个简单的重写来清除对象的引用,则可以使其回收

再看一个例子:

 let obj = {id: 1}
 let user = {info: obj}
 let set = new Set([obj])
 let map = new Map([[obj, '0']])
 ​
 // 重写obj
 obj = null 
 ​
 console.log(user.info) // {id: 1}
 console.log(set)
 console.log(map)

在这个例子中,我们重写了obj,但是{id: 1}依然存在于内存中,因为user对象以及后面的set/map还在强引用它,由于都是强引用,所以我们仍然可以获取到{id: 1}想要清除就只能重写所有引用将其置空了

再来看一个例子:

 let obj = {id: 1}
 let weakSet = new WeakSet([obj])
 let weakMap = new WeakMap([[obj, 'hahaha']])
 ​
 // 重写obj引用
 obj = null

在这个例子中,使用了WeakMapWeakSet,因为其为弱引用,所以obj引用置为null之后,对象{id: 1}将在下一次垃圾回收时被清理出内存

未清理的Console输出

我们之所以在控制台能看到数据输出,是因为浏览器帮助我们保存了输出对象的信息数据引用

也正是因此,未清理的console如果输出了对象也会造成内存泄漏

内存泄漏排查、定位、修复

这里我们要使用一个例子,从浏览器的角度反推排查是否存在内存泄漏,存在的话则定位并修复

 <!DOCTYPE html>
 <html lang="en">
 ​
     <head>
         <meta charset="UTF-8">
         <title>test</title>
     </head>
 ​
     <body>
         <button id="click">click</button>
         <h1 id="content"></h1>
 ​
         <script>
             let click = document.querySelector("#click");
             let content = document.querySelector("#content")
             let arr = []
 ​
             function closures() {
                 let test = new Array(1000).fill('0')
 ​
                 return function () {
                     return test
                 }
             }
 ​
             click.addEventListener("click", function () {
                 arr.push(closures())
                 arr.push(closures())
 ​
                 content.innerHTML = arr.length
             });
 ​
         </script>
     </body>
 ​
 </html>

很容易看出来,这是一个闭包的不正确使用造成的内存泄漏问题

我们每点击一次按钮就会添加两个闭包引用到数组中,所以随着点击次数的增加,页面会逐渐卡顿

所以现在,我们要假设我们不知道问题的所在,然后来反推出造成这个问题的原因

排查问题

首先,我们需要开启浏览器控制台的Performance面板,这是用来监控性能指标的工具

其中的各个按钮的功能为:

Performance按钮功能.png

接下来开始测试:

  • 首先要把Memory选项勾选,该选项会开启内存分析
  • 然后点击开始录制(1),并清理一下GC(6)
  • 接着点击页面中的按钮100次,这时页面的数值为200,点完之后再点击一下小垃圾桶(6),手动触发一次GC
  • 再点击页面中的按钮100次,这时候页面上的数值为400,然后停止录制

现在我们就可以观察面板中的数据信息了:

录制完的性能情况.png

可以看到,数据内存不断上涨

在200的时候,我们点击了一下小垃圾桶,也就是图中间轻微下降的折点,这是我们手动触发垃圾回收机制的结果,但是清理后的内存并没有减少很多,所以我们可以判断,该程序的点击操作可能存在内存泄漏

所以现在,我们就可以定位到内存泄漏存在于该点击事件上但是还不能知道其造成原因和位置,所以我们接着继续分析

分析问题

浏览器的开发者工具中还有Memory面板,这个面板可以为我们提供更加详细的信息,如记录JS CPU的执行时间细节,显示JS对象和相关DOM节点的内存消耗、记录内存的分配细节等

其中,Heap Profiling可以记录当前堆内存heap的快照,并生成对象的描述文件,该描述文件给出了当前JS运行所用的所有对象,以及这些对象所占用的内存大小、引用层级关系等等

Memory按钮功能.png

接下来开始测试:

  • 首先点击一下垃圾桶(3),触发一下GC

  • 点击开始生成快照(1),生成第一次快照并选中

    生成快照1.png

    在这个图中,我们分析一下各个部分代表什么:

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

    选择Snapshot 1后就是右侧视图表格了,表格左上方的下拉框有三个值:

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

    接下来我们再了解一下Summary选项数据表格列都代表什么:

    • Constructor显示所有的构造函数,点击每一个构造函数可以查看由该构造函数创建的所有对象
    • Distance显示通过最短的节点路径到根节点的距离,引用层级
    • Shallow Size显示对象所占内存,不包含内部引用的其他对象所占的内存
    • Retained Size显示对象所占的总内存,包含内部引用的其他对象所占的内存
  • 接下来,我们先点击小垃圾桶手动执行一次GC,然后点击1次页面的按钮,最后再点击生成快照按钮,生成快照

  • 再重复上步操作,但是点击2次页面按钮

  • 再重复上步操作,但是点击3次页面按钮

    现在我们查看一下快照的内存区别,将Summary切换为comparison即可:

    比较快照.png

    我们再来看看这一页的表格代表的分别是啥:

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

    这样就容易看出,只要Delta为正数,就有可能存在问题

    接下来我们再来了解一下Constructor这一列:

    • system、system/Context: 表示引擎自己创建的以及上下文创建的一些引用,这些不用太关注
    • closure :表示一些函数闭包中的对象引用
    • array、string、number、regexp :这一系列也能看出,就是引用了数组、字符串、数字或正则表达式的对象类型
    • HTMLDivElement、HTMLAnchorElement、DocumentFragment:等等这些其实就是代码中对元素的引用或者指定的 DOM 对象引用
  • 接下来我们就可以进行快照间的比较了,由于使用comparison比较麻烦,因为有4个快照我们得手动分析3次(看Delta列为正数在进行分析),所以现在浏览器给我们更简单的方式,使用右侧表格上的All Object弹框,里面就有各个快照的比对,并且还会过滤无效信息

    快照比较2.png

  • 我们来比较快照1和快照2,看看能得到什么结果

    比较快照1和快照2.png

    可以看出,这里浏览器已经帮我们过滤,剩下4项差异让我们比较

    其中system/Context我们无需关心,clouse也就是闭包,我们可以查看其详细信息

    比较快照找出问题位置.png

    可以看到,我们再生成快照2的时候手动执行了一次GC,并且触发了一次点击事件,添加了两个闭包引用,也可以定位到代码所在的位置

    所以这里可以得出一个结论:位于代码21行的闭包引用数组会造成内存泄漏

    除了clouse,我们还要关注一下两个数组Arrayarray,其实这两个是存在区别的,Array是指暴露在全局中的数组本身,而array表示的是闭包内部引用的数组变量test,可以通过引用层级来判断,因为array层级8比Array层级7要深

    所以这里又可以得出一个结论:全局变量arr的元素不断增多造成内存泄漏

修复验证

更加上述的查找方法,就能够定位到问题所在,并进行针对性的修复

比如全局对象增大的问题,我们虽然不能避免,但是可以限制一下全局对象的大小,增大到一定程度就清理一部分

比如闭包引用的问题,不让他引用,或者执行完置空

内存三大件

在前端,内存的问题主要有三个:

内存泄漏

对象已经不再使用但没有被回收,内存没有被释放,即内存泄漏

想要避免就避免让无用数据还存在引用关系,也就是多注意我们上面说的常见的几种内存泄漏的情况

内存膨胀

在短时间内内存占用极速上升到达一个峰值,想要避免需要使用技术手段减少对内存的占用

频繁GC

GC 执行的特别频繁,一般出现在频繁使用大的临时变量导致新生代空间被装满的速度极快,而每次新生代装满时就会触发 GC,频繁 GC 同样会导致页面卡顿

想要避免的话就不要使用太多的临时变量,因为临时变量不用了就会被回收,这和我们内存泄漏中说避免使用全局变量冲突

参考

juejin.cn/post/698418…