使用Chrome开发者工具分析内存泄漏
从用户角度来看,Web应用内存问题可以表现为以下几种形式:
问题形式 | 症状 |
内存泄漏 |
页面的性能随着运行时间的增加越来越差,这是因为页面消耗越来越多的内存 内存消耗量和性能呈负相关的原因包括:
|
内存暴涨 |
页面性能一直很差,这是因为页面使用大量的内存,超过页面速度优化的限度 所谓“大量”并没有量化标准,主要取决于客户端的硬件资源 |
频繁GC | 页面延迟或者卡顿。这时因为GC时脚本执行被暂停 |
这三种表现形式并不是孤立的。缓慢的内存泄漏最终会导致内存的大量使用,内存泄漏/暴涨会导致GC消耗更多的时间以及更频繁的运作。
本文主要探讨内存泄漏问题。
所谓内存泄漏,是指应用程序不再需要的内存,由于某种原因没有归还给操作系统。
JavaScript是一种基于垃圾回收机制(GCed)的语言,垃圾回收器可以辅助内存的回收:它检查某一片内存是否从应用其它部分可达,以确定该内存是否可以被安全回收。GCed语言中的内存泄漏主要由多余引用(unwanted references)导致。所谓多余引用,是从活动GC Root直接/间接指向一片不再需要的内存的引用。
尽管现代JS引擎能够在一些情况下自动执行垃圾回收,由于代码逻辑错误导致的内存泄漏仍然存在,特别是在单页面应用/Ajax成为主流的今天,长时间运行的Web页面往往因此渐渐变慢甚至崩溃。
“多余引用”在Web应用的场景下可以进一步的细分。下表列出常见的情形:
原因 | 说明 | ||
错误的使用全局变量 |
在JavaScript中不通过 var 关键字声明,而直接使用的变量是全局变量,这类变量本质上是 window 对象的属性。 全局变量(指向的内存)直到页面刷新之前,不会自动回收。因此如果使用全局变量保存大量数据,务必在不再需要这些数据时,手工设置全局变量的值为 null ,解除引用 |
||
忘记清理定时器 |
setInterval() 函数经常被使用,但是忘记清理。考虑下面的代码:
|
||
忘记清理回调函数 |
某些JS库则提供基于回调的模式实现,例如观察者模式 你应当在不需要时,明确的移除观察者(Listener函数),实例代码如下:
对于某些不能管理循环引用的浏览器(例如IE6,它不能检测DOM节点和JS代码之间的循环引用)来说,明确移除步骤非常重要。现代浏览器一般都能在被观察对象不可达时自动清理对应的观察者,尽管如此,明确移除观察者仍然是很好的实践 JS库,例如jQuery,会在移除DOM节点时自动完成上述“明确移除” |
||
忘记清理DOM节点的引用 |
为了操作方便,有时候你会在数据结构中引用DOM节点。 这会导致DOM节点有两个in引用,一个来自DOM树,一个来自你的数据结构。当DOM节点不再需要时,你需要确保这两个in引用都不可达 特别需要注意的是:子DOM节点不可回收的时候,其祖先节点亦不可回收 |
按快捷键Shift + ESC,可以打开Chrome任务管理器(Task Manager)。通过持续的观察任务管理器,你可以查看每个页面的内存用量的变化,进而获得可能的内存泄漏提示。常用输出列如下:
列 | 说明 |
Task | 显示浏览器主进程,或者扩展、页面、插件的名称 |
Memory |
Native内存用量,DOM节点存放在Native内存中,如果此列持续增加,提示存在无法释放的DOM节点 只有DOM节点从页面DOM树中移除,同时没有JS变量引用之,DOM节点才能够被释放。已经从DOM中移除,却仍然被JS代码引用的DOM节点,称为Detached节点,这种节点是引发内存泄漏的常见原因 注意:Chrome开发者工具会导致被调试页面占用的Native内存大量增加。在启用堆时间线记录时,甚至可以增大数倍。关闭开发者工具增大的内存会自动释放 |
JavaScript Memory |
JS堆内存用量,该列显示两个值,其中括号中的值(live)指示可达对象占用的内存。如果live值持续增加,提示存在无法释放的JS对象 结合Windows任务管理器观察,发现Windows任务管理器中chrome.exe进程的内存(私有工作集)总是和上面的Memory相同。因此JavaScript Memory应该是Memory的一部分,Memory是页面占用的总内存 |
CPU | 页面的CPU占用率 |
Network | 页面的网络通信速率 |
Chrome任务管理器只能给出一些粗略的提示。实际工作中我们通常使用Chrome Developer Tools来发现并定位内存泄漏。Dev Tools由9个面板组成,其中Timeline、Profiles有助于内存分析。
通过该面板可以直观的看到不正常的内存使用,你可以使用它获得对问题的初步认识。
切换到Timeline面板,然后勾选Memory,点击可以启动时间线记录,点击弹窗的Finish按钮则停止记录。注意,在启动、停止前,你应该点击执行强制垃圾回收。时间线记录示例如下:
在这个示例中,随着时间的推进,周期性的内存用量跳涨而之后不能收缩到原有水平、Nodes、Listeners计数的持续增加,这些都是明显的内存泄漏信号。
该面板是定位内存泄漏的关键,使用它你可以:
- 获取内存的快照,分析JS对象、DOM节点如何占用内存。支持比对两个快照,发现内存使用情况的变化
- 录制内存分配随时间的变化,可用于隔离出内存泄漏。
切换到Profiles面板,点选Take Heap Snapsot,然后点击Task Snapshot按钮,即可获得一张实时的堆快照。注意:获取快照前,会自动执行垃圾回收。快照示例如下:
点击左上角的下拉菜单,可以在几个视图之间切换:
视图 | 说明 |
Summary |
在上半部分的对象列表子面板中,显示获取快照的那一时刻,各类型对象总体分配情况,数据项包括:
你可以点击一个对象类型前面的小箭头(或者按Right方向键)展开,查看此类型对象的实例,注意实例后面会跟着灰色的@id,id是对象的唯一标识符。进一步展开,可以看到对象实例的属性 选中一个对象的实例/对象实例属性,可以在下半部分的Retainers子面板中查看选中条目的引用链,这个链从直接引用选中条目的那个对象开始,逐层上溯 点选顶部工具栏All objects右侧的小箭头,你可以选择查看所有对象,或者在任意两个快照之间分配的对象 |
Comparison | 对比两个快照,查看其分配内存大小、对象个数的比值 |
Containment | 已一系列根对象为起点,显示其容纳对象的树 |
Statistics | 使用饼图来展现各大类对象的内存用量比例 |
定位内存泄漏需要一些技巧,但是一般都是从快照的Summary视图开始分析。一个常用的工作流程是所谓三快照(three snapshot)技术:
- 执行必要的预热操作,确保那些需要长期驻留的对象初始化完毕
- 获取一个快照
- 执行可能导致内存泄漏的操作
- 获取一个快照
- 再次执行你觉得可能导致内存泄漏的操作
- 再次获取一个快照
- 选择最后一个快照,在列表顶部选择:
- 这样,你就可以看到那些在快照1-快照2这个时间段内分配的,到目前为止仍然驻留在内存中的那些对象。这些对象可能是导致内存泄漏的对象
- 展开对象类型节点,选中一个对象,这时下面的Retainers面板会显示该对象的驻留树——即该对象是如何被其它对象引用的
三快照技术有效的依据是:在应用经过预热(warm-up)阶段之后,分配的新对象一般都是由(定时器/用户)操作触发的、朝生暮死的对象。这些本该朝生暮死的对象如果一直驻留,意味着存在内存泄漏。
切换到Profiles面板,点选Record Heap Allocations,然后点击Start按钮,即可录制内存分配沿着时间线的变化情况——堆分配时间线(Heap Timelines)。点击按钮可以停止录制,并生成一个快照。
注意:时间线对象列表子面板,仅仅包含在时间线结尾时刻仍然存活的对象。
堆分配时间线的优势在于,你可以任意选择大小的时间区间,查看区间内发生的内存分配,有利于隔离出内存泄漏。时间线示例如下:
截图中,最上面的是时间轴,每一个蓝色/灰色的柱子表示一系列相关的内存分配。柱子的高度表示分配内存的大小,蓝色部分表示到结束录制的那个时间点为止,仍然驻留的内存大小,灰色部分表示已经被回收的内存大小。
中间的对象列表面板,显示选中的时间区间内的内存分配,按类型分组。展开一个类型节点,可以看到仍然存活的实例。
下面是Retainers面板,可以显示某个存活实例的引用链。例如上图中当前选中的字符串实例被window.arr[18]引用。
在对象列表子面板的Constructor列、Retainers子面板的Object列,都有以构造器表示的对象类型,这些类型的说明如下:
对象类型 | 说明 |
(closure) | 闭包,这些闭包引用了上下文对象 |
(compiled code) | 被Chrome编译过的JavaScript代码,引擎内部使用,我们无法控制 |
(array) | 引擎内部使用的数组对象 |
Array | JavaScript数组,这些数组常常包含大量的数据 |
Object | 简单(plain old)JavaScript对象 |
system / Context | 调用一个函数所需要的潜在对象,例如闭包使用的实际数据 |
system | 引擎内部使用的数据 |
(shared function info) | 引擎内部使用的函数共享信息 |
HTML***Element | 一个瘦包装器,引用位于Native内存中的DOM对象 |
这里我们举一个利用堆快照剖析DOM树泄漏的例子。考虑下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
<button id="btn">Create</button> <script type="text/javascript"> var detachedTree; document.getElementById('btn').addEventListener('click', function () { var div = document.createElement('div'); for (var i = 0; i < 10; i++) { var span = document.createElement('span'); div.appendChild(span); } detachedTree = div; }); </script> |
点击Create按钮后,监听器会创建一个div元素,其内部包含10个span元素。由于div元素尚未添加到页面DOM树中且被全局变量detachedTree引用,因此它属于Detached节点。
我们在点击Create按钮后获取一个快照,在Summary视图的Class filter中搜索Detached,可以看到如下结果:
在对象列表面板中,黄色背景表示Detached DOM子树的根节点,这类节点被JavaScript代码直接引用;红色背景(非直接引用)则表示Detached DOM子树的分支/叶子节点,没有被JavaScript代码直接引用。
在上图中点选一个对象,下面的Retainers面板会显示该对象的引用链:
可以看到,引用链从直接引用对象的变量开始,逆向追溯。引用链上的每个元素使用如下语法描述:
1 2 3 4 5 6 |
# name 可以是对象属性名称、native(表示该引用是本地代码)、[数组索引] # ObjectType 指明对象的类型 # @id 指明对象的唯一标识 name1 in ObjectType1 @id1 name2 in ObjectType2 @id2 # ObjectType2类型的对象id2的name2属性指向ObjectType1类型的对象id1 |
对于上图左右两个引用链,应当这样解读:
- 对于HTMLDivElement,window的detachedTree属性(就是我们声明的全局变量)直接引用之
- 对于HTMLSpanElement,window的detachedTree属性引用了HTMLDivElement ⇨ HTMLDivElement的native属性引用了DOM子树数组 ⇨ DOM子树数组的第1个元素引用了HTMLSpanElement
在追踪DOM树泄漏时,可以不关注红色背景节点,而仅追踪黄色背景节点被哪些JavaScript变量使用,然后清除多余引用。
“机房实景”是某系统中的一个模块,它以图形化的方式展示数据中心的各种设备:包括服务器、网络设备、监控设备。设备及其状态基于位图、矢量图、文本等形式展示。如果数据中心规模较大,可以为其设计多张实景图,并在这些实景图之间轮巡(即定期切换显示),以了解数据中心的网络和环境状态。
从技术角度来说,该模块以Draw 2D Touch 5.3.4为基础,而后者的底层框架则是Raphael以及jQuery。
在测试阶段,我们发现长时间运行机房实景模块后,浏览器占用的内存不断上涨。特别是启用实景图轮询的情况下,数小时后内存占用超过1G,最终浏览器会崩溃。
通过Chrome任务管理器查看:
- 刚进入机房实景模块时:Native内存占用80MB,JS内存占用20MB
- 10分钟后,强制GC后:Native内存占用110MB,JS内存占用45MB
- 20分钟后,强制GC后:Native内存占用130MB,JS内存占用64MB
这组数据说明机房实景模块很可能存在内存泄漏问题。
重新进入机房实景模块,等待模块预热完毕(机房实景模块在启动时需要加载所有实景图的UI元素数据,并在内存中创建相应的数据结构),启用轮巡,在Profiles面板中启动堆时间线记录,持续十分钟,执行GC后停止记录,结果如下:
可以看到,大尺寸内存分配,周期非常稳定,这恰恰是轮巡切换实景图的周期。这些内存分配的大部分(蓝色)始终无法回收,说明实景图切换会导致相当严重的内存泄漏。为何实景图切换会导致内存泄漏?这很可能是大量界面元素的复杂引用关系没有被正确的清理,后面我们会证实这一点。
重新进入机房实景模块,等待模块预热完毕,禁用轮巡, 再次启动堆时间线记录,持续十分钟,执行GC后停止记录,结果如下:
可以看到,在禁用轮巡的情况下,只有2分钟左右存在一次可能的JavaScript内存泄漏,其它时间分配的内存都大多数被回收了(灰色)。
首先看看禁用轮巡时,那唯一高耸的蓝色柱子是怎么回事,缩小区间,查看对象列表面板:
这个所谓的system类型,是浏览器内部行为导致的内存分配,和应用程序没有直接关系。因此我们可以认为它不是内存泄漏,并进一步推断:禁用轮巡时机房实景模块没有明显的JavaScript内存泄漏。
我们着重分析启用轮巡时那一系列蓝色柱子,这些柱子高度常常以M计,再多的系统内存也经不起这样的泄漏。
缩小区间,选择第一个柱子,设置对象列表面板按Retained Size降序排列,Retainers面板按Distance排列,结果如下:
这样排序的根据是:
- Retained Size大的对象,可能造成的内存泄漏量也大
- 离目标对象距离一样的多个引用引用,其Distance值越小(即离GC Root越近)越可能是内存泄漏的根源。上图的cache、159两个引用前者更有可能导致了内存泄漏
注意第一个驻留对象Object@1483747,其导致1870.752KB的内存无法释放;第二个对象Object@1483541,其导致1870.556KB内存无法释放。然后,所有Object总计才导致1873.092KB内存无法释放,这是怎么回事呢?
其实展开Object@1483747你就可以看到原因何在——Object@1483747的events字段恰恰引用了Object@1483541:
在任何时候,你都可以按Esc调出Console。选中对象列表面板或者Retainers面板中一条数据后,在Console中输入 $0 可以打印该条数据对应的对象(对于Retainers是in 后面的那个的对象,而非in前面属性名所指向的对象)的详细信息。你也可以悬停鼠标在一条数据上,相应对象的详细信息也会自动显示。
我们选取Retained Size较大的一个Object进行分析,显示其详细信息:
可以看到这是一个简单对象,有events、handle两个属性。你会注意到events属性的Retained Size远远大于events的子属性Retained之和,这一现象我也没弄清楚是怎么回事。
查看Retainers面板,可以发现jQuery.cache引用了Object@8364337,而jQuery.cache导致的内存驻留接近28M。看起来这个cache在占据大量内存,它会不会随着时间不停变大呢?继续启用轮巡运行机房实景模块15分钟,证实了这一猜测,cache的Retained Size膨胀到120MB:
上节我们已经确认jQuery.cache存在大量的内存泄漏,那么这个cache是做什么用的呢,为何会无限制的膨胀呢?
我们看看Object@8364337是如何分配,并加入到cache中的,切换Retainers到Allocation stack,查看该对象被创建时的调用栈:
(注意:默认不会录制内存分配时的栈信息,需要在开发者工具设置 ⇨ General ⇨ Profiler中启用。)
有了这个调用栈,即使你不熟悉实景图模块,也能够下手进行调试了。通过单步跟踪,发现大概的程序逻辑如下:
- Ext.define.onTick其实一个定时器,每过一定时间它就会调用mm.takeTurnTask.run()方法,即执行轮巡,切换实景图。切换是通过调用MapManager.switchTo()实现的
- Ext.define.switchTo:轮巡定时器触发,切换实景图
12345678910111213141516171819Ext.define( 'kssi.map.run.MapManager', {switchTo: function ( mapId ) {var me = this;var curMap = this.getCurMap();// 只在目标实景图不是当前实景图的情况下才切换if ( curMap && mapId == curMap.getId() ) {return;}me.show( mapId );},show: function ( mapId ) {var me = this;// 缓存当前实景图me.cache( me.getCurMap() );// 创建新的需要切换到的实景图me.createMapComp( mapId ); // ⇨me.updateMapView();}}); - Ext.define.createMapComp:创建新的实景图UI包装器组件,在创建前总是会尝试移除原先的组件:
1234567891011121314Ext.define( 'kssi.map.run.MapManager', {createMapComp: function ( mapId ) {var me = this;if ( me.currMapComp ) {// 创建新实景图之前,移除老的实景图me.mapCntr.remove( me.currMapComp, true );me.currMapComp = null;}// 创建新的实景图并添加到容器var mapComp = Ext.create( 'kssi.map.design.MapComponent', mapData );me.afterMapCompCreated( mapComp );me.mapCntr.add( mapComp );}}); - Map$d2d是实景图UI组件,基于Draw2D的实现。在其重绘(repaint)函数中,会创建Draw2D画布对象,并调用其初始化方法:
123456789101112Ext.define( 'com.kingsmartsi.secmon.map.domain.Map$d2d', {extend: 'com.kingsmartsi.secmon.map.domain.Map',canvas: null,repaint: function () {var map = this;var items = map.painted ? map.marshalItems() : map.items;map.clear();map.dom.innerHTML = '';// 就是把当前ExtJS对应的DOM元素传递给Draw2d画布的构造函数map.canvas = new draw2d.Canvas( map.dom.id ); // ⇨}} - Draw2D画布类遵循Class.js的类继承模拟机制,其构造函数的职责实质上由初始化方法init()承担
12345678910111213141516(function () {// 声明一个全局变量this.Class = function () {};Class.extend = function ( prop ) {// ...function Class() {// 实际的构造了逻辑是委托给init方法完成的if ( !initializing && this.init )this.init.apply( this, arguments );}// ...return Class;};})();因此,repaint()传递给new draw2d.Canvas()的map.dom.id,会直接转给init(),作为画布的容器元素
1234567891011121314151617181920212223draw2d.Canvas = Class.extend({init: function ( canvasId, width, height ) {this.canvasId = canvasId;// Map$d2d组件对应的DOM元素、Draw2D画布的html属性指向的DOM元素是同一个,只不过后者是一个jQuery元素封装this.html = $( "#" + canvasId ); // canvasId == Map$d2d.dom.id// 构建Raphael画布对象if ( typeof height !== "undefined" ) {this.paper = Raphael( canvasId, width, height );}else {this.paper = Raphael( canvasId, this.getWidth(), this.getHeight() );}// 对容器元素进行事件绑定this.html.bind( "mouseup touchend", function ( event ) { // ⇨} );// bind()方法只是on()的一个代理,可以认为两者一回事this.html.on( "dblclick", function ( event ) { // ⇨} );// 更多事件绑定...}}可以看到Draw2D画布在初始化时,调用jQuery的bind/on进行事件绑定。我们开始接近jQuery.cache膨胀的源头了
- jQuery在进行事件绑定时,实际上是调用jQuery.event对象的add()方法:
12345678910jQuery.fn.extend({on: function ( types, selector, data, fn, /*INTERNAL*/ one ) {// 遍历jQuery元素集合(这里只有一个id为canvasId的DIV元素),分别进行添加事件return this.each( function () {jQuery.event.add( this, types, fn, data, selector ); // ⇨} );}}); - jQuery.event.add在一开始就会调用_data()方法:
12345678910111213jQuery.event = {add: function( elem, types, handler, data, selector ) {// 这个_data()其实是从缓存中拿数据,下面可以看到这一点var elemData = jQuery._data( elem ); // ⇨if ( !elemData ) return;}};jQuery.extend({_data: function( elem, name, data ) {return internalData( elem, name, data, true ); // ⇨}}); - 而_data()则是internalData的简单代理,后者会初始化元素缓存:
12345678910111213141516171819202122232425262728function internalData( elem, name, data, pvt /* Internal Use Only */ ) {// 对于元素节点,使用jQuery.cache,也就是就是我们的泄漏对象var cache = isNode ? jQuery.cache : elem;// 分配缓存keyif ( !id ) {// Only DOM nodes need a new unique ID for each element since their data// ends up in the global cacheif ( isNode ) {id = elem[ internalKey ] = core_deletedIds.pop() || jQuery.guid++;}else {id = internalKey;}}// 如果尚未缓存,为元素建立缓存条目if ( !cache[ id ] ) {// Avoid exposing jQuery metadata on plain JS objects when the object// is serialized using JSON.stringifycache[ id ] = isNode ? {} : { toJSON: jQuery.noop };}thisCache = cache[ id ];// 缓存一条数据,以name为key,data为valueif ( data !== undefined ) {thisCache[ jQuery.camelCase( name ) ] = data;}// ...return ret;}
从上面的分析可以看到,通过jQuery来注册事件监听器时,会创建目标元素的缓存,从而导致jQuery.cache膨胀。
jQuery的设计中,必然包含了控制jQuery.cache大小的合理逻辑,否则缓存可能会不断膨胀,最终导致内存耗尽。
很多缓存系统使用注入LRU之类的算法,来限制缓存的大小,jQuery.cache好像没有这样设计。
既然jQuery.cache是关于元素的缓存,那么元素销毁后,其缓存条目理当一并销毁。如果这一设想成立,你必须让jQuery知道你销毁了元素,这样jQuery才有机会删除缓存条目(缓存属于jQuery内部实现)。
我们先从代码中寻找删除jQuery.cache条目的逻辑。直接在jquery-1.10.2.js中搜索“jQuery.cache”,匹配结果只有4条,很容易找到清理缓存条目的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
function internalRemoveData( elem, name, pvt ) { if ( !jQuery.acceptData( elem ) ) { return; } var thisCache, i, isNode = elem.nodeType, //元素1,文本3,注释8,文档9,文档类型10,文档片段11 cache = isNode ? jQuery.cache : elem, id = isNode ? elem[ jQuery.expando ] : jQuery.expando; // 如果缓存条目不存在,直接返回 if ( !cache[ id ] ) { return; } if ( name ) { // 获得当前元素的缓存对象 thisCache = pvt ? cache[ id ] : cache[ id ].data; if ( thisCache ) { // 把name转换为缓存key的数组 i = name.length; while ( i-- ) { // 删除name中所有key对应的缓存数据 delete thisCache[ name[i] ]; } // 如果缓存条目中还有数据,不销毁缓存条目 // isEmptyDataObject:data属性不影响为空判断 // isEmptyObject:必须真正的没有任何属性 if ( pvt ? !isEmptyDataObject(thisCache) : !jQuery.isEmptyObject(thisCache) ) { return; } } } // 销毁缓存条目 if ( isNode ) { jQuery.cleanData( [ elem ], true ); // ⇨ } else if ( jQuery.support.deleteExpando || cache != cache.window ) { delete cache[ id ]; } else { cache[ id ] = null; } } |
如果入参elem是DOM节点,则调用cleanData()执行复杂的处理逻辑;否则,仅仅将缓存条目从jQuery.cache中移除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
jQuery.extend({ cleanData: function (elems, /* internal */ acceptData) { var elem, type, id, data, i = 0, internalKey = jQuery.expando, cache = jQuery.cache, deleteExpando = jQuery.support.deleteExpando, special = jQuery.event.special; //在这里,elems必然是jQuery元素集合对象,遍历 for (; (elem = elems[i]) != null; i++) { // 如果支持缓存 if (acceptData || jQuery.acceptData(elem)) { id = elem[internalKey]; // 缓存条目键 data = id && cache[id]; // 缓存条目值 if (data) { // 移除事件处理函数 if (data.events) { for (type in data.events) { if (special[type]) { jQuery.event.remove(elem, type); } else { jQuery.removeEvent(elem, type, data.handle); } } } if (cache[id]) { // 从jQuery.cache中删除该缓存条目 delete cache[id]; if (deleteExpando) { delete elem[internalKey]; } else if (typeof elem.removeAttribute !== core_strundefined) { elem.removeAttribute(internalKey); } else { elem[internalKey] = null; } core_deletedIds.push(id); } } } } } }); |
那么,哪些函数调用此函数呢?直接调用它的函数有两个:
1 2 3 4 5 6 7 8 9 |
jQuery.extend({ cache: {}, removeData: function (elem, name) { return internalRemoveData(elem, name); }, _removeData: function (elem, name) { return internalRemoveData(elem, name, true); } }); |
对于第二个函数,存在以下调用链:jQuery.off() ⇨ jQuery.event.remove() ⇨ jQuery._removeData()。因此,解除注册元素上的事件处理器,可能会让元素缓存条目被清理。
在上面的分析中,我们知道internalRemoveData()可能调用cleanData()来清理节点的缓存数据,那么还有其它函数调用cleanData()么?搜索发现,下面的方法也会调用它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
jQuery.fn.extend( { // 移除匹配的元素 remove: function ( selector, keepData ) { var elem, elems = selector ? jQuery.filter( selector, this ) : this, i = 0; for ( ; (elem = elems[ i ]) != null; i++ ) { if ( !keepData && elem.nodeType === 1 ) { jQuery.cleanData( getAll( elem ) ); } } return this; }, // 清空子节点 empty: function () { var elem, i = 0; for ( ; (elem = this[ i ]) != null; i++ ) { // Remove element nodes and prevent memory leaks if ( elem.nodeType === 1 ) { jQuery.cleanData( getAll( elem, false ) ); } } return this; }, } ); |
小结一下:
- 函数internalRemoveData负责:
- 从缓存条目中删除指定的键值对
- 当缓存条目“为空”时,清理DOM节点的事件监听器数据,并且从jQuery.cache中移除缓存条目
- 方法cleanData负责:清理元素的缓存数据
- 当通过jQuery API移除元素时,它的位于jQuery.cache中的缓存条目被删除
- 当通过jQuery API移除元素的事件监听器时,它位于jQuery.cache中缓存条目的数据项会被删除,如果缓存条目在此数据项删除后变“空”,则删除缓存条目
因此:由于你通过jQuery API操控DOM,而导致其驻留jQuery.cache后,必须通过jQuery的API——例如remove()、off()进行清理操作,才能确保缓存条目在不需要时顺利移除。
通过上一节的分析,我们已经确信:必须通过jQuery的API进行清理操作,才能避免jQuery.cache的内存泄漏。实景图模块并没有直接使用jQuery,直接使用它的是Draw2D。那么,后者是否暴露了适当的接口供上层组件执行清理?
我们看一下实景图模块的清理逻辑。在前面的分析中我们已经看到,轮巡(switchTo)时,原有实景图UI包装器组件会被删除:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
switchTo: function ( mapId ) { var me = this; me.show( mapId ); } show: function ( mapId ) { var me = this; //首先缓存当前实景图UI包装器组件的数据内容 me.cache( me.getCurMap() ); //然后创新新实景图 me.createMapComp( mapId ); me.updateMapView(); } createMapComp: function ( mapId ) { var me = this; if ( me.currMapComp ) { // 移除老的实景图 me.mapCntr.remove( me.currMapComp, true ); me.currMapComp = null; } var mapData = me.mapDataCache[ mapId ]; // 创建新实景图并添加到容器中 var mapComp = Ext.create( 'kssi.map.design.MapComponent', mapData ); } |
上面的调用ExtJS标准API:Container.remove()用来移除原有的实景图UI包装器组件,由于实景图组件被设置为autoDestory,因此它会被销毁。除了ExtJS组件标准的销毁逻辑以外。实景图模块还扩展了一些逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// 实景图UI包装器组件 Ext.define( 'kssi.map.design.MapComponent', { map : null , //Map$d2d // 扩展的逻辑 onDestroy : function() { this.map.onDestroy(); } // 销毁从这里开始,这个是ExtJS框架的逻辑,复制过来便于理解流传 destory : function() { me.onDestroy(); if (!me.preserveElOnDestroy) { me.el.remove(); // 移除DOM元素 } } } ); //实景图UI组件 Ext.define( 'com.kingsmartsi.secmon.map.domain.Map$d2d', { canvas: null, // 扩展的逻辑 onDestroy: function () { this.canvas.destroy(); this.tip.close(); } } |
到这里为止,我们可以确认:
- Draw2D画布的目标元素,亦即ExtJS组件MapComponent对应的DOM元素,最终被(ExtJS API)销毁了。注意上一节我们的结论——必须通过jQuery API对操纵过的元素进行清理操作,因此这里的销毁肯定不能阻止jQuery.cache的泄漏
- 在销毁上述DOM之前,实景图模块调用Draw2D画布的destroy()方法
这个destroy()方法应当是删除Draw2D画布的相关资源,我们看看它的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
draw2d.Canvas = Class.extend( destroy: function () { // 清空 this.clear(); // 解除document上的事件绑定 $( document ).unbind( "keydown", this.keydownCallback ); $( document ).unbind( "keyup", this.keyupCallback ); this.eventSubscriptions = {}; // 删除Raphael画布对象 this.paper.remove(); }, // 清理画布上的Figure、Line等元素 clear: function () { this.fireEvent( "clear" ); var _this = this; // 移除所有线条 this.lines.clone().each( function ( i, e ) { _this.remove( e ); } ); // 移除所有图形 this.figures.clone().each( function ( i, e ) { _this.remove( e ); } ); // 重置实例变量 this.zoomFactor = 1.0; this.selection.clear(); // ... return this; } } ); |
注意到没有?Draw2D的Canvas类在初始化时注册了很多事件监听器(this.html.on/bind调用),但是在上面的destroy()方法中:
- 没有通过off()调用解除this.html的监听器绑定
- 也没有通过remove()调用删除this.html元素
(为了防止代码分析存在遗漏,你可以在jQuery的internalRemoveData、cleanData函数上添加断点,然后再运行实景图模块,如果断点没有命中或者调用针对的元素不是this.html,则证明上述结论无误)
因此,jQuery.cache导致的内存泄漏发生了。
弄清楚来龙去脉,解决这个问题就非常简单了,只需要再Map$d2d的onDestroy()钩子中添加一行代码即可:
1 2 3 4 5 6 7 8 |
Ext.define( 'com.kingsmartsi.secmon.map.domain.Map$d2d', { onDestroy: function () { this.canvas.destroy(); // 解决jQuery.cache内存泄漏问题 this.canvas.html.remove(); this.tip.close(); } }); |
上节我们通过修改Draw2D的代码,处理了jQuery.cache泄漏问题,现在我们需要验证一下,问题是不是真正解决了。重新录制10分钟的堆时间线:
和一开始的录制的时间线对比一下可以看到,我们解决jQuery.cache泄漏后,实景图整体内存消耗有了很大改善。 观察此图,我们可以看到:
- 每个高柱子(都大于1.0MB线)都是在轮巡切换实景图时发生的内存分配
- 总有4-5个蓝色高柱子,它们总是位于时间线的最右侧。这是个好现象,说明随着时间的推移,以前分配的内存大多被回收了
- 仍然有少量内存过了很久都没有被回收,注意观察那些非常低矮的蓝色短柱
通过多次重新录制、查看,发现上面第3条提及的蓝色短柱是随机出现的,其Retained Size也主要由Chrome本身导致,因此不需要刻意去分析。
那么,大片的灰色区域中,是否存在“肉眼不可见”(量太少以至于画不出蓝色柱子)的内存泄漏呢?选定一个大范围的区间查看:
那些带括号的一般是JS引擎内部使用,可以不去考虑。因此可能属于内存泄漏的类型包括:constructor、Object、Array、HasListeners。我们分别展开这些类型的第一个对象实例,合并到一张图片中进行对照:
注意在这4张图片的Retainers子面板中出现的ext-com-1035,很明显,后3个对象分别是第1个对象的events、protoEl.classList、hasListeners属性。只要第1个对象被回收,其它3个对象自然会被回收。因此,我们只需要搞清楚第1个对象是什么,为什么一直驻留就可以了。
下面看看constructor对象的列表,注意那些Retained Size一样的对象,它们往往是基于同样的逻辑创建出来了,一般你只需要分析其中一个就可以了:
以constructor@2398693为例,它的引用链是: window.Ext.ComponentMgr.all.map['ext-comp-1035'] 。如果你熟悉ExtJS的架构,就知道所有ExtJS组件创建后都在组件管理器(ComponentMgr)中注册,销毁前则从组件管理器中解除注册:
1 2 3 4 5 6 7 8 9 10 11 12 |
Ext.define('Ext.AbstractComponent', { constructor : function(config) { // ... me.initComponent(); // 在initComponent阶段之后,把自己注册到组件管理器 Ext.ComponentManager.register(me); }, destroy : function() { // ... Ext.ComponentManager.unregister(me); } }); |
constructor@2398693一直没有解除注册,可能因为实景图模块没有适当的销毁它。
下面看一下该对象的Allocation stack,看看是什么代码创建了constructor@2398693:
从栈顶向下跟踪,可以发现实景图UI组件创建了它:
1 2 3 4 5 6 7 8 9 |
Ext.define( 'com.kingsmartsi.secmon.map.domain.Map$d2d', { constructor: function () { this.callParent( arguments ); this.tip = Ext.create( 'Ext.tip.Tip', { cls: 'tooltip' } ); return this; } }); |
原来constructor@2398693是一个Tooltip,其实你随时可以通过鼠标悬停或 $0 来查看constructor@2398693的细节,进而获知它是一个Tooltip。
我们顺便看看Map$d2d是怎样销毁这个Tooltip的:
1 2 3 4 5 6 7 8 |
Ext.define( 'com.kingsmartsi.secmon.map.domain.Map$d2d', { onDestroy: function () { this.canvas.destroy(); //解决jQuery.cache内存泄漏问题 this.canvas.html.remove(); this.tip.close(); } }); |
Map$d2d调用Toltip的close()方法(这个方法从Ext.panel.Panel继承得到)。这个方法的行为是什么呢?官方文档描述如下:
关闭面板,默认行为是将其从DOM中移除,删除面板对象及其所有子组件。注意:该方法的行为受到closeAction设置的影响,如果要明确的控制,请使用destroy()或者hide()方法
注意到了吗,close()不一定销毁组件,也可能是隐藏组件,这取决于closeAction设置,而Ext.tip.ToolTip的closeAction默认是hide,这个ExtJS官方文档说明的很清楚。所以实景图模块存在BUG。
要解决上面的泄漏很简单,要么在onDestroy()时明确调用destroy(),要么修改closeAction设置:
1 2 3 4 5 6 7 8 9 10 11 |
Ext.define( 'com.kingsmartsi.secmon.map.domain.Map$d2d', { constructor: function () { this.tip = Ext.create( 'Ext.tip.Tip', { cls: 'tooltip', closeAction: 'destroy' } ); }, onDestroy: function () { this.tip.destroy(); }, }); |
回过头来想想,真的需要为每个实景图UI组件配备单独的Tooltip实例吗?完全没必要,ToolTip本身就倾向于全局使用。
处理好Tooltip泄漏后,更新服务器,再次录制10分钟的时间线:
可以看到,驻留的对象都是Chrome引擎内部使用的对象。连续运行实景图轮巡一小时,通过Chrome任务管理器观察,内存也没有明显增长。我们认为不存在内存泄漏了。
上面的分析结束后,我们认为内存泄漏问题已经解决。
但是,实景图轮巡是需要支持无限时间连续运行的,所以我们还要进行长期稳定性测试。连续运行16小时后,实景图模块占用的内存从100MB增长到400MB左右。看来,还是存在内存泄漏——平均每小时泄漏18MB内存,只是由于垃圾回收机制的干扰,我们无法在短时间内直观的察觉。
这里,我们还是首先考虑通过时间线分析。不要刷新浏览器,录制一个时间线。
和上一个时间线一样,我们选取一大片区间,中间并没有可供分析的对象。是不是泄漏都是在时间线之前分配的呢?毕竟实景图模块在录制前已经连续运行16小时了。这里有个技巧:将区间起点拉到最左边,可以看到在录制时间线之前分配的那些对象:
看到没有,在这16小时内:Object导致116MB内存驻留;constructor导致97MB内存驻留;Array导致80MB内存驻留。虽然这些内存可能会有重合、这些对象也不一定都是内存泄漏,但是,内存泄漏的存在是无容置疑的——最初运行的1小时内,实景图模块总计占据的内存还不到100MB。
我们先看看Object,大概浏览一下前面的几十条,发现它们都是window.mapManager.mapDataCache:
也就是说,内存中存在多个实景图管理器(kssi.map.run.MapManager)对象(它与mapDataCache是1:1关系)。这是严重的编程错误,在实景图模块,实景图管理器是全局唯一的对象,绝不应该存在多个实例。
检查代码,发现创建实景图管理器的逻辑是存放在Ext.onReady()方法中的,这意味着在页面的生命周期中,实景图只会创建一次。那么时间线中的多个mapManager,该如何解释?
页面的生命周期中,实景图只会创建一次,这个推论没有问题。出现多个mapManager,是因为时间线跨越了页面的生命周期,说它跨越的依据是 in Window@后面的数字。看到了吗,左边9601而右边是17947。window的标识符都变了,说明页面刷新了。我们重新录制时间线、结合console.log可以证明这一点:
看到没有,在运行1小时后,出现大量内存分配,日志也显示mapManager被创建。到这里我们可以确信实景图模块有自动刷新的逻辑,搜索reload找到:
1 2 3 4 5 6 7 |
Ext.TaskManager.start( { run: function () { mm.cacheTransientState( true ); window.location.reload(); }, interval: 3600 * 1000 } ); |
这段代码原本是用作临时解决内存泄漏的,而现在,解药却变成了毒药。这是为什么?
页面刷新后、页面内存被清空,整个时间线信息应该丢失才对。而我们的时间线完整的记录了多个window对象实例。这个现象可能的原因是,实景图是运行在iframe中的。top窗口一直没有刷新。你可以切换到Elements面板,搜索iframe发现:
1 2 |
<iframe src="/pems-web-manager/maprun/group" name="frame-frame" width="100%" height="100%" frameborder="0" id="ext-gen1114"> </iframe> |
页面刷新后,其内存仍然不能销毁,唯一可解释的原因是:其它iframe或者top窗口的某个变量直接/间接引用该页面中的变量。
通过查看top窗口的脚本monitoring.js,我们看到其中大量的引用了子页面的mapManager。
由于“每小时刷新实景图页面”是以前解决内存泄漏的临时方案,现在已经不需要了,删除定时器代码即可。
关于如何发现、定位并解决内存泄漏,请参考下面的几条建议:
- 不要猜测!去测试、去剖析
- 再小的内存泄漏,经过足够长的时间后也会对应用稳定性和性能产生严重影响,因此不能放过
- 尽早测试,特别是那些需要长时间运行的程序。随着程序规模的增长、新框架和新组件的引入,定位内存泄漏变得越来越困难
- 通过堆时间线去定位、隔离出内存泄漏
- 识别Chrome引擎随机性的内存分配,这些分配在对象列表中,其类型往往有括号包围
- 记录Allocation stack,快速的跟踪什么代码创建了对象
- 必要时,开启Debug进行单步跟踪,了解自己不熟悉的第三方框架的行为
- 解决了一个内存泄漏后,最好重新录制堆时间线,因为其它对象的驻留可能也是由这个已解决的内存泄漏引起
Leave a Reply