这几天看了一些文章和书,都讲到了Unity的GC,大致地整理了一下。

Unity的内存分配

Unity游戏运行时内存分为以下三个部分:

  1. Mono堆:C# 代码
  2. Native堆:资源,Unity引擎逻辑,第三方逻辑。
  3. 库代码:Unity库,第三方库。
    Unity实际上可以看作是一个使用C++开发的游戏引擎,它使用.net的脚本虚拟机。Unity从虚拟内存中给原生(C++)对象和虚拟机分配内存,同样,第三方插件的内存分配也都是在虚拟内存池中。

原生内存(Native Memory )是虚拟内存的一部分,它用来给所有需要的对象分配内存页面,包括Mono堆(Mono Heap)。

其中Unity的资源是通过Unity的C++层分配在Native堆内存中。
本文不讲述Native堆的GC优化,主要讲述Mono堆的GC优化。

Mono堆

C#代码在内存中分配可以分为堆栈内存托管堆内存堆栈内存主要用来存储较小的和短暂的数据片段,而托管堆内存主要用来存储较大的和存储时间较长的数据片段。
堆栈它的存储对象的特点以及运行方式类似于栈的数据结构,所以它内部存储的数据简洁,并以一种固定的方式进出,导致它的GC的速度非常快并且是由操作系统自动分配释放的。
托管堆相对于栈内存来说内部存储的方式更类似于链表,里面的数据则大小,存储时间,类型都不定,其中的内存分配和回收顺序并不可控,所以并不像栈内存的数据出入那样固定,托管堆就是要探究的Mono堆
托管堆中“托管”的本意是Mono可以自动地改变堆的大小来适应你所需要的内存,并且适时地调用GC操作来释放已经不需要的内存。

GC工作简介

Mono内存管理

在Mono堆的内存分配遇到的三种情况:

  1. 首先会先判断内存是否足够分配

    1. 情况1)是,则直接分配到对应的内存单元。
    2. 不是,Unity则会进行GC操作来回收内存,这里又会遇到两种情况。

      1. 情况2)回收来的内存足够存储,则分配到对应的内存单元。
      2. 情况3)回收后内存依然不足够内存的分配,则扩展内存大小,再分配对应的内存单元。

当然,也可以直接在代码中进行堆内存的GC操作。

Mono在GC中步骤主要分为4个:

  1. 停止所有需要mono内存分配的线程。
  2. 遍历所有已用内存,找到那些不再需要使用的内存,并进行标记。
  3. 释放被标记的内存到空闲内存。
  4. 重新开始被停止的线程。

UnityGC的垃圾回收器

UnityGC的垃圾回收器实际上与它的脚本后端GC相匹配,而Mono2.1和IL2CPPUnity的GC都是采用Boehm-Demers-Weiser(贝姆垃圾回收)的垃圾回收器,虽然之后mono版本更新,但是实际上只是C#编译器的升级,支持C#高版本语法,垃圾回收器仍然是贝姆垃圾回收。

GC操作带来的问题

主要是消耗性能。当玩家在进行到游戏操作的关键时刻,突然游戏进行了GC则会导致游戏卡顿,影响游戏体验。
其次还会带来堆内存碎片。当进行了多次的GC操作之后,由于堆内存分配的数据它的不固定性,有可能导致堆内存被分割成多个单元,导致虽然堆内存总体空间很大,但是其中的内存单元分布分散而且较小,当堆内存下一次要分配内存的时候,无法找到一个合适大小的存储单元,从而会触发GC操作以及扩展内存大小。
官网堆内存碎片举例图:
4.删除a块
5.添加d块
7.再添加a块
按照一般的逻辑应该只需要拓展d块的大小便可以足够内存的分配,如图6,但是这段并不是连续的内存块,所以最后只会到图7的情况,消耗了更多的内存空间。
堆内存碎片图示

堆内存碎片导致的两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加频繁地被触发。

减少GC操作带来的影响的方向

其实主要就是三个方向:

  1. 减少GC操作的次数,这样可以有效避免堆内存碎片的发生。
  2. 减少GC操作的时间,游戏进行时进行的GC操作需要尽可能不影响玩家游玩的体验。
  3. 可以在不重要的时间段进行GC操作(或者增长GC操作的时间来减免卡顿),比如场景的加载的时候调用Resources.UnloadUnusedAssets()(该方法中包含了GC.Collect(),小知识)或者勾选Project Settings->Player->Other Setting->Use incremental GC来从单帧到多帧来进行GC。

