当前位置: 首页 > 科技观察

浏览器垃圾回收机制与Vue项目内存泄漏场景分析

时间:2023-03-17 13:09:01 科技观察

1.介绍一下,浏览器中的Javascript有一个自动垃圾回收机制(GC:GarbageCollection),也就是说执行环境会负责管理代码执行过程中使用的内存。其原理是:垃圾收集器会周期性地(periodically)找出那些不再使用的变量,然后释放它们的内存。但是这个过程不是实时的,因为它的开销比较大,GC停止响应其他操作,所以垃圾收集器会以固定的时间间隔周期性地执行。不再使用的变量是其生命周期已经结束的变量。当然,它们只能是局部变量。直到浏览器卸载页面,全局变量的生命周期才会结束。局部变量只在函数执行过程中存在,在这个过程中会在栈或堆上分配相应的空间供局部变量存放它们的值,然后在函数中使用这些变量,直到函数结束thefunction,andtheclosed因为包内有内部函数,外部函数不认为是结束。下面进入代码说明:functionfn1(){varobj={name:'hanzichi',age:10};}functionfn2(){varobj={name:'hanzichi',age:10};returnobj;}vara=fn1();varb=fn2();让我们看看代码是如何执行的。首先,声明了两个函数,分别称为fn1和fn2。调用fn1时,进入fn1的环境,开辟一块内存存放对象{name:'hanzichi',age:10}。调用结束后,一个fn1环境,那么这块内存会被JS引擎中的垃圾回收器自动释放;在调用fn2的过程中,返回的对象被全局变量b所指向,所以这块内存不会被释放。问题来了:哪个变量没用?所以垃圾收集器必须跟踪哪些变量是无用的,并标记不再有用的变量以供将来回收它们的内存。用于标记未使用变量的策略可能因实现而异,但通常有两种实现:标记清理和引用计数。引用计数不太常用,标记清除比较常用。2.MarkSweepjs中最常用的垃圾回收方式就是marksweep。例如,当变量进入环境时,在函数中声明变量将变量标记为“进入环境”。从逻辑上讲,进入环境的变量占用的内存永远不会被释放,因为只要执行流进入相应的环境,它们就可能被使用。当变量离开环境时,它被标记为“离开环境”。functiontest(){vara=10;//标记,进入环境varb=20;//标记,进入环境}test();//执行后,a和b被标记离开环境,被回收。垃圾收集器在运行时会标记所有存储在内存中的变量(当然你可以使用任何标记方法)。然后,它剥离环境中的变量和环境中变量引用的变量的标签(闭包)。之后被标记的变量将被视为要删除的变量,因为环境中的变量无法再访问这些变量。最后,垃圾回收器完成内存清理,销毁那些标记的值,回收它们占用的内存空间。到目前为止,IE9+、Firefox、Opera、Chrome、Safari的JS实现都采用了mark-and-sweep垃圾收集策略或类似的策略,只是垃圾收集间隔各不相同。3.引用计数引用计数的意义是跟踪记录每个值被引用的次数。当一个变量被声明并且引用类型的值赋给该变量时,该值的引用计数为1。如果相同的值被赋给另一个变量,该值的引用计数加1。反之,如果包含对该值的引用的变量取另一个值,该值的引用计数减1。当该值的引用计数变为0时,表示无法访问该值,因此它占用的内存空间可以康复了。这样,下次垃圾收集器运行时,就会释放引用计数为零的值占用的内存。functiontest(){vara={};//对象指向的引用数为1varb=a;//对象指向的引用数加1,即2varc=a;//对象指向的引用数加1,为3varb={};//a对象的引用数减1为2}NetscapeNavigator3是第一个使用引用计数策略的浏览器,但是它很快就遇到了一个严重的问题:循环引用。循环引用是指对象A包含指向对象B的指针,而对象B也包含对对象A的引用。functionfn(){vara={};varb={};a.pro=b;b.pro=a;}fn();上面代码a和b的引用次数都是2,fn执行后,两个Object都离开了环境,在markclear模式下没有问题,但是在引用计数策略下,因为引用计数的a和b不为0,内存不会被垃圾回收器回收,如果多次调用fn函数,造成内存泄漏。在IE7和IE8上,内存猛增。我们知道IE中有些对象并不是原生的JS对象。例如其内存泄漏DOM和BOM中的对象是使用C++以COM对象的形式实现的,COM对象的垃圾回收机制采用了引用计数策略。因此,即使IE的js引擎是使用mark-and-clear策略实现的,但是JS访问的COM对象仍然是基于引用计数策略。也就是说,只要在IE中涉及到COM对象,就会出现循环引用问题。varelement=document.getElementById("some_element");varmyObject=newObject();myObject.e=element;element.o=myObject;此示例在DOM元素元素和原生js对象myObject之间创建循环引用。其中,变量myObject有一个属性e指向元素对象;并且变量元素也有一个指向myObject的属性o。因为这个循环引用,即使示例中的DOM从页面中移除,它也永远不会被回收。举个栗子:黄色表示被js变量直接引用,红色表示被内存中的js变量间接引用。如上图,refB被refA间接引用,所以即使refB变量被清空,也不会被回收元素refB被parentNode间接引用,只要不删除,其所有父元素(图中红色部分)不会被删除又如:window.onload=functionouterFunction(){varobj=document.getElementById("element");obj.onclick=functioninnerFunction(){};};这段代码看起来不错,但是obj引用的是document.getElementById('element'),document.getElementById('element')的onclick方法会引用外部环境中的变量自然包括obj,是不是很隐蔽?(在比较新的浏览器中,移除Node时,它上面的事件已经被移除了,但是在旧的浏览器中,尤其是IE,会有这个bug)解决方法:最简单的方法是手动移除循环引用。比如刚才的函数可以是myObject.element=null;element.o=null;window.onload=functionouterFunction(){varobj=document.getElementById("element");obj.onclick=functioninnerFunction(){};对象=空;};将变量设置为null意味着切断变量与其先前引用的值之间的连接。下次垃圾收集器运行时,这些值会被移除,它们占用的内存也会被回收。需要注意的是,IE9+不存在循环引用导致DOM内存泄露的问题。可能是微软做了优化,或者DOM的回收方式变了。4.内存管理4.1什么时候触发垃圾回收?垃圾收集器定期运行。如果分配了很多内存,恢复工作就会非常困难。确定垃圾回收间隔成为一个值得思考的问题。IE6的垃圾回收是根据内存分配量来运行的。当环境中有256个变量、4096个对象或64k字符串时,就会触发垃圾收集器工作。看起来很科学,没有必要按一个段落一次调用一次,有时候也没有必要。按需调用不好吗?但是如果环境中一直存在那么多变数,现在脚本这么复杂,也很正常,那么结果就是垃圾回收器一直在工作,这样浏览器就玩不下去了。微软在IE7中做了调整。触发条件不再是固定的,而是动态修改的。初始值与IE6相同。如果垃圾回收器分配的内存小于程序占用内存的15%,就意味着大部分内存是不能回收的。垃圾回收的触发条件过于敏感。此时,街道状况翻了一番。如果回收的内存高于85%,说明大部分内存应该早就被清理掉了。此时,将触发条件设回。这使得垃圾回收功能很多4.2合理的GC方案1.基本方案Javascript引擎基本的GC方案是(simpleGC):markandsweep(标记清除),即:遍历所有可访问的对象。回收不再可达的对象。2.GC的缺陷和其他语言一样。JS的GC策略避免不了一个问题:在GC的时候,为了安全起见,它停止响应其他操作。Javascript的GC在100ms以上,对于一般的应用来说还好,但是对于对连续性要求高的JS游戏和动画,就麻烦了。这就是新引擎需要优化的地方:避免GC导致的长时间停机。3.GC优化策略大卫大叔主要介绍了两种优化方案,这是最重要的两种优化方案:GenerationGC与Java回收策略的思想一致,也是V8主要采用的。目的是区分“临时”和“持久”对象;多回收“临时对象”区(新生代),少回收“持久对象”区(老年代),减少每次需要遍历的对象,从而减少每次GC耗时。如图:这里需要补充的是:对于tenuredgeneration对象,有额外的开销:从younggeneration迁移到tenuredgeneration。另外,如果引用了,引用点也需要修改。这里的主要内容可以参考Node中关于内存的介绍,很详细~增量GC的解决思路很简单,就是“每次处理一点,下次再处理一点,等等”。如图:这种方案虽然耗时短,但是中断较多,带来上下文切换频繁的问题。由于每种方案都有其适用的场景和缺点,在实际应用中,会根据实际情况选择方案。例如:当(object/s)比率低时,中断GC执行的频率较低,简单GC较低;如果大量的对象是长期“生存”的,那么分代处理的优势就不是很大了。4、Vue内存泄漏问题JS程序内存溢出后,某个函数体会永久失效(取决于当时JS代码运行到哪个函数),通常表现为程序突然卡顿或者程序异常。这时候我们就需要检查JS程序的内存泄漏情况,找出哪些对象占用了未释放的内存。这些对象通常被开发人员认为是被释放了,但实际上它们仍然被一个闭包引用,或者放在一个数组中。4.1泄漏点DOM/BOM对象泄漏;由引用脚本中的DOM/BOM对象引起;JS对象泄漏;通常是由闭包引起的,比如事件处理回调,导致DOM对象和脚本中对象的双向引用,这是常见的Leakage原因;4.2代码关注主要集中在各种事件绑定场景,如:DOM中的addEventLisner函数和派生的事件监听器,如Jquery中的on函数,Vue组件实例的$on函数;其他BOM对象事件监听,比如websocket实例的on函数;避免不必要的函数引用;如果使用render函数,避免在HTML标签中绑定DOM/BOM事件;4.3mounted/createdhook中DOM被JS绑定如何处理/BOM对象中的事件需要在beforeDestroy中解除绑定;如果在mounted/createdhook中使用了第三方库初始化,则需要在beforeDestroy中销毁(一般不用,因为经常直接全局Vue.use);如果组件中使用了setInterval,则需要在beforeDestroy中做相应的销毁处理;4.4在vue组件中处理addEventListener,调用addEventListener添加事件监听器,然后在beforeDestroy中调用removeEventListener移除对应的事件监听器。为了准确移除监听器,尽量不要直接使用匿名函数或现有函数的绑定作为事件监听器函数。mounted(){constbox=document.getElementById('time-line')this.width=box.offsetWidththis.resizefun=()=>{this.width=box.offsetWidth}window.addEventListener('resize',this.resizefun)},beforeDestroy(){window.removeEventListener('resize',this.resizefun)this.resizefun=null}4.5观察者模式导致的内存泄漏在spa应用中使用观察者模式时,如果观察者注册了观察方法,但是离开组件时没有及时移除,可能造成重复注册和内存泄漏;例如:进入组件时,ob.addListener("enter",_func),如果离开组件时没有ob,beforeDestroy.removeListener("enter",_func),就会造成内存泄漏。更详细的栗子参考:TexasHold'emChestnut4.6contextbinding导致的内存泄露有时在使用bind/apply/callcontextbinding方式时,可能会存在内存泄露的隐患。varClassA=function(name){this.name=namethis.func=null}vara=newClassA("a")varb=newClassA("b")b.func=bind(function(){console.log("Iam"+this.name)},a)b.func()//输出:Iamaa=null//releasea//b=null;//releaseb//b.func=null;//releaseb.funcfunctionbind(func,self){//模拟上下文绑定returnfunction(){returnfunc.apply(self)}}使用chromedevtool>memory>profiles查看内存中ClassA的实例数,发现有两个实例,一个和b。虽然a被设置为null,但是b方法中bind的闭包上下文self绑定了a,所以虽然a被释放了,但是b/b.func并没有被释放,闭包的self一直存在并保持着对a的引用。