Chrome内存工具使用介绍与案例

内存泄露是一个常见且棘手的问题,它会导致应用性能下降甚至崩溃,Chrome对此提供了强大的测试工具来检测和分析内存泄露。
在实操的过程中,还是遇到了一些问题,于是根据使用心得写了一篇说明,包含介绍如何使用Chrome控制台的Memory工具,和一个小小的案例来说明如何定位并修复问题,希望给有需要的朋友们一点参考。

泄露了吗?

故事当然要从确认问题是否存在开始。常见的分辨内存泄漏方法是通过拍摄内存快照,根据内存快照中的信息,来判断是否存在未释放的对象。那就从拍摄快照说起吧。

如何拍摄内存快照

打开Chrome控制台,切换到Memory分页。这里就是Chrome为我们提供的内存分析工具。
拍摄内存快照的操作非常简单,右侧面板默认会选中“Heap snapshot”(堆快照)选项,我们只需要点击左上角的录制按钮,就可以开始拍摄内存快照了。![]

右下角的虚拟机实例(“Select JavaScript VM instance”)Cocos项目一般只有一个选项,可以忽略。另外点击下方的“Take snapshot”也同样可以进行录制。

点击后“Profiles”栏下方会出现一个根据录制类型分类的结果列表,现在这里会出现一个“SnapShot 1”,末尾会显示进度值,我们需要等待Chrome完成抓取分析。

image

完成后,点击对应的快照项,右侧会出现快照的详细内容,我们晚点再来仔细介绍它。

如何判断发生泄露?

现在我们已经熟练掌握快照拍摄了,要怎么进一步的确定问题呢?
一般来说我们会拍照两个(或以上)的快照,观察每次快照之间内存总量的变化情况,来快速判断是否可能存在内存泄露。

第一个快照作为基础数据,可以在进入游戏后,或者在你怀疑可能产生内存泄漏的节点之前拍摄。如打开某界面前。
第二个快照作为对比组,在反复执行多次可能产生内存泄露的逻辑后拍摄。如反复打开某界面。

多次拍摄快照后会产生多个快照列表项,如下图:

image

列表项的末尾有一个“157MB”,这表示拍摄快照时堆内存的大小。
如果快照2的内存比快照1的内存大小明显增加,就可以判定“可能”存在内存泄露。为什么只是可能呢?

常见的情况是远程包导致的内存增加,在获得远程包的界面/资源之前,还需要加载远程包的数据、代码等,部分内容是不会被释放的,我们确实需要它们一直存活在内存里,所以不能算做内存泄露。
同样的,节点池、数据缓存也会导致内存的增加,除了对比内存大小,我们还需要进一步分析,是否存在不合理的增加。

:warning: 小提示
网页刷新后快照会被 全部清除 ,如果需要保存,可以右键快照项使用“Save Profile…”功能。

接着我们需要对快照中的数据进行分析,首先来了解一下内存快照面板。

内存快照面板

Chrome提供了多种视图,个人推荐默认的Summary视图。其他试图也可以尝试看看,主要是Containment视图,和Summary各有优势。

image

Constructor面板

Summary视图上方区域是以类(或者说构造函数)作为分类的所有实例列表,如下图中的ArrayBuffer,而匿名对象会以“{ 属性名… }”的形式列出,如下图中的{ deps, path, func }。

右侧有3列属性:

“Shallow Size”:这个对象本身占用的内存大小,包含一些基础类型的属性(比如number、boolean)。
“Retained Size”:这个对象以及(释放这个对象后会被同时释放的)引用对象占用的所有内存大小。
“Distance”:这个对象和GC根(如Window)之间访问路径的距离,可以辅助查找最短路径。

image

这个面板的可以用来快速找到占用内存高的对象。点击列表中的任意一个类,展开的内容会包含所有这个类的实例。每个实例的末尾都会有一串“@xxxx”,这是实例的唯一id,可以方便我们确定是不是排查到了同一个对象。

再次点击展开,显示的是这个实例的所有属性(再往下展开也是相同的逻辑),可以进一步查看到底是哪个属性占用了较多的内存。

