在U3D中使用C#编程的内存管理要点

1 篇文章 0 订阅

托管与非托管

托管

托管代码

    托管代码一般是指被公共语言运行库CLR运行的代码,这些代码被编译成一种中间语言(IL),被封装在一个叫程序集(assembly)的文件中,包含了描述你所创建的类,方法和属性的所有元数据。不能直接被机器运行,因此这些代码可以在不同语言的平台之间兼容。当某些方法被调用的时候,CLR把具体的方法编译成适合本地机器运行的机器码,然后把编译好的机器码缓存到内存,以备下次调用,(Just in Time compilation,简称JIT编译,与之对应的Ahead of Time,简称AOT,运行前编译)

这也是IOS不能用C#热更新的原因,IOS出于app store的审批问题或者别的深层次的安全问题,只支持AOT,不支持JIT。也因此,IOS上打包,必须经过C#(实际上Mono) àIL2PP àXcode工程的流程。这个转化,实际上就是废掉了JIT,强行执行AOT的流程。

简单粗暴的理解,我们用C#(.netmono)写出来的代码,都是托管代码。但是托管代码产生的不一定全是托管内存。

 

托管内存

       同样的,托管内存一般是指被公共语言运行库CLR控制的内存资源。由于有CLR控制,所以我们可以不去关心这些内存的回收,CLR会自己调用垃圾回收器进行回收。当然,你也可以主动去调用垃圾回收器回收。不过回收机制是CLR控制的。

       一般而言,在U3D中,我们使用C#创建出来的各种对象都属于托管资源,例如GameObject , 各种Compenent。只是引用类型在堆里,值类型在栈上。栈上的对象在函数调用过程中或者调用结束后就全部被释放掉,因此我们不需要管理栈内存。我们主要需要关注的就是托管堆,CLR的C#回收机制主要是基于Mark Sweep,不是引用计数(引用计数难以解决互相引用,并且在超长引用链的时候性能表现不好)

       Mark Sweep即标记(mark)清除(sweep)算法,在了解这个算法之前,先明确几个基本概念。

    首先是mutatorcollector,这两个名词经常在垃圾收集算法中出现,collector指的就是垃圾收集器,而mutator是指除了垃圾收集器之外的部分,比如说我们应用程序本身。mutator的职责一般是NEW(分配内存),READ(从内存中读取内容),WRITE(将内容写入内存),而collector则就是回收不再使用的内存来供mutator进行NEW操作的使用。

第二个基本概念是关于mutator roots(mutator根对象),mutator根对象一般指的是分配在堆内存之外,可以直接被mutator直接访问到的对象,一般是指静态/全局变量以及Thread-Local变量(Java中,存储在java.lang.ThreadLocal中的变量和分配在栈上的变量 - 方法内部的临时变量等都属于此类)。简而言之,这些变量即包含了所有的指针。

第三个基本概念是关于可达对象的定义,从mutator根对象开始进行遍历,可以被访问到的对象都称为是可达对象。这些对象也是mutator(你的应用程序)正在使用的对象。

 

明确这几个基本概念后,这个算法的原理也就呼之欲出了。先遍历一次所有的指针,将其指向的堆内存标记为可到达。然后再遍历一次所有的堆内存,没有被标记为可到达的即可以回收了。

因此,尽管使用的不是引用计数法,在管理托管堆内存的时候,我们要关注的点仍然是引用。只有当该堆内存对象的引用全部解除后,才能被GC

 

 

   

非托管

非托管代码

       相对于托管代码,非托管代码即是不被 CLR公共运行库控制和管理的代码。托管代码直接编译成目标计算机的机械码,这些代码只能运行在编译出它们的计算机上,或者是其它相同处理器或者几乎一样处理器的计算机上。非托管代码不能享受一些运行库所提供的服务,例如安全和内存管理等。如果非托管代码需要进行内存管理等服务,就必须显式地调用操作系统的接口。

非托管内存

代码层的非托管内存

非托管内存指的是.NET/Mono不知道如何回收的资源,最常见的一类非托管资源是包装操作系统资源的对象,例如文件,窗口,网络连接,数据库连接,画刷,图标等。

但是并不意味着垃圾回收器不会处理这些对象,只是处理的方式并非直接释放,而是调用Object.Finalize()方法(.net/mono中所有的引用类型对象都是派生自Object)。对于非托管对象,需要在此方法中编写回收非托管资源的代码,以便垃圾回收器正确回收资源。

默认情况下,该方法是空的,而且在.NET/MonoObject.Finalize()方法是无法重载的。那有的同学就会问了,既然如此,那还怎么释放?而且这样不是脱了裤子放屁多此一举么?

