介绍
当我们游戏运行时候,使用的是内存去存储数据。当数据不再需要了,存储数据的内存就会被释放以便重用。垃圾(Garbage)是存储不再使用的数据的内存的术语。垃圾回收(Garbage collection)是使内存可再次使用的进程的名字。
Unity使用垃圾回收来管理内存。如果垃圾回收频繁发生或者垃圾回收要做很多工作,我们的游戏性能就会变差。垃圾回收是造成性能问题的常见原因。
本文中,我们将学到垃圾回收是如何工作的和怎样高效使用内存来最小化垃圾回收在游戏中的影响。
用垃圾回收诊断问题
垃圾回收造成的性能问题可以表现为帧率低、卡顿甚至卡死。然而,其他问题也能引起类似的症状。如果我们游戏有类似的问题,首先我们应该使用Unity的Profiler工具来确定这个问题是否是垃圾回收引起的。
学习如何使用Profiler工具分析性能问题,请看这篇文章。
Unity内存管理简介
为了理解垃圾回收是如何发生的,我们必须明白Unity的内存工作原理。首先,我们必须明白引擎代码和我们自己编写的代码的内存管理是不同的。
核心引擎代码运行的时候的内存管理方式叫做手动内存管理(manual memory management)。这意味着核心引擎代码必须明确地说明如何使用内存。自动内管管理不会使用垃圾回收,因此本文不做进一步说明。
我们代码运行时候的内存管理方式叫做自动内存管理(automatic memory management)。这意味着我们的代码不需要明确地告诉Unity如何管理内存。Unity已经为我们考虑好了。
在基本层面上讲,Unity自动内存管理像这样:
- Unity可以访问两个内存池:栈(stack)和堆(heap)(also known as the managed heap)。栈用于短期存储的小数据,堆用于较长时间存储的较大的数据块。
- 当创建变量的时候,Unity会从栈或堆中请求一个内存块。
- 只要变量值作用域内(in scope(仍可被代码访问) ),分配给它的内存仍然会保留。我们说的内存被分配(allocated)了。我们可以这样描述,保存在栈内存的叫做栈对象(object on the stack),保存在堆内存的叫做堆对象(object on the heap)。
- 当变量不在作用域范围内了,这块内存就不再需要了,可以被它来的那个内存池回收。当内存被回收到池,我们说内存被重新分配(deallocated)了。只要变量的引用超出了作用域,栈内存就会被重新分配,而堆的内存不同,这种情况下内存不会重新分配即使变量的引用超出了作用域。
- 垃圾回收器标识和回收没有使用的堆内存。垃圾回收器定期清理堆内存。
现在我们了解了事件的流程,让我们详细了解栈和堆分配和释放内存的不同之处。
栈分配和释放内存的时候会发生什么?
栈分配和释放内存是非常快速和简单的。这是因为栈只用于存储短时间的小数据。分配和释放内存总是可以预测顺序和大小。
栈工作类似栈数据类型:这种情况下的内存更像一个简单的集合,元素按严格的顺序添加和移除。这种简单性和严格性使它如此快速:当变量存储在栈时,它的内存只从栈顶分配。当一个栈变量离开作用域,存储变量的内存会立即被栈重用。
堆分配内存会发生什么?
堆分配内存比栈分配内存要复杂得多。这是因为,堆可以用来存储长期数据和短期数据和不同类型和大小的数据。分配和释放不总是按可预见的顺序,并且
可能需要非常不同大小的内存块。
当创建一个堆变量的时候,会按以下的步骤:
- 首先,Unity会检查堆是否有足够的剩余内存。如果堆有足够的剩余内存,就会为变量分配内存。
- 如果堆内有足够的剩余内存,Unity触发垃圾回收尝试释放不使用的堆内存。这可能是一个很慢的操作。如果这时堆有足够的剩余内存,则会为变量分配内存。
- 如果垃圾回收后,堆剩余内存仍然不够,Unity会在堆中增加内存。这可能是一个很慢的操作。然后为变量分配内存。
堆分配可能会很缓慢,尤其是需要运行垃圾回收器或扩展堆内存的时候。
垃圾回收的时候会发生什么?
当一个堆变量离开作用域,存储该变量的内存不会被立即释放。不使用的堆内存只有在垃圾回收器运行时候才会释放。
每次垃圾回收器运行,会发生以下的事情:
- 垃圾回收器检查堆中每个对象
- 垃圾回收器搜索所有当前对象的引用以确定堆对象是否在作用域。
- 任何不在作用域的对象都打一个删除的标记。
- 分配给打了删除标记的对象的内存会被堆回收。
垃圾回收什么时候发生?
三件事可以造成垃圾回收的发生:
- 请求堆内存分配的时候,剩余内存不能满足时,垃圾回收器会运行
- 垃圾回收器不时的自动运行(不同的平台频率不同)
- 垃圾回收可以手动运行
垃圾回收可能是一个频繁的操作。请求对内存分配,内存不足时候会触发垃圾回收器,这意味频繁的堆内存申请和释放可能导致频繁的垃圾回收。
垃圾回收问题
现在我们知道了垃圾回收在Unity内存管理中的作用,我们可以考虑可能出现的问题类型。
最显而易见的问题是,垃圾回收器会花费很长的时间来运行。如果垃圾回收有大量的堆对象或有大量的引用要检查,检查所有对象的进度可能会很慢。这可能会造成我们游戏卡顿或运行缓慢。
另一个问题是,垃圾回收器可能在一个不恰当的时间运行。如果CPU在努力运行我们游戏的性能关键部分,即使垃圾回收会增加很小的消耗,也可能导致我们游戏掉帧和性能的显著变化。
另一个没有那么明显的问题是堆碎片(heap fragmentation)。从堆的空闲内存块申请的内存的大小依赖必须存储的数据的大小。当这些内存块被堆回收时,堆可能是一个被申请的内存块分割成的许多很小的空闲内存块。这意味着,尽管空闲内存总量很高,但如果我们没有使用垃圾回收器或扩展堆内存的话,我们是不能申请大内存块的,因为现存的内存块中没有那么大的内存块。
堆碎片会造成两个后果。第一个是我们游戏使用的内存会高于它所需要的内存。第二是垃圾回收器会频繁使用。关于堆碎片的详情和讨论,请看这篇文章。
查找堆分配
如果我们知道我们的游戏有垃圾回收问题,我们需要知道是我们代码的哪个部分产生了垃圾。当堆变量离开作用域的时候就会产生垃圾,所以我们首先要知道在堆上分配变量的原因。
栈和堆分配的是什么?
在Unity中,值类型的本地变量是在栈上分配的,除此之外是在堆上分配的。如果你不确定是哪种数据类型,请看这篇教程
下面是栈分配内存的示例代码,localInt即是局部变量又是值类型。这个变量申请的内存会在函数执行之后被栈立即回收。
void ExampleFunction()
{
int localInt = 5;
}
下面是堆分配内存的示例代码,localList变量是局部变量又是引用类型。这个变量申请的内存会在垃圾回收器运行的时候回收。
void ExampleFunction()
{
List localList = new List();
}
使用分析器查找堆分配
我们可以使用分析器查看我们代码哪里产生了对分配。
选中 CPU分析器,我们可以选择任何一帧查看 CPU该帧的数据。其中一列数据叫 GC alloc。这列数据显示该帧堆分配信息。如果沃恩选择列头,我们可以统计的数据进行排序,使得更容易看出我们游戏我们游戏哪个函数造成的堆分配最多。一旦我们知道了哪个函数造成对分配,我们就可以检测该函数。
一旦我们知道了函数中产生垃圾的代码,我们就可以决定如何去解决这个问题和最小化垃圾的产生。
减少垃圾回收的影响
通常来说,我们可以通过下面三个方法来减小垃圾回收的影响:
- 我们可以减少垃圾回收器运行的时间。
- 我们可以谁极爱你少垃圾回收器运行的频率。
- 我们可以故意触发垃圾回收器,使它在非性能关键时刻运行,比如加载场景的时候。
考虑到这一点,这里有三种策略可以帮助我们:
- 我们可以重构我们的游戏,使我们游戏更少的堆分配和更少的对象引用。更少的堆对象和更少的引用检测意味着当垃圾回收触发的时候会花费更少的时间运行。
- 我们可以减少堆内存分配和释放的频率,尤其是在性能关键的时候。更少的内存申请和释放意味着更少的机会触发垃圾回收。这同样会减少堆碎片的风险。
- 我们可以尝试定时垃圾回收和堆扩展以确保它们可以预测和在适当的时候运行。这是一个比较难比较不可靠的方法,但作为一个综合的内存管理策略,这可以减少垃圾回收的影响。
减少垃圾产生的数量
让我们通过代码测试几个能帮助我们减少垃圾产生的方法。
缓存(Caching)
如果我们频繁调用会申请堆内存,并且用完之后丢弃的函数,这会造成不必要的垃圾。我们应该保存这些对象的引用来重用它们。这个方法叫做缓存。
下面的示例,代码每次调用会造成堆内存分配。这是因为产生了新的数组。
void OntriggerEnter(Collider other)
{
Renderer[] allRenderer = FindObjectsOfType();
ExampleFunction(allRenderer);
}
下面的代码中只有一次堆内存申请,因为数组创建后缓存起来了。这个缓存的数组可以被重用,不会再产生更多的垃圾。
priavte Renderer[] allRenderer;
void Start()
{
allRenderer = FindObjectsOfType();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
不要在频繁调用的函数中申请内存
如果我们必须在MonoBehaviour中申请堆内存,最坏的情况是在频繁的函数中调用。比如Update()和LateUpdate(),这些函数每帧都会调用。如果我们产生垃圾的代码在这些地方调用,那么垃圾很快就会累加上来。我们应该考虑尽可能在Start()或Awake()缓存对象引用,或确保当需要的时候只申请一次内存。