:nerd_face: 小技巧

  1. 面板左上方有搜索栏,比如常见的内存泄露可能发生在界面类,就可以直接搜索View/Win等前后缀,快速排查
    image
  2. “Shallow Size”和“Retained Size”都可以排序,让占用高的排前面,更快定位

:warning: 小提示
Retained Size表示 “这个对象以及(释放这个对象后会被同时释放的)引用对象占用的所有内存大小”
下面使用一个简单的例子进行说明,当两个View持有同一个缓存对象(caches)时,表现如下:


将其中一个View持有的缓存改为另外的值,表现如下:

可以看到,当持有同一个对象时,caches所占的内存在View这一级是不计入的。因为释放其中的任一个View对象,caches都不会被释放(还被另一个View所引用)。

在这种情况下,我们一般能够在Constructor面板中直接找到这个对象,如果不幸是Array或者其他通用类型… 那就只能费点劲了… 比如在一大堆Array中查找到底哪个列表才是问题项:
image
注:鼠标放到对象上可以像debug时一样查看对象。

Retainers面板

Summary视图下方的Retainers面板是当前(Constructor面板)选中对象的持有者列表,如果定位到一个“不应该存在”的对象,可以在这里看看到底是谁还拿着它的引用舍不得放。

我们这里选中了一个Node,最末尾的两个_parent就表示,有两个cc_Node对象的_parent属性指向这个Node,这是我们常见的子节点对父节点的引用(两个子节点就会有两条)~

如果点击列表项,展开的是 持有这个对象 的对象列表(往下展开也是如此),比如我们选中了一个Node:

第一项表示有一个数组的下标2引用这个节点
再下一级是cc_Scene实例的_children属性引用数组
再往下是cc.Director实例的_scene属性引用Scene实例

这不感觉就是倒着的属性表达式嘛!没有错,就是这样的!Retainers面板就一个翻转后的属性树结构。

特别的是,这里有时候会出现多个属性引用同一个对象,看一个有趣的例子,我们选中一个sp.Skeleton的Skin对象:

可以看到一共有5个引用,其中有2个置灰项,一般来说置灰项可以忽略,我们将它们展开看看:

image

每一项的末尾都有一个“@”开头的编号,这是对象的唯一标识。这两个展开项看起来非常类似!它们都表示数组的第0个下标指向176607这个对象。
可以看到置灰项只是引用关系的另一种表达,也可以理解为JS内部的一些底层逻辑导致的引用,在我们正确移除引用后会同步去除,并不会实质上造成内存泄漏。

我们来关注剩下的列表项,同一个skin,有3处引用:

image

可以试试倒着(从子节点往父节点读)读读看,我们用SkinA代表选中的实例:
Skeleton.data.defaultSkin = SkinA
Skeleton.skin = SkinA
Skeletonk.data.skins[0] = SkinA
这里看似有3个引用,但归根到底都是同一个Skeleton,在游戏代码中也会出现类似的情况,因为Chrome会列出所有引用路径,我们还需要通过Retainers面板精准的找出谁才是真正的罪魁祸首,把它释放掉!

:nerd_face: 小技巧

Retainers中的列表项可以打开右键菜单,其中几个好用的功能:

  1. Reveal in Summary view:帮你在Constructor面板找到这个对象,回到正常的父->子排列的属性树,并且可以查看这个对象的其他属性。
  2. Store as global variable:debug的老朋友了,存储为全局变量并且在控制台打印。

实战

小案例

不知道看完掌握了几成?我们再来做一个简单的实战巩固一下,一个非常非常非常常见的内存泄露:界面上注册监听事件,关闭时忘记移除:sweat_smile:

用一段简单的代码模拟游戏中的界面类,在new对象的时候会注册一个监听事件。

image

在后续的逻辑中,我们可能关闭了这个界面,表现上看起来没有任何问题,我们再也见不到它了。但实际上… 它正不吱声地躲在内存的角落里。

按照我们说好的,首先拍摄第一个快照,然后打开这个界面,关闭界面,再拍摄第二个快照。
这里我们可以点击“All objects”展开选项菜单,选中第三个筛选项,它表示“在快照1和快照2之间分配的对象”,这样可以排除掉快照1前创建的对象,大幅缩小排查范围。

