返回「计算机、信息技术与工程」

【优化】Unity内存泄露

更多
Markdown 结构化数据
本文目录 38 个章节

【优化】Unity内存泄露

创建时间:2020/6/18 20:46

  • 【优化】Unity内存泄露
    • 1 概念
    • 2 后果
    • 3 Unity内存泄露
      • 3.1 概述
      • 3.2 代码中的泄漏–Mono内存泄漏
        • GC概念
        • Mono堆内存泄露的定义
        • 泄露的情况
      • 3.3 资源中的泄漏–Native内存泄漏
        • Native资源管理与回收方式
        • 泄露的情况
    • 4 修复内存泄露——定位即修复
      • 4.1 思路与方法
        • 内存快照比较
        • 通过资源名来识别
      • 4.2 工具与使用
        • Unity Profiler查找内存泄露
        • 通过Unity提供的接口Resources.FindObjectOfTypeAll进行资源的Dump
        • Leak Parser的方案
          • 为什么在Mono层可以Dump出引用关系?
      • 4.3 UWA博客提供的泄漏检测方法
        • 检查资源的使用情况,特别是纹理、网格等资源的使用
        • 通过Profiler来检测WebStream或SerializedFile的使用情况
        • 通过Android PSS/iOS Instrument反馈的App线程内存来查看
    • 5 避免内存泄漏
    • 6 关于内存泄漏的一些问题及经验
      • 频繁new后果
      • static变量
      • Destroy机制与Custom Null Check of Unity
        • 现象
        • 解释
        • 总结:Destroy机制
      • 误区
    • 7 案例
      • 7.1 Unity填坑笔记——记一次“内存泄露”的排查
        • 问题原因
        • 解决方法
    • Ref

1 概念

内存泄漏(Memory leak) 是在计算机科学中,由于疏忽或错误造成程序 未能释放已经不再使用的内存 。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在 释放该段内存之前就失去了对该段内存的控制 ,从而造成了 内存的浪费

在不需要的时候,却还存在的内存占用 ,就是内存泄露

2 后果

内存泄漏会因为减少可用内存的数量从而降低计算机的性能。最终,在最糟糕的情况下,过多的可用内存被分配掉导致全部或部分设备停止正常工作,或者应用程序崩溃。

内存泄漏带来的后果 可能是不严重 的,有时甚至能够被常规的手段检测出来。在现代操作系统中,一个应用程序使用的常规内存在程序终止时被释放。这表示一个短暂运行的应用程序中的内存泄漏不会导致严重后果。

在以下情况,内存泄漏导致 较严重的后果

  • 程序运行后置之不理,并且随着时间的流逝消耗越来越多的内存(比如服务器上的后台任务,尤其是嵌入式系统中的后台任务,这些任务可能被运行后很多年内都置之不理);

  • 新的内存被频繁地分配,比如当显示电脑游戏或动画视频画面时;

  • 程序能够请求即使在程序终止之后也不会被释放的内存(比如共享内存);

  • 泄漏在操作系统内部发生;

  • 泄漏在系统关键驱动中发生;

  • 内存非常有限,比如在嵌入式系统或便携设备中; 当运行于一个程序终止时内存并不自动释放内存的操作系统(比如AmigaOS)之上时。

Ref: https://zh.wikipedia.org/wiki/%E5%86%85%E5%AD%98%E6%B3%84%E6%BC%8F

3 Unity内存泄露

3.1 概述

在Unity的实际项目中,大多数的泄漏,都是因为 static 导致的。我们在做需求的时候,因为static用起来方便,可以跨过实例的束缚,快速地保存、获取数据;但是用完了之后,就 忘记顺手清空static引用 了。因为 static的作用域是全局,回收机制都会把它当做是“非垃圾”来对待,那么相关的对象就不会被销毁 了。

Unity下的内存泄漏也主要分为 代码侧的泄漏资源侧的泄漏

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

Ref: https://gameinstitute.qq.com/community/detail/108181

GC概念

Unity是使用基于Mono的C#作为脚本语言,它是 基于Garbage Collection机制的内存托管语言 。那么既然是内存托管了,为什么还会存在内存泄漏呢?因为GC本身并不是万能的,GC能做的是通过一定的算法找到“垃圾”,并且自动将“垃圾”占用的内存回收。那么什么是垃圾呢?

垃圾 :没有引用的东西 垃圾回收 (Garbage Collection,GC)是指一种 自动的存储器管理机制当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间 。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。

Mono堆内存泄露的定义

