深入浅出再谈Unity内存泄漏

来源:互联网 发布:合肥网络招聘会 编辑:程序博客网 时间:2024/06/14 18:40

原文地址:http://blog.csdn.net/wetest_tencent/article/details/52318607

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/立即体验!


0 0
原创粉丝点击
热门问题 老师的惩罚 人脸识别 我在镇武司摸鱼那些年 重生之率土为王 我在大康的咸鱼生活 盘龙之生命进化 天生仙种 凡人之先天五行 春回大明朝 姑娘不必设防,我是瞎子 中专升大专的入学考没考上怎么办 小孩摔跤额头出了个包怎么办 小孩摔跤后脑勺出了个包怎么办 结婚后疏于关心老婆寒心了怎么办 江苏取消小高考高二学生怎么办 上海学而思家长陪读听不懂怎么办 高考报名的电话号码填错了怎么办 高考报名用的电话号码变换了怎么办 弟媳妇一个月就大闹一次怎么办 丈夫出轨我亲弟媳妇我怎么办 被山西博大泌尿医院坑了怎么办 家长反应孩子学校受欺负老师怎么办 白色衣服和牛仔裤洗变色了怎么办 生完孩子肚子上的松皮怎么办 xp电脑玩cf进入地图黑屏怎么办 爸妈吵架妈妈走了爸爸哭了该怎么办 总担心旅馆被拍视频传上网怎么办 微博买了猜冠军现在停了怎么办 脸上毛孔大有黑头怎么办小窍门去 进去精神病院出来真的疯了怎么办 房子已过户新业主不交物业费怎么办 村委会欠百姓征地补偿款不给怎么办 因为近亲人人都不看好的婚姻怎么办 碰到工作中特别积极的同事怎么办 丈夫车祸死亡妻子和孩子以后怎么办 丈夫死后妻子改嫁儿子不同意怎么办 满了60岁社保没满15年怎么办 捷豹的dpf灯亮了怎么办 朋友如新直销产品是你该怎么办 传福音接受了却被家人拦阻该怎么办 奶奶出钱由孙子抓奖中奖后怎么办 我不想学车了驾校不同意退学怎么办 2017年大学挂科面临退学怎么办 微信重新登录后东西全没了怎么办 宝宝吃鸡蛋过敏全身起红疹怎么办 180在产蛋鸡因断鸡减产怎么办 住友39熔接机熔接损耗大怎么办 支付宝实名认证刷脸失败怎么办 支付宝注册刷脸不是本人怎么办 小学科学只考88分中学怎么办 收银机关机时才上传数据是怎么办