image

如果拍摄了不止2次快照,那这里就会有多种筛选方式。

image

通过“Retained Size”从大到小排序,然后查看列表中是否有不应该存在的对象

我抓到你了:cowboy_hat_face:!我们关闭了TestView,但是却在这里见到了它。
一般来说,如果一个内存泄漏能够让你有感,在反复执行后,它就会排在比较前面的位置。
这里只是一个测试界面,所以并没有占用多少的内存。但在实际的项目中,它可能还引用着图片、特效、文字…
每次打开/关闭这个界面后,内存都会偷摸增加2、3MB,是不是有点恐怖了?

那怎么找到导致内存泄露的原因?让我们展开Retainers中的引用项看看究竟:

按照前面的阅读方式,我们可以得到:
window.cc.director._callbackTable.director_after_update.callbackInfos[1].target = TestView实例

按照语义,大概能够猜到,director实例中持有事件监听的数据,其中“director_after_update”的某个监听器的target指向了TestView的实例。

看看源码:

image

果然如此!我们在注册事件时传入的this,已经被被缓存起来了,这样在触发回调时才能保证this指向正确。

在确定泄露的原因之后,处理就相对简单了。哪些引用不符合预期,就处理哪些路径。只要断开对象之间的引用,就可以正常进入回收流程。
示例中是事件导致的泄露问题,只需要在关闭界面时,将注册的所有事件相应调用off函数移除监听即可。

:warning:小提示
控制台打印的对象也会存在引用!你可以在录制前清空,或者在排查的时候留心一下~

常见的泄露场景

  1. 事件监听。前面的实例里已经提到啦,因为会缓存this,甚至是回调函数的bind(this)。
  2. 闭包。闭包中引用的变量会因为闭包未被释放导致无法清除,常见的还会引用this。
  3. 全局变量。

好啦,主要的内容到这里就结束了,希望对想要了解内存泄露排查方式或者正在抓虫的你有一点点帮助~
最后来看点别的,是一个更好用的工具!

拓展内容

Chrome出了一个炫酷的新功能,针对内存泄露的判断和定位都有很大的效率提升,我其实很想拿新的模块来讲解,但是我在实际使用的时候,录制的时间一长,它 会 崩 溃:sob:
不知道是不是游戏内存占用过大还是什么原因,总之录制一会开发者工具就直接寄掉了。
有机会的话还是可以尝试使用一下,看起来真的很炫酷!说不定你们不卡死呢!

录制的时候选择第二个选项“Allocations on timelines”

同样点击左上角按钮开始录制,录制完成后长这样:

这不是差不多嘛!上方多了一个像时间轴一样的东西,是的,重点就是这个时间轴!
换一张对比比较明显的:

image
时间轴分为灰色和蓝色两部分。整根柱子表示在这个时间点新分配的内存数量,灰色的部分表示不活跃的对象(在以后的某个时刻,这些内存会被释放掉)。相反蓝色部分,就是到录制完成时仍然活跃,它们可能无法被释放!
时间轴可以帮我们快速定位出存在大量未释放内存的区块,缩小排查的范围,对定位问题的效率有着极大的提升。

点击时间轴,可以对数据的范围进行框选,可以帮助我们在多个疑点中准确找到最有可能的目标。
至于下方的其他面板,前面都已经介绍过啦,就不重复说了。

效率的提升无疑是非常重要的,不过定位问题只是第一步,接下来的代码调整也是一个大难题,祝你好运啦~

相关文档

没有什么比官方文档更权威~ 如果在使用中有任何疑问,务必去翻翻看。

Chrome官方的内存面板说明:

https://developer.chrome.com/docs/devtools/memory?hl=zh-cn

Chrome官方录制快照介绍文档:

https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots?hl=zh-cn

最最最后,我们公司正在招人,厦门广州均有岗位,有兴趣的朋友可以来了解一下~

10赞

nice 我做优化时的日常操作流程。真好用。

分享下我的笔记,虽然有点长有点啰嗦,见谅。

1赞

早看到就不写了 :rofl:

比我以前给公司留的那份文档详细 :rofl:

相互学习,相互印证,哈哈哈哈