WeTest腾讯质量开发平台 深入浅出再谈 Unity 内存泄漏

腾讯WeTest · 2016年08月25日 · 2027 次阅读

作者:Arthuryu,腾讯高级开发工程师

WeTest 导读

本文通过对内存泄漏(what)及其危害性(why)的介绍,引出在 Unity 环境下定位和修复内存泄漏的方法和工具(how)。最后提出了一些避免泄漏的方法与建议。

在之前推送的文章《内存是手游的硬伤——腾讯游戏谈 Unity 游戏 Mono 内存管理及泄漏问题》中,已经对腾讯游戏在 Unity 游戏开发过程中常见的 Mono 内存管理问题进行了介绍,收到了很多用户的反馈,希望能够更全面的介绍关于 unity 内存管理的问题。本期微信推送腾讯 WeTest 团队邀请到了公司中资深的测试专家 Arthuryu,对 Unity 内存泄漏进行一个更加系统的介绍。
 

内存泄漏及其危害

相信各位程序猿们或多或少都会听到过内存泄漏这个名词,但是对于一些新手猿来说,或许不是很了解。内存泄漏?是内存漏出来了么?和霸气侧漏一样么?让我们先来看一下 wikipedia 的定义:

看了一遍冗长的定义,或许各位猿们心中就是一个大写的 “晕” 字。让我们打一个通俗的比方来解释下这个定义。

内存泄漏,可以通俗解释为“借银行钱不还”。在计算机的二进制世界里,操作系统就是银行;每一笔贷款,都是一次内存的申请;而你,就是一个应用程序。即银行贷款 应用程序操作系统申请内存。当然,在计算机世界中,我们需要感谢操作系统,因为他是一个不收利息的银行,你借了多少内存,你就只需要还回多少内存。那么我们可以总结一下,内存泄漏的简单定义,就是申请了内存,却没有在该释放的时候释放

如果你总是贷款而不还钱,那么银行里的钱就越来越少,最终导致其他人要借钱时,就无钱可借了。现实生活中,银行为了避免无钱可接,就会把总是借钱不还的人拉入黑名单,不再借他钱;而操作系统则更加凶残,他会直接 “做了你”,操作系统将会直接 kill 掉应用程序。由此可以看出,内存泄漏的危害性与严重性,如果持续泄漏,将因内存占用过大而导致应用崩溃。当然泄漏还有其他的危害,例如内存被无用对象占用,导致接下来的内存分配需要更高的时间成本,从而造成游戏的卡顿等等。

Unity 中的内存泄漏

在对内存泄漏有一个基本印象之后,我们再来看一下在特定环境——Unity 下的内存泄漏。大家都知道,游戏程序由代码和资源两部分组成,Unity 下的内存泄漏也主要分为代码侧的泄漏和资源侧的泄漏,当然,资源侧的泄漏也是因为在代码中对资源的不合理引用引起的。

代码中的泄漏 – Mono 内存泄漏

熟悉 Unity 的猿类们应该都知道,Unity 是使用基于 Mono 的 C#(当然还有其他脚本语言,不过使用的人似乎很少,在此不做讨论)作为脚本语言,它是基于 Garbage Collection(以下简称 GC)机制的内存托管语言。那么既然是内存托管了,为什么还会存在内存泄漏呢?因为 GC 本身并不是万能的,GC 能做的是通过一定的算法找到 “垃圾”,并且自动将 “垃圾” 占用的内存回收。那么什么是垃圾呢?
我们先来看一下 wikipedia 上对于 GC 实现的简介: 

