浅谈Unity的GC
这几天看了一些文章和书,都讲到了Unity的GC,大致地整理了一下。
Unity的内存分配
Unity游戏运行时内存分为以下三个部分:
- Mono堆:C# 代码
- Native堆:资源,Unity引擎逻辑,第三方逻辑。
- 库代码: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)是,则直接分配到对应的内存单元。
不是,Unity则会进行GC操作来回收内存,这里又会遇到两种情况。
- (情况2)回收来的内存足够存储,则分配到对应的内存单元。
- (情况3)回收后内存依然不足够内存的分配,则扩展内存大小,再分配对应的内存单元。
当然,也可以直接在代码中进行堆内存的GC操作。
Mono在GC中步骤主要分为4个:
- 停止所有需要mono内存分配的线程。
- 遍历所有已用内存,找到那些不再需要使用的内存,并进行标记。
- 释放被标记的内存到空闲内存。
- 重新开始被停止的线程。
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操作带来的影响的方向
其实主要就是三个方向:
- 减少GC操作的次数,这样可以有效避免堆内存碎片的发生。
- 减少GC操作的时间,游戏进行时进行的GC操作需要尽可能不影响玩家游玩的体验。
- 可以在不重要的时间段进行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运行后创建预制体调用,在运行后创建的预制体它相应的资源都会保存在内存中,之后在创建时间会减少。
为了减少堆内存的GC操作,所以要尽可能减少Destroy的操作,来减少堆内存垃圾和内存消耗,防止内存泄漏。
了解SetActive
当游戏物体从False状态到True只会执行OnEnable,而这个特性很适合粒子系统,因为粒子系统中Play()是非常消耗性能的,如果只是在需要粒子特效的时候创建出来,那么这里Play()的开销都会吃下,但如果通过缓存池的话就可以在创建出来的时候就进行Play()操作,之后每次需要该粒子系统时,只需要通过SetActive(true)就可以避免这种消耗。在SetActive(false)之后,物体的渲染等组件都会禁用,平时虽然会有一定的消耗,并不会很多,但是要注意SetActive()函数的反复多次调用也会消耗很大的性能,因为它会遍历自身所有继承MonoBehaviour脚本然后调用OnEnable和OnDisable,还是有一定的开销,如果经常使用可以通过其他方式来进行显示和隐藏。
采用对象池的对象特点
- 会在场景中大量地使用(如果仅有几个,对象池采用并没有什么意义)。
- 对象有一定、短暂的生命周期(说明对象会在短时间内删除,过多的删除会产生过多的GC垃圾)。
- 对象会频繁地创建(采用对象池可以节省new()的时间)。
- 对象需要引用的资源有一定的大小(采用对象池可以更加节省引用资源的时间)。
对象池的优点
对象池相当于通过增加平常的内存消耗(即Object隐藏消耗的性能)来减少Instantiate消耗的时间和Destroy产生的GC。
游戏中的大忌就是突然卡顿(当游戏到关键时刻来个卡顿,会造成极差的游戏体验),对象池可以将平常的内存利用起来,来避免关键时刻的卡顿。
参考:
- 《CLR via C#》
- 《Unity3D脚本编程》 PS:书本出于2016,有些信息有点老了
- Unity Mono堆内存管理和GC
- Mono中的BOEHM GC 原理学习(1)
- Unity游戏内存分布概览
- 【Unity游戏开发】GC垃圾回收器学习
- 【Unity3d游戏开发】浅谈Unity中的GC以及优化
- Unity将来时:IL2CPP是什么?