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

JavaScript内存泄漏的4种方式以及如何避免

时间:2023-03-13 19:40:02 科技观察

介绍内存泄漏是每个开发者最终都会面临的问题,它是许多问题的根源:卡顿、崩溃、高延迟等应用程序问题。什么是内存泄漏?从本质上讲,内存泄漏可以定义为应用程序不再需要占用由于某种原因未被操作系统或可用内存池回收的内存的时间。编程语言以不同的方式管理内存。只有开发人员知道哪些内存不再需要,操作系统可以回收它。一些编程语言提供的语言特性可以帮助开发人员做这样的事情。其他人则希望开发人员清楚内存的需求。JavaScript内存管理JavaScript是一种垃圾收集语言。垃圾收集语言通过定期检查以前分配的内存是否可达来帮助开发人员管理内存。换句话说,垃圾收集语言缓解了“内存仍然可用”和“内存仍然可达”的问题。两者之间的区别很微妙但很重要:只有开发人员知道哪些内存将来仍会被使用,而无法访问的内存是由算法确定和标记的,并在适当的时候被操作系统回收。JavaScript内存泄漏垃圾收集语言内存泄漏的主要原因是不需要的引用。在理解它之前,有必要了解一下垃圾回收语言是如何区分内存是可达的还是不可达的。Mark-and-sweep大多数垃圾收集语言使用的算法称为Mark-and-sweep。该算法包括以下步骤:垃圾收集器创建一个“根”列表。根通常是对代码中全局变量的引用。在JavaScript中,“window”对象是一个全局变量,被认为是根。窗口对象始终存在,因此垃圾收集器可以检查它及其所有子对象是否存在(即不是垃圾);所有的根都被检查并标记为活动的(即不是垃圾)。还递归检查所有子对象。如果root中的所有对象都可访问,则不认为是垃圾。所有未标记的内存将被视为垃圾,收集器现在可以释放内存并将其返回给操作系统。现代垃圾收集器改进了算法,但本质是一样的:标记可达内存,剩下的进行垃圾收集。不需要的引用是开发人员知道不再需要但由于某种原因仍留在活动根树中的内存引用。在JavaScript中,不需要的引用是保留在代码中的变量,不再需要它,但它指向一块应该释放的内存。有些人认为这是开发人员的错误。为了理解JavaScript中最常见的内存泄漏,我们需要了解什么样的引用容易被遗忘。三种常见的JavaScript内存泄漏1:意外的全局变量JavaScript对未定义变量的处理很松散:未定义变量在全局对象中创建一个新变量。在浏览器中,全局对象是窗口。functionfoo(arg){bar="thisisahiddenglobalvariable";}真相是:functionfoo(arg){window.bar="thisisanexplicitglobalvariable";}functionfoo忘记在里面使用var,不小心创建了一个全局变量。这个例子泄漏了一个简单的字符串,这是无害的,但还有更糟糕的情况。另一个意想不到的全局变量可能是这样创建的:functionfoo(){this.variable="potentialaccidentalglobal";}//Foo调用自身,this指向全局对象(window)//而不是undefinedfoo();在JavaScript文件的头部加上'usestrict'可以避免此类错误。启用JavaScript的严格模式解析以避免意外的全局变量。全局变量注意事项虽然我们讨论了一些意想不到的全局变量,但仍然有一些显式全局变量会产生垃圾。它们被定义为不被收集(除非定义为空或重新分配)。尤其是在使用全局变量来临时存储和处理大量信息时,需要格外小心。如果必须使用全局变量来存储大量数据,请确保将其设置为null或在使用后重新定义它。与全局变量相关的内存消耗增加的主要原因是缓存。缓存数据是为了复用,缓存要有个上限才有用。高内存消耗导致缓存溢出,因为缓存内容无法回收。2:忘记定时器或回调在JavaScript中使用setInterval是很常见的。常见的一段代码:varsomeResource=getData();setInterval(function(){varnode=document.getElementById('Node');if(node){//处理节点和someResourcenode.innerHTML=JSON.stringify(someResource));}},1000);这个例子说明了什么:不再需要与节点或数据关联的计时器,可以删除节点对象,不再需要整个回调函数。但是定时器回调函数还是没有被回收(定时器停止时会被回收)。同时,如果someResource存储了大量数据,则无法回收。对于观察者示例,重要的是在不再需要它们(或关联的对象变得不可访问)时显式删除它们。旧的IE6无法处理循环引用。今天,即使没有明确删除它们,一旦观察者对象变得不可访问,大多数浏览器也会回收观察者处理程序。观察者代码示例:varelement=document.getElementById('button');functiononClick(event){element.innerHTML='text';}element.addEventListener('click',onClick);对象观察器和循环引用注意事项旧版本的IE无法检测DOM节点和JavaScript代码之间的循环引用,这可能导致内存泄漏。如今,现代浏览器(包括IE和MicrosoftEdge)使用更高级的垃圾回收算法,可以正确检测和处理循环引用。也就是说,回收节点内存时,不需要调用removeEventListener。3:DOM之外的引用有时保留DOM节点的内部数据结构很有用。如果要快速更新表的几行,将DOM的每一行存储为字典(JSON键值对)或数组是有意义的。此时,对同一个DOM元素有两个引用:一个在DOM树中,一个在字典中。如果您决定将来删除这些行,则需要清除这两个引用。varelements={button:document.getElementById('button'),image:document.getElementById('image'),text:document.getElementById('text')};functiondoStuff(){image.src='http://some.url/image';button.click();console.log(text.innerHTML);//更多逻辑}functionremoveButton(){//按钮是body的后代元素document.body.removeChild(document.getElementById('button'));//此时,还有一个全局的#button引用//elements字典。按钮元素还在内存中,无法被GC回收。此外,还必须考虑对DOM树中的内部节点或子节点的引用。假设您在JavaScript代码的表中保存了对的引用。以后决定删除整张表时,直觉是GC会回收除了保存的以外的其他节点。实际情况并非如此:这个是表格的子节点,子元素与父元素是引用关系。由于代码保留对的引用,因此整个表保留在内存中。存储对DOM元素的引用时要小心。4:闭包闭包是JavaScript开发的一个关键方面:匿名函数可以访问父作用域中的变量。代码示例:vartheThing=null;varreplaceThing=function(){varoriginalThing=theThing;varunused=function(){if(originalThing)console.log("hi");};theThing={longStr:newArray(1000000).join('*'),someMethod:function(){console.log(someMessage);}};};setInterval(replaceThing,1000);代码片段做了一件事:每次调用replaceThing时,theThing得到一个大数组和一个带有新闭包(someMethod)的新对象。同时,变量unused是一个引用originalThing(replaceThing之前称为theThing)的闭包。你困惑吗?最重要的是,一旦闭包作用域被创建,它们就有了相同的父作用域,作用域是共享的。someMethod可以通过theThing使用,someMethod与unused共享闭包作用域,即使从未使用过unused,它引用的originalThing也会强制它保留在内存中(防止它被回收)。当这段代码反复运行时,你会看到内存使用量不断上升,而垃圾收集器(GC)无法减少内存使用量。本质上,创建了一个闭包链表,每个闭包作用域都带有对一个大数组的间接引用,造成严重的内存泄漏。Meteor的博客文章解释了如何解决这个问题。在replaceThing的***处添加originalThing=null。ChromeMemoryProfiler概述Chrome提供了一套很棒的工具来检测JavaScript内存使用情况。与记忆相关的两个重要工具:时间线和配置文件。Timelinetimeline可以检测代码中不需要的内存。在这张截图中,我们可以看到潜在泄漏对象的数量在稳步增加。数据采集??结束时,内存占用明显高于采集开始时,总Node数也很高。有多种迹象表明代码中存在DOM节点泄漏。ProfilesProfiles是一个你可以花很多时间关注的工具,它可以保存快照,比较不同的JavaScript代码内存使用快照,也可以记录时间分配。每个结果包含不同类型的列表,有与内存泄漏相关的汇总(summary)列表和比较(control)列表。摘要列表显示了不同类型对象的分配大小和聚合大小:浅大小(特定类型的所有对象的总大小)、保留大小(浅大小加上与其关联的其他对象大小)。它还提供了一个对象与其关联的GC根有多远的想法。通过比较不同快照的对比列表,可以发现内存泄漏。示例:使用Chrome查找内存泄漏基本上有两种类型的泄漏:由周期性内存增长引起的泄漏和偶尔的内存泄漏。显然,周期性内存泄漏很容易发现;偶尔的泄漏比较困难,通常很容易忽略。偶尔出现的可能被认为是优化问题,而周期性的则被认为是必须解决的错误。以Chrome文档中的代码为例:varx=[];functioncreateSomeNodes(){vardiv,i=100,frag=document.createDocumentFragment();for(;i>0;i--){div=document.createElement("div");div.appendChild(document.createTextNode(i+"-"+newDate().toTimeString()));frag.appendChild(div);}document.getElementById("nodes").appendChild(frag);}functiongrow(){x.push(newArray(1000000).join('x'));createSomeNodes();setTimeout(grow,1000);}执行grow时,开始创建div节点并插入到DOM中,并将一个巨大的数组分配给全局变量。可以通过上述工具检测到内存的稳定增长。找出哪些周期性增长的内存时间轴标签擅长于此。在Chrome中打开示例,打开DevTools,切换到timeline,检查内存并点击record按钮,然后点击页面上的TheButton按钮。过一会,停止记录,看看结果:两个迹象表明存在内存泄漏,图中的Nodes(绿线)和JSheap(蓝线)。节点在稳步增长,没有下降,这是一个了不起的迹象。JS堆的内存使用量也在稳步增长。由于垃圾收集器,不容易发现。该图显示内存使用量上下波动。事实上,每次下降之后,JS堆的大小都比之前大了。换句话说,虽然垃圾收集器一直在收集内存,但内存会周期性地泄漏。确认存在内存泄漏后,我们寻找根本原因。保存两个快照切换到ChromeDevTools的profiles选项卡,刷新页面,当页面刷新完成后,点击TakeHeapSnapshot将快照保存为基线。然后再次单击TheButton按钮,等待几秒钟,然后保存第二个快照。在筛选菜单中选择Summary,在右侧选择ObjectsallocatedbetweenSnapshot1andSnapshot2,或者在筛选菜单中选择Comparison,即可看到对比列表。在这个例子中,很容易发现内存泄漏。查看(string)的SizeDeltaConstructor,8MB,58个新对象。新对象被分配,但没有被释放,占用8MB。如果展开(string)Constructor,您将看到许多单独的内存分配。选择一个单独的分配,下面的保留者将引起我们的注意。我们选择的分配是与窗口对象的x变量关联的数组的一部分。这显示了从大对象到无法回收的根(窗口)的完整路径。我们已经找到潜在的泄漏及其来源。我们的例子相当简单,只有少量DOM节点被泄露,使用上面提到的快照很容易找到。对于较大的站点,Chrome还提供了RecordHeapAllocations功能。记录堆分配查找内存泄漏返回Chrome开发工具的配置文件选项卡,然后单击记录堆分配。工具运行的时候,注意最上面的蓝色条,代表内存分配,每一秒都有大量的内存分配。运行几秒钟然后停止。在上图中,可以看到该工具的杀手锏:选中某个时间线,可以看到该时间段的内存分配情况。选择尽可能接近峰值的时间线,下面的列表只显示了三个构造函数:一个是最泄漏的(字符串),其次是关联的DOM分配,最后是Textconstructor(包含在DOM叶节点文本中).从列表中选择一个HTMLDivElement构造函数,然后选择分配堆栈。现在知道元素分配到哪里了(grow->createSomeNodes),仔细看图中的时间线,发现HTMLDivElementconstructor被调用了很多次,说明内存已经被占用,无法被GC回收.我们知道这些对象是要分配的确切位置(createSomeNodes)。回到代码本身,讨论如何修复内存泄漏。另一个有用的功能是在堆分配结果区域中,选择分配。该视图显示了与内存分配相关的函数列表,我们立即看到grow和createSomeNodes。选择grow时,查看相关的对象构造函数,可以清楚地看到(string)、HTMLDivElement和Text泄漏了。结合上面提到的工具,可以很容易地发现内存泄漏。