既然mono已经有了完善的GC机制,那是否还会存在内存泄漏呢? 答案是肯定的,只是此处的内存泄漏需要重新定义一下,我们 把对象已经不再需要使用却没有被GC回收的情况称为mono内存泄漏 。Mono内存泄漏会使空闲内存减少,GC频繁,mono堆不断扩充,最终导致游戏内存占用的升高。

泄露的情况

在某对象超出其作用域时,我们 “忘记”清除对该无用对象的引用了。 在不需要的时候,却还存在的内存占用 ,就是内存泄露

旧版 Mono/Unity 项目中,托管堆扩张后常不会立即把全部保留内存返还给操作系统,但这不等于所有 Unity 版本和后端都“只增不减”。增长步长也不是固定的 6—10 MB。应区分 Managed Used、Managed Reserved 与进程系统内存,并按当前 Unity 版本、Mono/IL2CPP 后端和 GC 模式实测。

最常见的一种情况:“存在该释放却没有释放的错误引用”,导致回收机制认为目标对象不是“垃圾”,以至于不能被回收

  1. 忘记顺手清空static引用 。因为 static的作用域是全局,回收机制都会把它当做是“非垃圾”来对待,那么相关的对象就不会被销毁

游戏中大部分mono内存泄漏的情况都是由于静态对象的引用引起的,因此对于静态对象的使用需要特别注意,尽量少用静态对象,对于不再需要的对象将其引用设置为null,使其可以被GC及时回收

  1. 由于 Lua对于C#对象的引用 ,导致C#的GC机制无法释放掉对应的内存对象。

Lua自身是不会拿到C#的对象的,而是通过Tolua这个胶水层来处理。深入ToLua来看,会发现所有对象的引用都是由ObjectTranslator这个类来处理,其中使用了一个ObjectPool对C#对象进行存储,Lua层拿到的是一个int形式的Handler。对于Lua层拿到的对象,会重写其__gc函数,当Lua的GC执行的时候,会调用这一函数,从而释放掉ObjectTranslator这层缓存的C#对象。

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

资源泄漏,是指将资源加载之后占有了内存,但是在资源不用之后,没有将资源卸载导致内存的无谓占用。

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

Native资源管理与回收方式

Unity的Native内存回收是需要主动触发的。主动调用的接口是Resources.UnloadUnusedAssets()。

其实GC也提供了同样的接口GC.Collect()用来主动触发垃圾回收,这两个接口都需要很大的计算量,我们不建议在游戏运行时时不时主动调用一番,一般来说,为了避免游戏卡顿,建议在加载环节来处理垃圾回收的操作。

Unity还提供了另外一个更加暴力的方式——Resources.UnloadAsset()来卸载资源,但是这个接口无论资源是不是“垃圾”,都会直接删除,是一个很危险的接口,建议确定资源不使用的情况下,再调用该接口。

泄露的情况

首先和代码侧的泄漏一样,由于 “存在该释放却没有释放的错误引用”,导致回收机制认为目标对象不是“垃圾”,以至于不能被回收 ,这也是最常见的一种情况。

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

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

例子:因为这张UI贴图,是在“大厅”时申请的,但是在“单局”时,它已经不被需要了,可是它还在内存中。这种在不需要的时候,却还存在的内存占用,就是上文我们定义的内存泄漏。

4 修复内存泄露——定位即修复

只要 在回收到来之前,将引用解开 就可以避免内存泄漏了,似乎是个很简单的问题。但是由于实际项目的逻辑复杂度往往超出想象,引用关系不是简单的一层两层(有时候往往会多达十几层,甚至数十层才连接到最终的引用对象),并且可能存在交叉引用、环状引用等复杂情况,单纯从代码review的角度,是很难正确地解开引用的。

4.1 思路与方法

内存快照比较

内存快照比较是寻找内存泄漏的常用手段,将两次内存的状态截取出来,进行比较,可以清楚地发现内存的变化,寻找内存的增量与泄漏点。一般会在游戏进关前以及出关后做两次dump,其中新增的内存分配,可以视为泄漏。

通过资源名来识别

即在美术资源(如贴图、材质)命名的时候,就将其所属的游戏状态放在文件名中,如某贴图叫做BG.png,在大厅中使用,则修改为OG_BG.png(OG = OutGame)。这样在一坨IG(IG=InGame)资源里面,混入了一个OG,可以很容易地识别出来,也方便利用程序来识别。这么做还有一个好处,可以强化美术对资源生命周期的认识,在制作资源,特别是规划UI图集时,可以有一个指导意义。

4.2 工具与使用

Unity Profiler查找内存泄露

