GC(Garbage Collection,垃圾回收)作为Unity内存管理的一部分,是游戏性能上非常重要的一环。游戏运行时使用内存来存储数据,当这些数据不需要再被使用到时,我们需要释放这部分内存来实现内存的复用,这便是GC的意义所在。
目前来讲我自己平时的业余开发还没有很涉及到严峻的性能问题,所以GC说实话平时用的不多。所以这里简单地学一下GC的一些概念及常见的减少GC开销的方法,后续如果游戏涉及到严峻的性能开销问题,再回过头来好好学一下。
为什么需要GC
我们知道,Unity中值类型的局部变量会分配在栈上,除此之外都分配在堆上。栈变量超出作用域后,分配给该变量的内存会在函数调用结束后被立刻回收。而当堆变量超出作用域后,这部分内存并不会被立刻回收,从而产生垃圾,而这些垃圾会在下次GC时被回收。
GC干了什么
每次执行GC时,将执行一下步骤:
- 垃圾收集器检索堆上的每个对象。
- 垃圾收集器搜索所有当前对象引用以确定堆上的对象是否仍在作用域内。
- 不在作用域内的对象被标记为删除。
- 删除被标记的对象并将内存返回给堆。
GC是个费时的操作,堆上的对象越多,代码中的引用数越多,GC就越费时。
何时会触发GC
- 堆内存不足时。
- GC会不时地自动运行(频率因平台而异)。
- 主动调用GC时。
GC所带来的开销问题
- 如果有很多的堆对象及大量的堆对象引用,GC会花费很长的时间。
- 在不合时宜的时间(如游戏的性能关键部分)GC被触发,也可能导致帧速率下降和性能问题。
- 堆碎片也很导致GC被更频繁地触发,同时游戏消耗的内存会高于实际所需要的内存大小。
减小GC的影响
- 减少GC的时间,即更少的堆内存分配和更少的堆对象引用。
- 减少GC的频率,即减少堆内存分配和释放的频率。
- 主动调用GC,即尝试主动触发GC及堆内存扩展,避开性能关键点。
具体减少GC的方式
这里我们简单说几种新手GCer比较常见的方式,具体可以参考文末的参考链接。
缓存
如果我们的代码重复调用产生堆分配的函数,然后丢弃结果,这将产生不必要的垃圾。 对此,我们应该存储对这些对象的引用并复用它们。
缓存前:1
2
3
4
5void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType<Renderer>();
ExampleFunction(allRenderers);
}
缓存后:1
2
3
4
5
6
7
8
9
10
11private Renderer[] allRenderers;
void Start()
{
allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
不要在频繁调用的函数中分配
如果我们需要在MonoBehaviour中分配堆内存,在频繁调用的函数里分配是最糟糕的。比如每帧调用的函数Update()和LateUpdate(),在这些地方分配,垃圾将非常快的累积。我们应该尽可能在Start()或Awake()里缓存这些对象的引用,或者确保分配内存的代码只在需要的时候被运行。
对象池
即使减少了脚本中的堆分配,在运行时大量对象的创建和销毁依然会引起GC问题。对象池是一种通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术。对象池在游戏中广泛使用,最适合于频繁产生和销毁类似对象的情况。例如,当枪射击子弹时。
手动强制GC
最后,我们可能希望自己触发GC。 如果我们知道堆内存已被分配但不再使用(例如,如果我们的代码在加载资源时生成垃圾),并且我们知道垃圾收集冻结不会影响播放器(例如,当加载界面还显示时),我们可以使用以下代码请求GC:
1 | System.GC.Collect(); |
这将强制运行GC,在我们方便的时候释放未使用的内存。
使用Profiler工具来查找堆分配
我们可以使用Profiler工具来查看哪部分代码产生了堆分配。
选中CPU Usage,然后选中任意帧就可以在Profiler窗口的下部查看到该帧的CPU使用数据。其中一列叫GC alloc,这一列显示了这帧中的堆分配信息。点击列头对该列进行排序,这样可以更直观的看出当前帧哪些函数产生了最多的堆分配。这样就可以检查这些产生堆分配的函数。
一旦我们知道函数内的什么代码导致生成垃圾,我们可以决定如何解决这个问题,并最大限度地减少垃圾的生成量。
参考
Unity游戏的GC(garbage collection)优化
官方文档:Understanding the managed heap