第三个方法比较直白没什么好讲的,而第一个方向和第二个方向的主要思路都是一样的,就是减少堆内存的内存分配
C#的数据类型可以分为值类型和引用类型,而引用类型则存储在堆内存中,那么也就可以得出一个主要的思路方向:

想要尽可能减少托管堆的GC操作对于游戏性能的影响,就是尽可能地减少引用类型在堆内存的分配。

详细一点就有很多了,比如:

  • 合理引用类型(这里需要了解一些string的一些东西)。
  • 使用Unity建议的函数(比如CompareTag,使用gameObject.tag就会生成垃圾)。
  • 减少装箱操作等等。
  • Struct的特殊情况,原本结构体是值类型,但是如果它的内部有一些引用类型的话,那么在GC中也会对整个结构进行排查,所以最好将这种结构体拆开,减少GC的时间。
  • 使用对象池
  • 等等

为什么使用对象池

缓存池大致逻辑

简单来说缓存池的逻辑就是当游戏需要创建某个Object时就会在缓存池中获取,如果存在该物体,则会将该物体SetActive(True),但如果没有才会进行Instantiate的操作,当游戏不需要该Object时则会将该物体SetActive(False)隐藏起来,也不会进行Destroy。

了解Instantiate和Destroy

Instantiate函数是先寻找内存中是否有相应的资源,如果有则引用,如果没有则分配内存空间来存放这些资源,但不仅仅如此还会有new的操作,为了方便Mono运行,new操作中还会有一些额外信息需要托管堆为其分配空间,比如类型对象指针和同步索引块。
Destroy函数只是删除引用,而内部调用的mesh,tex等资源都还在内存(在Native堆中),而被删除的引用会到Unity的垃圾回收机制中等待被回收。
可以做一个实验,首先在场景中放一个预制体,运行Unity,通过Instantiate创建相同的预制体,记录时间t1,将它删除后,再创建该多个预制体,记录时间t2,t3..tn,比较两者,可以很明显地发现t2与t3..tn相接近,而t2..tn小于t1,说明场景中已有的预制体资源是无法被Unity运行后创建预制体调用,在运行后创建的预制体它相应的资源都会保存在内存中,之后在创建时间会减少。
Debug时间

为了减少堆内存的GC操作,所以要尽可能减少Destroy的操作,来减少堆内存垃圾和内存消耗,防止内存泄漏。

了解SetActive

当游戏物体从False状态到True只会执行OnEnable,而这个特性很适合粒子系统,因为粒子系统中Play()是非常消耗性能的,如果只是在需要粒子特效的时候创建出来,那么这里Play()的开销都会吃下,但如果通过缓存池的话就可以在创建出来的时候就进行Play()操作,之后每次需要该粒子系统时,只需要通过SetActive(true)就可以避免这种消耗。在SetActive(false)之后,物体的渲染等组件都会禁用,平时虽然会有一定的消耗,并不会很多,但是要注意SetActive()函数的反复多次调用也会消耗很大的性能,因为它会遍历自身所有继承MonoBehaviour脚本然后调用OnEnable和OnDisable,还是有一定的开销,如果经常使用可以通过其他方式来进行显示和隐藏。

采用对象池的对象特点

  1. 会在场景中大量地使用(如果仅有几个,对象池采用并没有什么意义)。
  2. 对象有一定、短暂的生命周期(说明对象会在短时间内删除,过多的删除会产生过多的GC垃圾)。
  3. 对象会频繁地创建(采用对象池可以节省new()的时间)。
  4. 对象需要引用的资源有一定的大小(采用对象池可以更加节省引用资源的时间)。

对象池的优点

对象池相当于通过增加平常的内存消耗(即Object隐藏消耗的性能)来减少Instantiate消耗的时间和Destroy产生的GC。
游戏中的大忌就是突然卡顿(当游戏到关键时刻来个卡顿,会造成极差的游戏体验),对象池可以将平常的内存利用起来,来避免关键时刻的卡顿。

参考