zoukankan      html  css  js  c++  java
  • 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/立即体验!

  • 相关阅读:
    百度之星资格赛1001——找规律——大搬家
    HDU1025——LIS——Constructing Roads In JGShining's Kingdom
    DP(递归打印路径) UVA 662 Fast Food
    递推DP UVA 607 Scheduling Lectures
    递推DP UVA 590 Always on the run
    递推DP UVA 473 Raucous Rockers
    博弈 HDOJ 4371 Alice and Bob
    DFS(深度) hihoCoder挑战赛14 B 赛车
    Codeforces Round #318 [RussianCodeCup Thanks-Round] (Div. 2)
    DP(DAG) UVA 437 The Tower of Babylon
  • 原文地址:https://www.cnblogs.com/lancidie/p/8643309.html
Copyright © 2011-2022 走看看