举个简单的例子,在Unity编辑器环境下运行游戏工程,经过“大厅”页面,进入到“单局”。打开Unity Profiler,切换到Memory并做一次内存采样(具体请参考https://docs.unity3d.com/Manual/ProfilerMemory.html)。在采样的结果中(其中包含采样时刻内存中所有的资源),点开Assets->Texture2D,如果其中可以看到有“大厅”UI使用的贴图,那么我们可以定义这张UI贴图,属于资源上的泄漏。

如何找到这些泄漏的资源: 最直观的方法,当然也是最笨的方法,就是在每次游戏状态切换的时候,做一次内存采样,并且将内存中的资源一一点开查看,判断它是否是当前游戏状态真正需要的。

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

通过Unity提供的接口Resources.FindObjectOfTypeAll进行资源的Dump

可以根据需求Dump贴图、材质、模型或其他资源类型,只需要将Type作为参数传入即可。Dump成功之后我们将结果保存成一份文本文件,这样可以用BeyondCompare对多次Dump之后的结果进行比较,找到新增的资源,那么这些资源就是潜在的泄漏对象,需要重点追查。

Leak Parser的方案

Dump出资源的引用关系,并通过画图库,将引用关系图表化,以方便引用关系的查找。

为什么在Mono层可以Dump出引用关系?

因为 每一个资源的Native对象(可以认为是Unity在C++层分配的对象),在Mono层都有一个壳对象 ;我们平时在C#里对对象的操作,都是在壳对象上完成的。例如Mesh.GetTriangles(),我们其实是调用了壳对象Mesh的GetTriangles接口,而实际的工作,则是GetTriangles调用Unity底层的Native对象对应的NativeGetTriangles(这名字是我瞎掰的,不是真的叫这个名字,这么写是为了方便理解)来完成的。更重要的是,我们所关注的引用关系,其实都是Mono层壳对象之间的引用关系(也可以理解为是逻辑层的引用关系,逻辑层是C#实现的,逻辑层不能跳过接口,直接引用引擎层的对象),查找引用,重点也就是关注这一部分。

其他工具:腾讯Wetest的Unity Cube

4.3 UWA博客提供的泄漏检测方法

检查资源的使用情况,特别是纹理、网格等资源的使用

在我们进行过的项目深度优化过程中,资源泄漏是内存泄露的主要表现形式,其具体原因是用户对加载后的资源进行了储存(比如放到Container中),但在场景切换时并没有将其Remove或Clear,从而无论是引擎本身还是手动调用Resources.UnloadUnusedAssets等相关API均无法对其进行卸载,进而造成了资源泄露。对于这种情况的排查相当困难,这是因为项目中的资源量过于巨大,泄露资源往往很难定位。因此,我们在UWA测评报告中对项目中的每个资源都进行了详细的监控,并通过“生命周期”这一衡量指标让大家可以清楚地了解到每个资源在项目运行过程中的使用范围。 这样,大家可以通过资源的“生命周期”属性来快速查看有哪些资源是“常驻”内存的,并且判断该资源是“预加载”资源还是“泄露”资源。

资源场景比较:

  • 同种类型场景或同一场景进行比较

  • 不同类型场景进行比较 不同类型的场景,其资源使用是完全不同的。比如,游戏中主城和战斗副本的资源,除少部分常驻内存的资源外,二者使用的绝大部分资源应该是不一致的。所以,通过比较两种不同类型的场景,你可以直接查看比较结果中的“ 共同资源 ”,并判断其是否确实为预先设定好的常驻资源。如果不是,则它很可能是“泄露”资源,需要你进一步查看项目的资源管理是否存在漏洞。

通过Profiler来检测WebStream或SerializedFile的使用情况

AssetBundle的管理不当也会造成一定的内存泄露,即上一场景中使用的AssetBundle在场景切换时没有被卸载掉,而被带入到了下一场场景中。对于这种情况,建议直接通过Profiler Memory中的Take Sample来对其进行检测,通过直接查看WebStream或SerializedFile中的AssetBundle名称,即可判断是否存在“泄露”情况。

通过Android PSS/iOS Instrument反馈的App线程内存来查看

承接上述“误区二”中的说法,“Unity Profiler中内存回落正常,但Android的PSS数值并没有完全回落”是有可能的,这是因为Unity Profiler反馈的是引擎的真实分配的物理内存,而PSS中记录的则包括系统的部分缓存。一般情况下,Android或iOS并不会及时将所有App卸载数据进行清理,为了保证下次使用时的流畅性,OS会将部分数据放入到缓存,待自身内存不足时,OS Kernel会启动类似LowMemoryKiller的机制来查询缓存甚至杀死一些进程来释放内存。因此,并不能通过一两次的PSS内存没有完全回落来说明内存泄露问题。

我们推荐的测试方式是在两个场景之间来回不停切换,比如主城和战斗副本间。理论上来说,多次切换同样的场景,如果Profiler中显示的Unity内存回落正常,那么其PSS/Instrument的内存数值波动范围也是趋于稳定的,但如果出现了PSS/Instrument内存持续增长的情况,则需要大家注意了。这可能有两种可能:

  • Unity引擎自身的内存泄露问题 。这种概率很小,之前仅在少数版本中出现过。

  • 第三方插件在使用时出现了内存泄露 。这种概率较大,因为Profiler仅能对Unity自身的内存进行监控,而无法检测到第三方库的内存分配情况。因此,在出现上述内存问题时,建议大家先对自身使用的第三方库进行排查。

5 避免内存泄漏

内存泄漏是完全可以避免的。相对于等泄漏发生了再回头来追查,平时多花点时间清理“垃圾”反而是更加高效的做法。

  1. 在架构上,多添加析构的abstract接口,提醒团队成员,要注意清理自己产生的“垃圾”。

  2. 严格控制static的使用,非必要的地方禁止使用static。

  3. 强化生命周期的概念,无论是代码对象还是资源,都有它存在的生命周期,在生命周期结束后就要被释放。如果可能,需要在功能设计文档中对生命周期加以描述。

6 关于内存泄漏的一些问题及经验

频繁new后果

创建出来的东西,如果被引用在一个容器里,或者被某些脚本的变量引用,那么这部分堆内存就释放不掉;但如果没有被任何容器或者变量引用(比如,临时拼一个 String),那么这部分堆内存会在 GC 的时候释放(释放是指变为空闲的堆内存,堆内存的总量是不会下降的)。 对于后者, 频繁地 new 对象虽然不会一直增加堆内存,但是会加速 GC 调用的频率 ,所以同样是需要尽量避免的。

static变量

如果脚本是MonoBehaviour,而且在切换场景后所挂的Game Object被释放了,那么这个脚本对象所引用的堆内存就会在GC的时候被释放。 但有一种例外,如果是通过Static变量引用的堆内存,那么依然是释放不掉的,除非手动解开引用,比如变量置Null,数组Clear等等。

Destroy机制与Custom Null Check of Unity

现象

Destroy了B对象所在的GameObject后,遍历打印B的引用都为Null,在Inspector面板上看是missing。而这时候进行GC,堆内存其实并未释放这些B对象。只有当A对象中的数组被清空后,再调用GC,才可释放这些对象所占内存。

解释

Unity中对Null的检测做了特殊的处理,在Unity中MonoBehaviour对象除了存在于Managed Heap中作为“壳”(wrapper objects),在Native内存中还会有一个相对应的“实体”,在调用Destroy时,真正被释放的正是这个“实体”。而在判断一个MonoBehaviour对象是否为Null时,Unity会首先检测“实体”是否已经被销毁,如果是则返回为true,但此时Managed Heap中的“壳”实际上依然是被引用的,从而就会出现对象的Null判断为true,但实际上还是被引用着,无法被GC释放的问题。 如果作为Unity.GameObject对象是null,而作为System.Object对象不是null,说明这个对象已经被Unity标记为销毁了,Unity.GameObject重载的==运算符让游戏逻辑认为它是空的。

官方解释:http://blogs.unity3d.com/2014/05/16/custom-operator-should-we-keep-it/ When you get a c# object of type “GameObject”[2], it contains almost nothing. this is because Unity is a C/C++ engine. All the actual information about this GameObject (its name, the list of components it has, its HideFlags, etc) lives in the c++ side. The only thing that the c# object has is a pointer to the native object. We call these c# objects “wrapper objects”. The lifetime of these c++ objects like GameObject and everything else that derives from UnityEngine.Object is explicitly managed. These objects get destroyed when you load a new scene. Or when you call Object.Destroy(myObject); on them. Lifetime of c# objects gets managed the c# way, with a garbage collector. This means that it’s possible to have a c# wrapper object that still exists, that wraps a c++ object that has already been destroyed. If you compare this object to null, our custom == operator will return “true” in this case, even though the actual c# variable is in reality not really null. downsides:

  • It is counterintuitive.

  • Comparing two UnityEngine.Objects to eachother or to null is slower than you’d expect.

  • The custom ==operator is not thread safe, so you cannot compare objects off the main thread. (this one we could fix).

  • It behaves inconsistently with the ?? operator, which also does a null check, but that one does a pure c# null check, and cannot be bypassed to call our custom null check.

总结:Destroy机制

将Native对象Destroy掉(是否立刻释放Native层的内存不清楚),C#层的壳不做处理,访问C#层对象时,会进行判空——根据引用的C#层对象,访问相应的Native层对象,看是否被Destroy,如果Native对象是Destroyed,则返回null。此时的null是fake null,C#层的对象壳还在被引用,无法被GC。只有将C#层手动置空,或者所在的容器Clear掉,才能被GC。

误区

  1. 我的项目进出场景前后内存回落不一致,比如进入场景后,内存增加40MB,出来后下降30MB,仍有10MB内存没有返回给系统,即说明内存存在泄露情况。

  2. 我的项目在进出场景前后,Unity Profiler中内存回落正常,但Android的PSS数值并没有完全回落(出场景后的PSS值高于进场景前的PSS值),即说明内存存在泄露情况。

以上两种情况均 不能单独证明内存泄漏 。内存不完全回落可能来自资源缓存、分配器保留、驱动或系统共享页统计。更可靠的证据是:在可重复场景中多轮操作后持续增长,并能用快照、引用链、原生分配调用栈或映射明细定位到不再需要却仍被保留的对象。

7 案例

7.1 Unity填坑笔记——记一次“内存泄露”的排查

原文链接

问题原因

  1. 在没有UI缓存的情况下,每创建一个ui,都会去初始化对应的prefab,并且Lua层会获取自己需要设置的那些GameObject以及Component,这时候这些对象都会在ObjectTranslator这层有记录;

  2. 当UI关闭的时候,会调用GameObject.Destroy函数,将对应的C# GameObject销毁;

  3. 这时候,Lua中那些对于C#对象的应用并不会销毁,因为没有调用Lua的GC,于是出现了ObjectTranslator这层依然保存着这些对象的引用的情况;

  4. 由于Lua的内存增长比较慢,所以对于GC的触发非常不频繁;

  5. C#部分GC的时候,对于这些在ObjectTranslator层记录的对象,虽然它们在Unity眼中已经不再被使用了,与null的相等判定结果是true,但是作为System.Object对象,它们实际上并不是null,而且在被ObjectTranslator对象引用,无法释放占用的内存空间,这就导致了内存的增长,即使触发了C#的GC逻辑,也无法进行释放;

  6. 当Lua的GC被调用过一次之后,下次C#的GC就可以释放掉这部分的对象。

解决方法

对于跨语言的系统设计,内存释放一直是要持续关注的部分。这次发现的问题并不是ToLua的bug,而是由于C#和Lua都是基于延迟清理的思路实现GC算法,再加上两边的GC无法同步进行而导致的。 虽然游戏已经上线,对于C#部分的修改也比较难提交,但是我们还是讨论和分析了一些解决方案。

  1. 比较理想的方式,其实是在C#触发GC的时候,先去调用一次Lua的GC,这样让两边的GC有一个同步的过程,可以多地释放掉无用的内存。但是这种方式不太好实现,貌似没找到方便监听系统触发GC的逻辑。

  2. 使用更高频率的Lua GC。我们之前Lua手动GC的方式是在状态改变的时候,这次针对ui开关的测试是无法触发到Lua的手动GC的,那么一种思路是按照一个间隔来手动触发Lua的GC,尽早释放掉内存。但是这个实际其实比较难找,做不好会造成莫名其妙的顿卡。

  3. Tolua的作者蒙哥建议在关闭ui这样的节点,手动做一下一个小Step的GC,这样可以保证释放掉一部分内存。这个Step的参数要自己调整好,过大会在关闭ui的时候造成顿卡,过小又没办法及时释放内存。

  4. 在Lua中确定不再需要C#对象的时候,手动使用System.Object的Destroy函数进行释放,这个Warp出来的函数Tolua做了特殊的处理,会调用Tolua.Destroy来进行释放。这种就相当于针对这些对象放弃了自动GC的逻辑,需要手动进行释放,好处是可以精准控制,但是坏处是很繁琐,需要对于代码做大量的重构。

  5. 在C#层,做一个tick逻辑,每帧检查ObjectTranslator中的objects中的一部分对象,如果是Unity.GameObject类型的,查看其是否等于null,如果作为Unity.GameObject对象是null,而作为System.Object对象不是null,说明这个对象已经被Unity标记为销毁了,Unity.GameObject重载的==运算符让游戏逻辑认为它是空的,这时候C#对象可以提前销毁掉,因为即便Lua层想访问它,也已经会报错了。

方法5来进行内部的测试,原因是这种方式对于Lua层代码的改动最小,也能解决我们的大部分问题

Ref

https://blog.uwa4d.com/archives/optimzation_memory_2.html