.NET/Mono中,编译器是根据类的析构函数来自动生成Object.Finalize()方法的,所以对于包含非托管资源的类,可以将释放非托管资源的代码放在析构函数。但是不能在析构函数中释放托管资源,因为析构函数是有垃圾回收器调用的,属于另外一个范畴,可能在析构函数调用之前,类包含的托管资源已经被回收了,从而导致无法预知的结果。

有的同学可能会觉得这样很麻烦,又是析构函数,又得注意是不是托管资源的。有没有比较简单粗暴的方法来释放这个对象?

有,那就是Dispose()方法,让使用者能够手动的释放类的托管资源和非托管资源,使用者手动调用此方法后,垃圾回收器不会对此类实例再次进行回收。

       只有当这个类不适合或者无法找到手动Dispose()的时机时,才使用析构函数来释放非托管资源。

.NET/Mono中应该尽可能的少用析构函数释放资源。因为没有析构函数的对象垃圾处理器一次处理就能从内存删除,但有析构函数的对象,需要两次,第一次调用析构函数,第二次删除对象。而且在析构函数中包含大量的释放资源代码,会降低垃圾回收器的工作效率,影响性能。所以对于包含非托管资源的对象,最好及时的调用Dispose()方法来回收资源,而不是依赖垃圾回收器。

       C#中,凡是继承了IDisposable接口的类,都可以使用using语句,从而在超出作用域后,让系统自动调用Dispose()方法。 一个资源安全的类,都实现了IDisposable接口和析构函数。提供手动释放资源和系统自动释放资源的双保险。

非代码层的非托管内存

       除了上述的部分,还有一部分资源,从语言的角度讲,它们不属于非托管资源。但是从引擎的角度讲,它们也可以算作非托管资源的一部分。那就是我们的文件资源。例如我们加载的贴图,模型,骨骼,配置文件等。实际项目运行过程中,这些才是真正的大头。

       U3D中,一般这些资源的加载是通过AssetsBundle来实现的。我们都知道,在加载AssetsBundle时候,最开始加载的AssetsBundle的只是一个硬盘资源镜像, 其中包含了资源的索引,资源的依赖关系,但是并不包含Assets资源本身。只有当执行AssetsBundle.Load以后,才算是真正将资源加载到了内存。(www方式比较特殊,其加载使加载整个资源到内存,完了创建一个Assetsbundle镜像给你)

       针对AssetsBundleU3D提供了UnLoad(false)Unload(true)两种方式,前者只是卸载AssetsBundle镜像文件,但是没有释放Assets资源本身。而后者才是释放Assets资源本身。

       我们这部分的核心议题是如何管控Assets资源的内存。

       一种办法是使用U3D 提供的AssetsBundle.UnloadUnusedAssets()。方法名说的很明白,释放没有被引用的Assets资源。但是这种办法弊端很多。首先针对Assets的引用我们需要很小心,这涉及到托管堆内存的释放。假如托管堆一个Compenent没有被正确释放,例如MeshRenderer,其引用到的贴图和模型等就都无法被AssetsBundle.UnloadUnusedAssets()方法释放。其次,AssetsBundle.UnloadUnusedAssets()方法本身的开销很大,他需要遍历和检查所有Assets资源的被引用情况。其三,由于开销很大,所以导致一般只能在切场景的时候调用,如果玩家长时间在一个场景,并且接触的资源逐步增加的时候(例如一直有不同装备的玩家路过),很可能超过上限造成闪退。

       比较推荐的方式还是将资源分门别类,然后做好引用计数和内存阈值分配。超过的部分手动调用UnLoad(true)直接释放。这样一来首先各个模块之间的资源进行了切割,降低了风险。在释放时的开销也可以平摊。允许在场景运行过程中进行资源释放。

      

降低GC的办法

       为什么特别讲一下GC。因为在项目,大部分明显的卡顿都可以直接找到原因,或者是加载的资源过大,或者是一帧中处理的计算峰值过高等。从profiler中可以直接找到原因(U3D的原生Profiler只能显示C#,Lua层的也可以做到,但是需要做工具。这里不展开讲)。

    但是唯独GC导致的卡顿,你无法直接找到原因。就更不用说解决方案。而且很多频繁GC导致频繁卡顿的项目,是从一开始就积累了各种小问题,最终导致质变,成了一个很烂而且难以优化的项目。

缓存需多次利用的对象

如果在代码中反复调用某些造成堆内存分配的函数但是其返回结果并没有使用,这就会造成不必要的内存垃圾,我们可以缓存这些变量来重复利用。

   例如下面的代码每次调用的时候就会造成堆内存分配,主要是每次都会分配一个新的数组。

对比下面的代码,只会生产一个数组用来缓存数据,实现反复利用而不需要造成更多的内存垃圾:

复用对象,而不是delete再new

例如MVC框架中的Mgr类,在切换角色时,我们依然可以复用原来的Mgr,只需要还原其中的数据到初始化状态即可。

       又比如表现层的弹道,陷阱,伤害数字等,可以做成对象池来反复使用,每次回收时还原其状态和数据即可。

字符串操作

在c#中,字符串是引用类型变量而不是值类型变量,即使看起来它是存储字符串的值的。这就意味着字符串会造成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。

  c#中的字符串是不可变更的,也就是说其内部的值在创建后是不可被变更的。每次在对字符串进行操作的时候(例如运用字符串的“加”操作),unity会新建一个字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会造成内存垃圾。

  我们可以采用以下的一些方法来最小化字符串的影响:

  1)减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。

  2)减少不必要的字符串操作,例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件。

  3)如果我们需要实时的创建字符串,我们可以采用StringBuilderClass来代替,StringBuilder专为不需要进行内存分配而设计,从而减少字符串产生的内存垃圾。

  4)移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会创建至少一个字符(空字符)的字符串。如果游戏中有大量的该函数的调用,这会造成内存垃圾的增加。

 