定义还是过于冗长,我们来联想一下生活中,我们一般把没有利用价值的东西,称为垃圾,也就是没有用的东西,就是垃圾。在 GC 的世界中,也是一样的,没有引用的东西,就是 “垃圾”。因为没有引用了,就意味着对于其他任何对象而言,都认为目标对象对我已经没有利用价值了,那它就是 “垃圾” 了。根据 GC 的机制,其占用的内存就会被回收。
基于以上的知识,我们很容易就可以想到为什么在托管内存的环境下,还是会出现内存泄漏了。这就像现实生活中的宅男宅女,吃了泡面总是忘记把盒子扔到门外的垃圾箱里;从计算机的角度来说,则是,在某对象超出其作用域时,我们 “忘记” 清除对该无用对象的引用了。
说到这,有的同学可能会有疑问:我每次在代码中申请的内存都非常小,少则几 B,多则几十 K,现在设备的内存都比较大(几百 M 还是有的吧),即使泄漏会产生什么大影响么?
首先,水滴石穿的典故相信大家都知道,实际代码中,并非只有显示调用 new 才会分配内存,很多隐式的分配是不容易被发现的,例如产生一个 List 来存储数据,缓存了服务器下发的一份配置,产生一个字符串等等,这些操作都会产生内存的分配。你分配几十 K,他分配几十 K,一会儿内存就没了。
其次,有一点需要说明的是,在 Unity 环境下,Mono 堆内存的占用,是只会增加不会减少的。具体来说,可以将 Mono 堆,理解为一个内存池,每次 Mono 内存的申请,都会在池内进行分配;释放的时候,也是归还给池,而不会归还给操作系统。如果某次分配,发现池内内存不够了,则会对池进行扩建——向操作系统申请更多的内存扩大池以满足该次的内存分配。需要注意的是,每次对池的扩建,都是一次较大的内存分配,每次扩建,都会将池扩大 6-10M 左右(此处无官方数据,是观察所得)。

上图是某游戏经过 Cube 测试的结果,可以看到 Mono 堆内存为 39M 左右,而建议值一般为 50M。
我们必须知道,Mono 内存泄漏是 Unity 游戏开发中需要特别重视的部分。

资源中的泄漏 – Native 内存泄漏

资源泄漏,顾名思义,是指将资源加载之后占有了内存,但是在资源不用之后,没有将资源卸载导致内存的无谓占用。
同样的,在讨论资源内存泄漏的原因之前,我们先来看一下 Unity 的资源管理与回收方式。为什么要将资源内存和代码内存分开讨论,也是因为其内存管理方式存在不同的原因。

上文中说的代码分配的内存,是通过 Mono 虚拟机,分配在 Mono 堆内存上的,其内存占用量一般较小,主要目的是程序猿在处理程序逻辑时使用;而 Unity 的资源,是通过 Unity 的 C++ 层,分配在 Native 堆内存上的那部分内存。举个简单的例子,通过 UnityEngine 命名空间中的接口分配的内存,将会通过 Unity 分配在 Native 堆;通过 System 命名空间中的接口分配的内存,将会通过 Mono Runtime 分配在 Mono 堆。

了解了分配与管理方式的区别,我们再来看看回收的方式。如上文所说,Mono 内存是通过 GC 来回收的,而 Unity 也提供了一种类似的方式来回收内存。不同的是,Unity 的内存回收是需要主动触发的。就好比说,我们把垃圾扔在门口的垃圾桶里,GC 是每天来看一次,有垃圾就收走;而 Unity 则需要你打个电话给它,通知它有垃圾要回收,它才会来。主动调用的接口是Resources.UnloadUnusedAssets()。其实 GC 也提供了同样的接口GC.Collect()
用来主动触发垃圾回收,这两个接口都需要很大的计算量,我们不建议在游戏运行时时不时主动调用一番,一般来说,为了避免游戏卡顿,建议在加载环节来处理垃圾回收的操作。有一点需要说明的是,Resources.UnloadUnusedAssets()内部本身就会调用GC.Collect()。Unity 还提供了另外一个更加暴力的方式——Resources.UnloadAsset()来卸载资源,但是这个接口无论资源是不是 “垃圾”,都会直接删除,是一个很危险的接口,建议确定资源不使用的情况下,再调用该接口。

基于上述基础知识,我们再来看一下为什么会有资源的泄漏。首先和代码侧的泄漏一样,由于 “存在该释放却没有释放的错误引用”,导致回收机制认为目标对象不是 “垃圾”,以至于不能被回收,这也是最常见的一种情况。

针对资源,还有一种典型的泄漏情况。由于资源卸载是主动触发的,那么清除对资源引用的时机就显得尤为重要。现在游戏的逻辑趋于复杂化,同时如果有新成员加入项目组,也未必能够清楚地了解所有资源管理的细节,如果 “在触发了资源卸载之后,才清除对资源引用”,同样也会出现内存泄漏了。
赶上了资源回收  
赶上了资源回收

错过了资源回收
错过了资源回收

