如何避免JS内存泄漏
!DOCTYPEhtmlhtmllang="en"headmetacharset="UTF-8"/titlememory-leak/title/headbodyppushdateforbuttonclass="count-date"0/buttontimes/ppaddDate:buttonclass="push-date"adddate/button/ppclear:buttonclass="clear"clear/button/pscriptconstpushDate=document.querySelector(".push-date");constdateCount=document.querySelector(".count-date");letdateAry=[];letdateNum=0;//pushDate.addEventListener("click",()={dateCount.innerHTML=`${++dateNum}`;for(letj=0;j;++j){dateAry.push(newDate());}});constclear=document.querySelector(".clear");//clear.addEventListener("click",()={dateAry=[];dateCount.innerHTML="0";});/script/body/html
代码1
代码1的逻辑很简单:点击“adddate”按钮时会向dateAry数组中push个newDate对象,点击“clear”按钮时将dateAry清空。很明显,“adddate”操作会造成内存占用不断增长,如果将这个逻辑用在实际应用中便会造成内存泄漏(不考虑故意将代码逻辑设计成这样的情况),下面我们看一下如何调查这种内存增长出现的原因以及如何找出内存泄漏点。1heapsnapshot为了避免浏览器插件的干扰,我们在chrome中新建一个无痕窗口打开上述代码。然后在chrome的devtools中的Memory工具中找到“HeapSnapshot”工具,点击左上角的录制按钮录制一个Snapshot,然后点击“adddate”按钮,在手动触发GC(GarbageCollect)之后,再次录制一个Snapshot,反复执行上述操作若干次,像图1中操作的那样,得到一系列的Snapshot。图1录制Snapshot图2是我们刚刚得到的Snapshot组,其中的第一个是页面初始加载的时候录制的,不难发现,从第二个开始,每个Snapshot相比于上一个其大小都增加了约KB,我们点击选择Snapshot2,在classfilter输入框中处输入date,可以得到Snapshot2中所有被Date构造器构造出来的JS对象,也就是Date对象,这里看到的构造器跟浏览器内部的实现有关,不必跟JS的对象对应。选中一个Date对象,在下面的面板中可以看到所选对象的持有链以及相关持有对象的内存的保留大小(RetainedSize),从图中可以看出选中的Date对象是Array的第1个元素(index从0开始),而这个Array的持有者是system/Context上下文中的dateAry,system/Context上下文就是代码中script标签的上下文,我们可以看到在这个dataAry的保留大小是KB,我们再切到Snapshot3,用相同的方式查看内存持有和大小,可以发现Snapshot3中的dataAry的保留大小变成了KB,相比于Snapshot2增涨了约KB!逐一比较后面的Snapshot4、5后也能得到相同的对比结果,即下一个Snapshot中的dateAry比上一个的保留大小大约KB。图2录制的Snapshot组参考我们可以知道,“adddate”按钮在被点击时,会向dateAry数组中push个新的Date对象,而在图2中的Date构造器的右侧可以看到这个Date对象(Datex),它对应的正式我们的循环创建的那个Date对象。综合上面的操作我们可以知道,choromedevtools中的Memroy的HeapSnapshot工具可以录制某一个时刻的所有内存对象,也就是一个“快照”,快照中按“构造器”分组,展示了所有被记录下来的JS对象。如果这个页面是一个实际服务于用户的网站的某个页面话(用户可能非常频繁的点击“adddate”按钮,作者可能想记录用户点击的次数?也许吧,虽然我也不知道他什么要这么做)随着用户使用时间的增长,“adddate”按钮的反应就会越来越慢,整体页面也随之越来越卡,原因除了系统的内存资源被占用之外,还有GC的频率和时长增长,如图3所示,因为GC执行的过程中JS的执行是被暂停的,所以页面就会呈现出越来越卡的样子。图3Performance录制的GC占比图4chrome的任务管理器最终:图5内存占用过高导致浏览器崩溃那么,在这个“实际”的场景下,如何找出那“作祟”的个Date对象呢?我们首先想到的应该是就是:之前不是录制了好多个Snapshot吗?可不可以把它们做对比找到“差异”呢,从差异中找到增长的地方不就行了?思路非常正确,在此之前我们再分析一下这几个Snapshot:每次点击“adddate”按钮、手动触发GC、得到的Snapshot的大小相比上一次都有所增加,如果这种内存的增长现象不符合“预期”的话(显然在这个“实际”的例子中是不符合预期的),那么这里就有很大的嫌疑存在内存泄漏。这个时候我们选中Snapshot2,在图2所示的"Summary"处选择“Comparison”,在右侧的"Allobjects"处选择Snapshot1,这样一来,Constructor里展示便是Snapshot1和Snapshot2的对比,通过观察不难发现,图中的+KB最值得怀疑,于是我们选中它的构造器Date,展开选中任意子项看详情,发现其是被Array构造器构造出来的dateAry持有的(即dateAry中的一员),并且dateAry被三个地方持有,其中系统内部的array我们不用理会,图6中写有"contextin()"地方给了我们持有dateAry的context所在的位置,点击便可以跳到代码所在的位置了,整个操作如图6所示:图6定位代码位置这里有一个值得注意的地方,图6中的“contextin()”中的"()",这里之所以展示为了"()"是因为代码中用了“匿名函数”(代码2中第2行的箭头函数)://pushDate.addEventListener("click",()={dateCount.innerHTML=`${++dateNum}`;for(letj=0;j;++j){dateAry.push(newDate());}});代码2匿名函数但是如果我们给函数起一个名字,如下面的代码所示,也就是如果我们使用具名函数(代码3第2行函数add)或者将函数赋值给一个变量并使用这个变量(第10和18行的行为)的时候,devtools中都可以看到相应的函数的名字,这也就可以帮助我们更好的定位代码,如图7所示。
//pushDate.addEventListener("click",functionadd(){dateCount.innerHTML=`${++dateNum}`;for(letj=0;j;++j){dateAry.push(newDate());}});constclear=document.querySelector(".clear");constdoClear=function(){dateAry=[];dateCount.innerHTML="0";};//clear.addEventListener("click",doClear);代码3具名函数图7具名函数方便定位这样我们便找到了代码可疑的地方,只需要将代码的作者抓过来对着他一顿“分析”这个内存泄漏的问题基本就水落石出了。其实,Snapshot除了“Comparison”之外还有一个更便捷的用于对比的入口,在这里直接可以看到在录制Snapshot1和Snapshot2两个时间点之间被分配出来的内存,用这种方式也可以定位到那个可疑的Datex:图8Snapshot比较器上文件介绍的是用HeapSnapshot寻找内存泄漏点的方法,这个方法的优点:可以录制多个Snapshot,然后方便的两两比较,并且能看到Snapshot中的全量内存,这一点是下文要讲的“Allocationinstrumentationontimeline”方法不具备的,并且这种方法可以更加方便地查找后面会讲的因DetachedDom导致的内存泄漏。2Allocationinstrumentationontimeline但是,不知道你有没有觉得,这种高频率地录制Snapshot、对比、再对比的方式有点儿麻烦?我需要不断的去点击“adddate”,然后鼠标又要跑过去点击手动GC、录制Snapshot、等待录制完毕,再去操作,再去录制。有没有简单一些的方式来查找内存泄漏?这个时候我们回到Memory最初始的界面,你突然发现“Heapsnapshot”下面还有一个radio:“Allocationinstrumentationontimeline”,并且这个radio下面的介绍文案的最后写着:“Usethisprofiletypetoisolatememoryleaks”,原来这是一个专门用于调查内存泄漏的工具!于是,我们选中这个radio,点击开始录制按钮,然后将注意力放在页面上,然后你发现当点击“adddate”按钮时,右面录制的timeline便会多出一个心跳:图9Allocationinstrumentationontimeline如图9所示,每当我们点击“adddate”按钮时,右面都有一个对应的心跳,当我们点击“clear”按钮时,刚才出现的所有心跳便全都“缩回”去了,于是我们得出结论:每一个“心跳”都是一次内存分配,其高度代表内存分配的量,在之后的时间推移过程中,如果刚才心跳对应的被分配的内存被GC回收了,“心跳”便会跟着变化为回收之后的高度。于是,我们便摆脱了在Snapshot中来回操作、录制的窘境,只需要将注意力集中在页面的操作上,并观察哪个操作在右边的时间线变化中是可疑的。经过一系列操作,我们发现“adddate”这个按钮的点击行为很可疑,因为它分配的内存不会自动被回收,也就是只要点击一次,内存就会增长一点,我们停止录制,得到了一个timeline的Snapshot,这个时候如果我们点击某个心跳的话:图10点击某个心跳熟悉的Datex又出现了(图11),点击一个Date对象看持有链,接下来便跟上文Snapshot的持有链分析一样了:图11通过timeline找到泄漏点这个方法的优点上文已经说明,可以非常直观、方便的观察内存随可疑操作的分配与回收过程,可以方便的观察每次分配的内存。它的缺点:录制时间较长时devtools收集录制结果的时间会很长,甚至有时候会卡死浏览器;下文会讲到detachedDOM,这个工具不能比较出detachedDOM,而heapsnapshot可以。3performancedevtools中的Performance面版中也有一个Memory功能,下面看一下它如何使用。我们把Memory勾选上,并录制一个performance结果:图12Performance的录制过程在图12中可以看到,在录制的过程中我们连续点击“adddate”按钮10次,然后点击一次“clear”按钮,然后再次点击“adddate”10次,得到的最终结果如图13所示:图13Performance的录制结果在图13中我们可以得到下面的信息:
整个操作过程中内存的走势:参见图13下方的位置,第一轮点击10次的过程中内存不断增长,点clear之后内存断崖式下跌,第二轮点击10次内存又不断增长。这也是这个工具的主要作用:得到可疑操作的内存走势图,如果内存持续走高则有理由怀疑此操作由内存泄漏的可能。
内存的增长量:参见JSHeap位置,鼠标放上去可以看见每个阶梯上下位置的内存增长/下跌的量
通过在timeline中定位某个“阶梯”,我们也能找到可疑的代码,如图14所示:
图14通过Performance定位问题代码这种方法的优点:可以直观得看到内存的总体走势,并且同时得到所有操作过程中的函数调用栈和时间等信息。缺点:没有具体的内存分配的细节,录制的过程不能实时看到内存分配的过程。二内存泄漏出现的场景1全局JS采用标记清扫法去回收无法访问的内存对象,被挂载在全局对象(在浏览器中即指的是window对象,在垃圾回收的角度上称其为根节点,也叫GCroot)上的属性所占用内存是不会被回收的,因为其是始终可以访问的,这也符合“全局”的命名含义。解决方案就是避免用全局对象存储大量的数据。2闭包(closure)我们把稍加改动便可以得到一个闭包导致内存泄漏的版本:!DOCTYPEhtmlhtmllang="en"headmetacharset="UTF-8"/titlememory-leak/title/headbodyppushdateforbuttonclass="count-date"0/buttontimes/ppaddDate:buttonclass="push-date"adddate/button/ppclear:buttonclass="clear"clear/button/pscriptconstpushDate=document.querySelector(".push-date");constdateCount=document.querySelector(".count-date");letary=[];constwrap=()={constdateAry=Array(3_).map(()=newDate());constinner=()={returndateAry;};returninner;};//pushDate.addEventListener("click",functionadd(){ary.push(wrap());dateCount.innerHTML=`${ary.length}`;});constclear=document.querySelector(".clear");//clear.addEventListener("click",functionclear(){ary=[];dateCount.innerHTML=`${ary.length}`;});/script/body/html代码3闭包导致内存泄漏将上述代码加载到chrome中,并用timeline的方式录制一个Snapshot,得到的结果如图15所示:图15闭包的录制结果我们选中index=2的心跳,可以看到Constructor里面出现了一个"(closure)",我们展开这个closure,可以看到里面的"inner()",inner()后面的"()"表示inner是一个函数,这时候你可能会问:“图中的Constructor的RetainedSize大小都差不多,为什么你要选(closure)?”,正是因为没有明显占比较高的RetainedSize我们才随便选一个调查,后面你会发现不管你选了哪一个最后的调查链路都是殊途同归的。我们在下面的Retainers中看下inner()的持有细节:从下面的Retainers中可以看出inner()这个closure是某个Array的第2项(index从0开始),而这个数组的持有者是system/Context(即全局)中的ary,通过观察可以看到ary的持有大小(RetainedSize)是KB大约等于KB的5倍,5即是我们点击“adddate”按钮的次数,而下面的5个"previousinsystem/Context"每个大小都是KB,而它们最终都是被某个inner()闭包持有,至此我们便可以得出结论:全局中有一个ary数组,它的主要内存是被inner()填充的,通过蓝色的index.html:xx处的代码入口定位到代码所在地看一下一切就都了然了,原来是inner()闭包内部持有了一个大对象,并且所有的inner()闭包及其持有的大对象都被ary对象持有,而ary对象是全局的不会被回收,导致了内存泄漏(如果这种行为不符合预期的话)。返回去,如果这个时候你选择上面提到的system/Context构造器,你会看到(见图16,熟悉吧):图16system/Context也就是你选择的system/Context其实是inner()闭包的上下文对象(context),而此上下文持有了KB内存,通过蓝色的index.html:xx又可以定位到问题代码了。如果你像图17一样选择了Date构造器进行查看的话也可以最终定位到问题,此处将分析过程留给读者自己进行:图17选中Date构造器3DetachedDOM我们先看一下下面的代码,并用chrome载入它:
!DOCTYPEhtmlhtmllang="en"headmetacharset="UTF-8"/titlememory-leak/title/headbodypaddDate:buttonclass="push-date"adddate/button/ppdeletebutton:buttonclass="del"del/button/pscriptconstaddDate=document.querySelector(".push-date");constdel=document.querySelector(".del");functionadd(){}addDate.addEventListener("click",add);del.addEventListener("click",functiondel(){addDate.remove();});/script/body/html代码4DetachedDom然后我们采用HeapSnapshot的方式将点击“del”按钮前后的两个snapshot录制下来,得到的结果如图6所示。我们选用和snapshot1对比的方式并在snapshot2的过滤器中输入"detached"。我们观察得到的筛选结果的"Delta"列,其中不为0的列如下:Constructor#DeltaDetachedHTMLButtonElement+1DetachedEventListener+1DetachedInternalNode+2DetachedText+1DetachedV8EventListener+1要解释上述表格需要先介绍一个知识点:DOM对象被回收需要同时满足两个条件,1、DOM在DOM树中被删掉;2、DOM没有被JS对象引用。其中第二点还是比较容易被忽视的。正如上面的例子所示,DetachedHTMLButtonElement+1代表有一个buttonDOM被从组件树中删掉了,但是仍有JS引用之(我们不考虑有意为之的情况)。相似的,DetachedEventListener也是因为DOM被删掉了,但是事件没有解绑,于是Detached了,解决方案也很简单:及时解绑事件即可。于是解决的方法就很简单了:参见代码5,回掉函数del在执行完毕时临时变量会被回收,于是两个条件就都同时满足了,DOM对象就会被回收掉,事件解绑了,DetachedEventListener也就没有了。值得注意的是table元素,如果一个td元素发生了detached,则由于其自身引用了自己所在的table,于是整个table就也不会被回收了。
scriptconstdel=document.querySelector(".del");functionadd(){}document.querySelector(".push-date").addEventListener("click",add);del.addEventListener("click",functiondel(){document.querySelector(".push-date").removeEventListener("click",add);document.querySelector(".push-date").remove();});/script代码5DetachedDOM的解决方法图18DetachedDOM的SnapshotPerformancemonitor工具DOM/eventlistener泄漏在编写轮播图、弹窗、toast提示这种工具的时候还是很容易出现的,chrome的devtools中有一个Performancemonitor工具可以用来帮助我们调查内存中是否有DOM/eventlistener泄漏。首先看一下代码6:
!DOCTYPEhtmlhtmllang="en"headmetacharset="UTF-8"/titlememory-leak/title/headbodypaddDate:buttonclass="push-date"adddate/button/pdivclass="btn-list"/divscriptconstbtnList=document.querySelector(".btn-list");constaddDate=document.querySelector(".push-date");addDate.addEventListener("click",functiondel(){constbtn=document.createElement("button");btn.innerHTML="abtn";btnList.appendChild(btn);});/script/body/html代码6不断增加DOMNODE按照我们图19的方式打开Performancemonitor面版:图19打开Performancemonitor工具DOMNodes右侧的数量是当前内存中的所有DOM节点的数量,包括当前document中存在的和detached的以及计算过程中临时创建的,每当我们点击一次“adddate”按钮,并手动触发GC之后DOMNodes的数量就+2,这是因为我们向document中增加了一个button节点和一个button的文字节点,就像图20中所示。如果你写的toast组件在临时插入到document并过一会儿执行了remove之后处于了detached状态的话,Performancemonitor面版中的DOMNodes数量就会不断增加,结合snapshot工具你便可以定位到问题所在了。值得一提的是,有的第三方的库的toast便存在这个问题,不知道你被坑过没有。图20不断增加的DOMNodes4console这一点可能有人不会留意到,控制台打印的内容是需要始终保持引用的存在的,这一点也是值得注意的,因为打印过多过大对象的话也是会造成内存泄漏的,如图21所示(配合代码7)。解决方法便是不要肆意打印对象到控制台中,只打印必要的信息出来。
!DOCTYPEhtmlhtmllang="en"headmetacharset="UTF-8"/titlememory-leak/title/headbodypaddDate:buttonclass="push-date"adddate/button/pdivclass="btn-list"/divscriptconstaddDate=document.querySelector(".push-date");addDate.addEventListener("click",functiondel(){consttmp=Array(3_).fill().map(()=newDate());console.info(tmp);});/script/body/html代码7console导致内存泄漏图21console导致的内存泄漏三总结本文用了几个简单的小例子介绍了内存泄漏出现的时机、寻找泄漏点的方法并将各种方法的优缺点进行了对比,总结了避免出现内存泄漏的注意点。希望能对读者有所帮助。文中如果有本人理解错误或书写错误的地方欢迎留言指正。参考
转载请注明:http://www.abuoumao.com/hyfz/950.html