U3D的API的使用

在编程中,我们需要知道当我们调用不是我们自己编写的代码,无论是Unity自带的还是插件中的,我们都可能会产生内存垃圾。Unity的某些函数调用会产生内存垃圾,我们在使用的时候需要注意它的使用。

  这儿没有明确的列表指出哪些函数需要注意,每个函数在不同的情况下有不同的使用,所以最好仔细地分析游戏,定位内存垃圾的产生原因以及如何解决问题。有时候缓存是一种有效的办法,有时候尽量降低函数的调用频率是一种办法,有时候用其他函数来重构代码是一种办法。现在来分析unity中中常见的造成堆内存分配的函数调用。

在Unity中如果函数需要返回一个数组,则一个新的数组会被分配出来用作结果返回,这不容易被注意到,特别是如果该函数含有迭代器,下面的代码中对于每个迭代器都会产生一个新的数组:

 

 

对于这样的问题,我们可以缓存一个数组的引用,这样只需要分配一个数组就可以实现相同的功能,从而减少内存垃圾的产生:

 

 

此外另外的一个函数调用GameObject.name 或者GameObject.tag也会造成预想不到的堆内存分配,这两个函数都会将结果存为新的字符串返回,这就会造成不必要的内存垃圾,对结果进行缓存是一种有效的办法,但是在Unity中都对应的有相关的函数来替代。对于比较gameObject的tag,可以采用GameObject.CompareTag()来替代。

在下面的代码中,调用gameobject.tag就会产生内存垃圾:

 

采用GameObject.CompareTag()可以避免内存垃圾的产生:

 

 

不只是GameObject.CompareTag,unity中许多其他的函数也可以避免内存垃圾的生成。比如我们可以用Input.GetTouch()和Input.touchCount()来代替Input.touches,或者用Physics.SphereCastNonAlloc()来代替Physics.SphereCastAll()。

 

装箱和拆箱

    装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。比如String.Format()函数需要传入字符串和对象类型参数,如果传入字符串和int类型数据,就会触发装箱操作。如下面代码所示:

 

 

在Unity的装箱操作中,对于值类型会在堆内存上分配一个System.Object类型的引用来封装该值类型变量,其对应的缓存就会产生内存垃圾。装箱操作是非常普遍的一种产生内存垃圾的行为,即使代码中没有直接的对变量进行装箱操作,在插件或者其他的函数中也有可能会产生。最好的解决办法是尽可能的避免或者移除造成装箱操作的代码。

   

协程

调用StartCoroutine()会产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该限制该函数的调用。基于此,任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程。

yield在协程中不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如:

 

 

由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:

 

 

另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:

 

我们可以采用缓存来避免这样的内存垃圾产生:

 

最后说个野路子,见仁见智,欢迎讨论

    系统为什么会触发GC?说白了就是内存不够了,得向系统再要。但是在向系统要内存是有风险的,如果系统的内存不够了可能会直接杀进程(闪退)。所以在向系统要之前,需要自己检查一下是否自己的内存里面有垃圾?如果有,则清理掉,再看够不够。如果够了就不向系统要了。如果清理后发现还不够,才向系统要。

 

    U3D有个机制,就是向系统申请的堆内存,一旦申请下来了,是不会退还给系统了。用不用是我U3D的事情。因此我们可以在profiler的内存栏中看到used managed heap 和 unused managed heap,即已使用的托管堆内存和空闲的托管堆内存。

 

    那么我们可以,先通过测试测出项目的安全峰值堆内存大小。然后项目启动的时候,直接强行申请这个大小的堆内存,然后马上释放。等于这块内存就是我U3D进程的自留地了。那么在接下来的运行中,自己选择合适的时机主动GC(例如切场景时),则基本上可以避免系统触发的GC。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值