还有一种资源上的泄漏,是因为 Unity 的一些接口在调用时会产生一份拷贝(例如 Renderer.Material 参考https://docs.unity3d.com/ScriptReference/Renderer-material.html),如果在使用上不注意的话,运行时会产生较多的资源拷贝,造成内存的无端浪费。但是此类内存拷贝一般量较少,修复起来也比较简单,这里不做大篇幅的介绍。

修复内存泄漏

根据上文描述,我们知道只要在回收到来之前,将引用解开就可以避免内存泄漏了,似乎是个很简单的问题。但是由于实际项目的逻辑复杂度往往超出想象,引用关系也不是简单的一层两层(有时候往往会多达十几层,甚至数十层才连接到最终的引用对象),并且可能存在交叉引用、环状引用等复杂情况,单纯从代码 review 的角度,是很难正确地解开引用的。如何查找导致泄漏的引用,是修复泄漏的难点和重点,也是本文主要想介绍的部分,下面就针对如何查找引用介绍一些思路和方法。至于时序问题,比较简单,在此不做赘述。

New Memory Profiler For Unity5

Unity 的 Memory Profiler 一直就是一个被用户诟病的地方,对于内存的使用量,被谁使用等信息,没有很好的反映。Unity5 作为最新一代的 Unity 产品,对于这个弱点进行了一些补强,推出了新一代的内存分析工具,较好地解决了上述问题。但是没有提供两次(或多次)内存快照的比较功能,这点比较遗憾。
注:内存快照比较是寻找内存泄漏的常用手段,将两次内存的状态截取出来,进行比较,可以清楚地发现内存的变化,寻找内存的增量与泄漏点。一般会在游戏进关前以及出关后做两次 dump,其中新增的内存分配,可以视为泄漏。
 
 

由于是 Unity 官方的工具,网上有比较详细的使用教程,在此不加赘述,可以参考下列链接或 Google:
Unity-Technologies MemoryProfiler
memoryprofiler intro
由于 Unity5 普及度及稳定性还有待提升,公司内普遍还是 4.x 的环境,那么上述的新工具就不适用了。有的同学说,升级一个 5 的工程来做 Memory Profile 嘛,这个当然也可以,不过 Unity5 对于 4 的兼容性不太好,升级过程中需要修改不少东西,维护两个工程也是比较麻烦的事。

那么,下面就给出两个在 Unity4 环境下也可以使用的泄漏追踪工具。

Mono 内存的放大镜——Cube

Cube 是 腾讯游戏下的腾讯 WeTest 平台上针对Unity项目的性能指标收集工具,通过 Cube 可以较方便地获取到游戏的各项性能指标,为性能优化提供了方向。同时 Cube 也是游戏性能一个很好的衡量工具。微信号没法直接点开链接,所以点击 “阅读原文” 可以进到工具页面。(我真的不是在做广告)
 这里我们利用 “MONO 内存对象深度分析” 的特点。该功能可以允许用户抓取某一时刻的 Mono 内存状态,并且提供不同时刻内存状态的比较,快速定位到新增的内存分配。 

鉴于 Cube 官方已经给出了详细的使用说明,就不再赘述数据的抓取过程。这里简单聊一下如何通过 Cube 抓取的数据更好地追踪和解决问题。

如下图所示,假设我们已经抓取了两次数据(snapshot1 & snapshot2),并且进行比较,得到两次内存快照之间新增的分配数据。

比较之后得到如下图所示的一系列数据,总结来说,就是在某个堆栈,分配了某个类型的对象,占用 xx 内存。这样的数据会有成千上万条(上文所说,代码中的内存分配,是非常细碎,并且数量极多的,在这里得到了验证),并且其中有很多堆栈是重复的,因为每一次的内存分配(即使是同一处位置产生的分配),都会产生一条记录。无序的数据影响了我们对数据的处理,这里我们对数据做一些分析整理。

我们举一些简单的例子来说明处理的过程。

每一条记录,都是经过一系列的函数调用(堆栈),最终分配了一些内存,用图形化的方式表示为:

让我们多加一些数据:

通过对图的观察,我们发现可以把上述离散的图整理成一棵树:

将所有数据都做同样的归类处理之后,可以得到一棵或多棵这样的分配树。这么做的好处是:
1) 根据函数,可以将内存的分配做一个模块的划分,快速定位到相关的模块。
2) 可以清晰地看到每一层函数的分配总量(如 A 函数总共分配 4096+20+4096B),可以根据占用内存的多少决定修复的优先级。
将对比之后的新增项一一清理之后,就可以基本清除 Mono 内存的多余分配和泄漏了。

顺藤摸瓜——从 Mono 中寻找资源引用

在尝试寻找资源引用,修复资源泄露之前,我们需要先了解一下如何在 Unity 中定位资源泄漏。
我们需要使用 Unity 自带的 Memory Profiler(注意不是上文说的 Unity5 的新 Profiler,是老的残疾版 Profiler)。举个简单的例子,在 Unity 编辑器环境下运行游戏工程,经过 “大厅” 页面,进入到 “单局”。此时打开 Unity Profiler,切换到 Memory 并做一次内存采样(具体请参考https://docs.unity3d.com/Manual/ProfilerMemory.html,不赘述)。 在采样的结果中(其中包含采样时刻内存中所有的资源),点开 Assets->Texture2D,如果其中可以看到有 “大厅” UI 使用的贴图(如下图),那么我们可以定义这张 UI 贴图,属于资源上的泄漏。


为什么说这种情况就属于资源泄漏呢,因为这张 UI 贴图,是在 “大厅” 时申请的,但是在 “单局” 时,它已经不被需要了,可是它还在内存中。这种在不需要的时候,却还存在的内存占用,就是上文我们定义的内存泄漏。

那么在平时项目中,我们如何找到这些泄漏的资源呢?
最直观的方法,当然也是最笨的方法,就是在每次游戏状态切换的时候,做一次内存采样,并且将内存中的资源一一点开查看,判断它是否是当前游戏状态真正需要的。这种方法最大的问题,就是耗时耗力,资源数量太多眼睛容易看花看漏。

这里介绍两种讨巧的方法:
1) 通过资源名来识别。即在美术资源(如贴图、材质)命名的时候,就将其所属的游戏状态放在文件名中,如某贴图叫做 BG.png,在大厅中使用,则修改为 OG_BG.png(OG = OutGame)。这样在一坨 IG(IG=InGame)资源里面,混入了一个 OG,可以很容易地识别出来,也方便利用程序来识别。这么做还有一个好处,可以强化美术对资源生命周期的认识,在制作资源,特别是规划 UI 图集时,可以有一个指导意义。
2) 通过 Unity 提供的接口Resources.FindObjectsOfTypeAll()进行资源的 Dump,可以根据需求 Dump 贴图、材质、模型或其他资源类型,只需要将 Type 作为参数传入即可。Dump 成功之后我们将结果保存成一份文本文件,这样可以用 Beyond Compare 对多次 Dump 之后的结果进行比较,找到新增的资源,那么这些资源就是潜在的泄漏对象,需要重点追查。
结合上述的方法与思路,应该可以轻松找到泄漏的资源了。

此时我们再回头看一下 Unity Profiler,其实 Unity 提供了资源索引的查找功能,只不过该功能是以一个树形结构的文本来展示的(如下图)。上文曾提到过,Unity 内部的引用关系往往是非常复杂的,可能需要通过十几甚至几十层的引用,才能找到最终的引用者,并且引用关系错综复杂,形成一张庞大的图,此时光靠展开树形结构来查找,几乎是不可能的事了。

防微杜渐,避免内存泄漏

介绍完对于 Unity 内存泄漏的追踪方法,我还想往下多讲一步,只要我们在平时开发的过程多做思考,防微杜渐,内存泄漏是完全可以避免的。相对于等泄漏发生了再回头来追查,平时多花点时间清理 “垃圾” 反而是更加高效的做法。
落地到平时的开发流程中,在这里提出几点建议,欢迎各位大牛补充:
1) 在架构上,多添加析构的 abstract 接口,提醒团队成员,要注意清理自己产生的 “垃圾”。
2) 严格控制 static 的使用,非必要的地方禁止使用 static。
3) 强化生命周期的概念,无论是代码对象还是资源,都有它存在的生命周期,在生命周期结束后就要被释放。如果可能,需要在功能设计文档中对生命周期加以描述。
相信大家出门旅游,都有看过下图类似的标语,作为一名合格的程序猿,也应该能够处理好代码中的 “垃圾”,不要让我们的游戏成为一个 “垃圾场”。
 
为了避免以上手游性能方面对游戏的负面影响,腾讯 WeTest 平台下的Cube工具可以帮助开发者发现游戏内分类资源的一个占用情况,帮助在游戏开发过程中不断改善玩家的体验。目前功能还在免费开放中。点击http://wetest.qq.com/cube/立即体验!

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册