本文是Unity官方教程,性能优化系列的第三篇《Optimizing garbage collection in Unity games》的翻译。
相关文章:
Unity性能优化(1)-官方教程The Profiler window翻译
Unity性能优化(2)-官方教程Diagnosing performance problems using the Profiler window翻译
Unity性能优化(3)-官方教程Optimizing garbage collection in Unity games翻译
Unity性能优化(4)-官方教程Optimizing graphics rendering in Unity games翻译
简介
当游戏运行时,使用内存存储数据。当数据不再需要时,存储这些数据的内存被释放,以便重新使用。我们把已经存储了数据,但是已经不再使用这些数据的内存叫做垃圾。我们把重新使得这些存储垃圾的内存变的可用的过程叫做垃圾回收。
Unity使用垃圾回收作为内存管理的一部分。如果垃圾回收执行的太频繁或者垃圾太多,那么我们的游戏可能会性能较差。这意味着垃圾回收是很常见的引起性能问题的原因。
在这篇文章中,我们将学习垃圾回收怎样工作,在什么时间发生,以及怎样有效率的使用内存以便把垃圾回收对我们游戏的影响降到最低。
Unity内存管理概述
为了理解垃圾回收怎么样工作以及什么时候发生,我们必须首先理解内存使用在Unity中是怎么工作的。Unity管理内存的方法叫做自动内存管理。这意味着我们的代码不需要明确的告诉Unity怎么样在细节上去管理内存。Unity替我们做了这些。
在本质上,Unity中的自动内存管理是这样工作的:
-Unity在两种内存池中存取:栈内存和堆内存。栈用来存储短期的和小块的数据,堆用来存储长期的和大块的数据。
-当一个变量创建时,Unity在栈或堆上申请一块内存。
-只要变量在作用域内(仍然能够被我们的代码访问),分配给它的内存表示在使用中。我们称这块内存已经被分配。我们描述一个变量被分配到栈内存为栈上对象,一个变量被分配到堆内存为堆上对象。
-当变量不在作用域了,内存不再被需要了,就可以被返回到它被申请的内存池。当内存被返回到内存池时,我们称之为内存释放。当变量不在作用域内时,栈上的内存会立刻被释放。而当堆上的变量不在作用域时,在堆上的内存并不会在这一刻马上被释放,并且此时内存状态仍然是已被分配状态。
-垃圾回收器识别并且释放无用的堆内存。垃圾回收器周期性的清理堆。
现在我们清楚了流程,让我们更近一步,看看在栈上分配释放内存和在堆上分配释放内存的区别。
栈上分配和释放内存时发生了什么
栈上分配和释放内存很快并且很简单。这是因为栈上只是用来存储小数据且很短的时间。分配和释放内存总是按照预期的顺序和预期的大小。
栈工作像栈数据类型一样:它是一个一些元素的简单集合,在这里是一些内存块,元素智能按照严格的顺序添加或者移除。因为这种简洁和严格,所以很快:当一个变量存储在栈上时,内存简单的在栈的“末尾”被分配,当栈上的变量不在作用域时,存储它的内存马上被返还回栈以便重用。
当堆上内存分配时发生了什么
堆上分配内存比栈上要复杂很多。因为堆上会存储长期和短期的数据,并且数据有很多不同的类型和大小。堆上内存的分配和释放并不总是有预期的顺序,并且可能需要不同大小的内存块。
当一个堆变量被创建,会发生以下步骤:
-首先Unity必须先检测堆上是否有足够的空闲内存。如果堆上空闲内存足够,那么为变量分配内存。
-如果堆上内存不足,Unity触发垃圾回收器尝试释放堆上无用的内存。这个操作可能会比较慢。如果现在空闲内存足够了,那么为变量分配内存。
-如果执行垃圾回收后,堆上空闲内存仍然不足,Unity会增加堆内存容量。这操作可能会比较慢。这样就可以为变量分配内存了。
堆上分配内存可能会很慢,尤其是需要执行垃圾回收和扩展堆内存时。
垃圾回收时发生了什么
当堆变量不在作用域后,存储它的内存不会马上被释放。只有执行垃圾回收时,堆上的无用内存才会被释放。
每次垃圾回收执行时,会发生如下步骤:
-垃圾回收器检查堆上的每个对象。
-垃圾回收器查找所有当前对象的引用,确认堆上对象是否还在作用域。
-任何不在作用域的对象被标记为待删除。
-被标记的对象被删除掉,且把为他们分配的内存返还到堆中。
垃圾回收可能是昂贵的操作。堆上的对象越多,它需要做的工作就越多;我们代码里对象的引用越多,它需要做的工作就越多。
垃圾回收什么时候发生
三件事情可能会触发垃圾回收:
-当堆上分配内存时,且空闲内存不足,会触发垃圾回收。
-垃圾回收随着时间自动触发(虽然频率由平台决定)。
-我们可以手动强制执行垃圾回收。
垃圾回收可能会很频繁。因为当堆上分配内存时,且空闲堆内存不足,会触发垃圾回收,这意味着频繁的分配释放堆内存可能会导致频繁的垃圾回收。
垃圾回收带来的问题
现在我们理解了垃圾回收在Unity内存管理中扮演的角色,我们可以考虑可能会遇到哪些类型的问题了。
最明显的问题是垃圾回收会花费一部分时间去执行。如果垃圾回收器有很多堆上的对象需要检查,并且很多对象的引用需要检查,检查所有这些对象可能会比较慢。这可能会引起游戏卡顿或运行慢。
另一个问题是垃圾回收可能在不恰当的时机执行。如果cpu已经努力工作在我们游戏的性能临界的部分了,这时甚至垃圾回收引起的一点额外消耗可能会引起帧率下降和性能的明显改变。
另一个不太容易被注意到的问题是堆碎片化。当内存在堆上被分配时,是在空闲空间中根据要被存储的数据大小分块的。当这些内存块被返还回堆内存时,堆内存中的空闲空间被分成了一些碎块。这意味着,也许堆上总的空闲很大,但是我们不能直接分配大块内存给需要的变量,因为没有足够大的内存块了,除非执行垃圾回收或者扩展堆内存。
碎片化的堆内存会带来两个影响。首先,我们游戏使用的内存占用比实际需要的高,其次,垃圾回收会更频繁的执行。
诊断垃圾回收问题
垃圾回收引起的性能问题,可能表现出来为低帧率,性能不稳定或者是间歇性的卡死。如果你的游戏性能问题表现如此,那么首先你应该使用Unity Profiler去确认问题是否真的由垃圾回收造成。
学习怎么使用Profiler诊断性能问题,请阅读Unity性能优化(2)-官方文档简译。
如果使用Profiler确认问题是垃圾回收引起的,那么请继续阅读。
查找堆内存分配
如果我们知道了是垃圾回收引起的性能问题,那么我们需要知道是我们代码的哪部分生成了垃圾。当堆变量不在作用域了,会产生垃圾,我们要先知道是什么引起了变量分配在堆上。
什么分配在堆上和栈上?
在Unity中,值类型的变量分配在栈上,除此外所有变量都分配在堆上。如果你不确认Unity中值类型和引用类型的区别,请看这个教程this tutorial.
下面代码是栈上分配的例子,变量localInt是局部变量和值类型的。为这个变量分配的内存,会在这个函数执行完毕后立刻释放。
void ExampleFunction() { int localInt = 5; }
下面代码是堆内存分配的例子,变量localList是局部的引用类型。为它分配的内存会在垃圾回收执行时才被释放。
void ExampleFunction() { List localList = new List(); }
使用Profiler查找堆分配
我们能够在Profiler窗口中看到我们的代码在哪创建了堆分配。
选中CPU usage profiler,我们可以选择任意帧,在下部窗口查看cpu使用数据。其中一列数据叫做GC alloc。这一列显示了这帧中执行的堆分配。如果我们点击这列的标题,可以把数据按照GC alloc排序,使我们可以很轻松的看到游戏中哪些函数引起了最多的堆内存分配。一旦我们知道了引起问题的函数,我们就可以仔细的检查它。
一旦我们知道了这个函数中的哪些代码引起了垃圾生成,我们可以决定怎么去解决这个问题,并最小化垃圾的生成。
降低垃圾回收的影响
泛泛地说,我们有三种方法降低垃圾回收对我们游戏的影响:
-我们可以降低垃圾回收执行的时间。
-我们可以降低垃圾回收执行的频率。
-我们可以在合适的时机手工触发垃圾回收,防止它在性能临机时发生,如在加载界面执行垃圾回收。
有三种策略可以帮助我们:
-我们可以组织我们的代码,较少的分配堆内存,且较少对象引用。这意味着当垃圾回收触发时,执行的比较快。
-我们可以降低堆内存的分配和释放,尤其是在性能临界时。这意味将较少的触发垃圾回收,也能降低堆内存碎片化的风险。
-我们可以尝试手工触发垃圾回收和堆内存扩展以便他们在可预期的和方便的时间执行,这是比较困难且不太可靠的方法,但是作为整体内存管理策略的一部分也是能降低垃圾回收的影响的。
减少垃圾生成
让我们看看几项可以帮助我们代码中降低垃圾生成的技术。
缓存
如果代码重复的调用造成堆内存分配的方法,然后丢弃结果,将造成不必要的垃圾。作为代替,我们应该应该保存结果的引用并复用他们。这项技术成为缓存。
下面例子中,函数每次被调用时都会造成堆内存分配,因为有新的数组创建。
void OnTriggerEnter(Collider other) { Renderer[] allRenderers = FindObjectsOfType<Renderer>(); ExampleFunction(allRenderers); }
下面代码只有一次堆内存分配,因为数组创建和填充一次,然后被缓存了。缓存数组可以复用而不用生成更多垃圾。
private Renderer[] allRenderers; void Start() { allRenderers = FindObjectsOfType<Renderer>(); } void OnTriggerEnter(Collider other) { ExampleFunction(allRenderers); }
不要在频繁调用的函数中分配堆内存
如果我们必须在MonoBehaviour中分配堆内存,那么最坏的地方是在哪些频繁调用的函数中。例如Update() 和 LateUpdate(),每帧调用一次,所以如果在这里生成垃圾,垃圾将会增加的很快。我们应该考虑在Start() 或 Awake()方法中缓存引用。或者确保引起的堆分配的代码只有在需要的时候才运行。
让我们看一个简单的例子,调整代码,使得只有数据改变时才执行。下面的代码中,每次调用Update()方法都会分配堆内存,频繁的生成垃圾。
void Update() { ExampleGarbageGeneratingFunction(transform.position.x); }
通过简单的修改,现在我们确保只有transform.position.x改变时,才调用生成垃圾的代码。现在我们只有在必要的时候才会进行堆内存分配,比原来每帧都进行要好很多。
private float previousTransformPositionX; void Update() { float transformPositionX = transform.position.x; if (transformPositionX != previousTransformPositionX) { ExampleGarbageGeneratingFunction(transformPositionX); previousTransformPositionX = transformPositionX; } }
另一个在Update()中降低垃圾生成的技术是使用计时器。这种情况适用于造成堆内存分配的代码必须定期运行,但是不需要每帧都运行时。
下面的例子代码中,每帧生成垃圾:
void Update() { ExampleGarbageGeneratingFunction(); }
下面的例子中,我们使用计时器,每秒生成垃圾:
private float timeSinceLastCalled; private float delay = 1f; void Update() { timeSinceLastCalled += Time.deltaTime; if (timeSinceLastCalled > delay) { ExampleGarbageGeneratingFunction(); timeSinceLastCalled = 0f; } }
像这些小小的改变,很好的降低了垃圾生成的数量。
清除集合
创建新集合会在堆上分配内存。如果我们发现代码中不止一次的创建新集合,那么我们应该缓存集合的引用,并使用Clear()方法清空内容来替代重复的创建新集合。
下面例子中,每次使用New方法,都会在堆上分配内存。
void Update() { List myList = new List(); PopulateList(myList); }
在下面的例子中,只有在集合创建或者在底层集合必须调整大小的时候才会分配堆内存,极大的降低了垃圾的生成数量。
private List myList = new List(); void Update() { myList.Clear(); PopulateList(myList); }
对象池
即使我们在脚本中降低了堆内存分配,我们可能还是有垃圾回收问题,如果我们在运行时创建和摧毁很多对象。对象池技术可以降低堆内存的分配和释放,通过复用对象来替代重复的创建和摧毁对象。对象池技术在游戏中应用广泛,特别适合当我们需要频繁的生成和摧毁相似的对象时。例如射击的子弹。
对象池的全面指导超出了本文范围,但是他是一项值得学习的很有用处的技术。This tutorial on object pooling on the Unity Learn site是在Unity中实现对象池系统的很好的指导。
引起不必要的堆内存分配的通常原因
我们理解局部的,值类型的变量分配在栈上,其余类型的变量都分配在堆上,尽管如此,有许多在堆上分配内存的行为可能会出乎我们的意料。让我们看看一些引起不必要的堆内存分配的通常原因并且考虑怎么做去减少这些问题。
字符串
在C#中,字符串是引用类型的,但是使用时候他们看起来像值类型那样用。这意味着,创建和丢弃字符串会产生垃圾。由于字符串在我们代码中很常用,这些垃圾可能会积少成多。
C#中的字符串是不可变的,这意味着她们的值在他们创建后不能被改变。我们每次操作字符串(例如,使用+去连接两个字符串),Unity都创建一个新的字符串并保存结果值,然后丢弃旧的字符串,这会产生垃圾。
我们可以遵循下面一些简单的规则使得从字符串生成的垃圾最小化。让我们考虑这些规则,并看看应用例子。
-我们应该减少没必要的字符串的创建。如果我们使用一样的字符串多过一次,我们应该只创建一次字符串然后缓存它。
-我们应该减少没必要的字符串操作。例如,如果我们需要频繁的更新一个文本组件的值,并且其中包含一个连接字符串操作,我们应该考虑把它分成两个独立的文本组件。
-如果我们必须在运行时组建字符串,我们应该使用StringBuilder类。这个类是设计来做字符串组建的,并且不产生堆内存分配,在我们连接复杂字符串时,这将减少很多垃圾的产生。
-我们应该移除Debug.Log(),当不需要进行调试后。在我们游戏的正式版本中他虽然没有输出任何东西,但是仍然会被执行。调用一次Debug.Log()至少创建和释放一次字符串,所以如果我们的游戏包含了很多调用,垃圾会积少成多。
让我们看看下面的例子,它低效的使用字符串,产生了没必要的垃圾。我们创建了字符串,并且在Update()方法中合并字符串,产生了很多没必要的垃圾。
public Text timerText; private float timer; void Update() { timer += Time.deltaTime; timerText.text = "TIME:" + timer.ToString(); }
在下面的例子中,我们把字符串拆分成独立的两部分,在Update()中不再需要合并字符串了,减少了垃圾的产生。
public Text timerHeaderText; public Text timerValueText; private float timer; void Start() { timerHeaderText.text = "TIME:"; } void Update() { timerValueText.text = timer.toString(); }
Unity函数调用
意识到我们调用任何不是我们自己写的代码,无论是Unity自身还是插件,都会产生垃圾是十分重要的。一些Unity函数会造成堆内存分配,所以我们应该小心的使用,避免产生没必要的垃圾。
这里并没有我们应该避免调用的清单。每个函数都是在一些情况下有用,在另外一些情况下没什么作用。一如既往,我们最好使用Profiler仔细的分析我们的游戏,确认垃圾在哪产生的,并且仔细思考如何处理他。有些情况下,可能缓存函数调用的结果是很明智的。另外一些情况下,也许不要调用函数那么频繁。有时可能最好去重构我们的代码,并且使用不同的函数。说了这么多,让我们看看几个例子,一些常用的Unity函数在堆上分配内存,我们如何去处理好他们。
每次我们调用一个返回数组的Unity函数,一个新的数组被创建,并且作为返回值返回给我们。这个行为并不总是很明显或者像预期一样。特别是当函数是存取器时(例如Mesh.normals)。
下面代码,每次循环都会创建新的数组。
void ExampleFunction() { for (int i = 0; i < myMesh.normals.Length; i++) { Vector3 normal = myMesh.normals[i]; } }
我们可以很简单的解决问题,我们可以缓存返回数组的引用,这样做后,我们只创建一次数组,产生的垃圾也因此降低了。
下面代码演示了这些。
void ExampleFunction() { Vector3[] meshNormals = myMesh.normals; for (int i = 0; i < meshNormals.Length; i++) { Vector3 normal = meshNormals[i]; } }
另外一个出乎人们预料的引起堆内存分配的函数是GameObject.name 或 GameObject.tag。他们都是返回新字符串的存取器。这意味着调用他们会产生垃圾。缓存可能会有效果,在这个例子中,我们可以用相关的Unity方法替代他们。当检查游戏对象的Tag是否相等时,使用GameObject.CompareTag()不会产生垃圾。
下面代码中,GameObject.tag产生了垃圾:
private string playerTag = "Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.tag == playerTag; } 我们修改后,不会产生任何垃圾:
private string playerTag = "Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.CompareTag(playerTag); }
Unity还有很多的函数,有类似的不会引起堆内存分配的版本,例如我们可以使用Input.GetTouch() 和 Input.touchCount 代替Input.touches ,或者使用Physics.SphereCastNonAlloc() 代替Physics.SphereCastAll().
装箱
当一个值类型变量,用在一个需要引用类型变量的位置时,会发生装箱操作。装箱操作,通常发生在我们把值类型的变量如int或者float,传递给需要object参数的函数时如Object.Equals()。
例如,函数String.Format()接收一个字符串和一个object参数。当我们传参数一个字符串和一个int时,int必须被装箱,下面是例子代码:
void ExampleFunction() { int cost = 5; string displayString = String.Format("Price: {0} gold", cost); }
装箱会产生垃圾是因为底层发生的行为。当一个值类型变量被装箱时,Unity创建一个临时的System.Object在堆上,去包装值类型变量。System.Object是引用类型的变量,所以当这个临时的对象被创建和销毁时产生了垃圾。
装箱引起的不必要的堆内存分配是十分常见的。即使我们在代码中没有直接装箱操作,我们使用的插件或者其他间接调用的函数也可能在幕后进行了装箱操作。避免装箱操作的最佳实践就是尽可能的少使用导致装箱操作的函数,以及避免使用直接的装箱操作。
协程
调用StartCoroutine()会产生少量的垃圾,因为Unity必须创建管理协程的类实例。考虑到这一点,当我们关注游戏的交互和性能时,应该有限制的使用协程。为了减少协程产生垃圾的影响,我们不建议在性能临界的时候使用协程。我们还应该特别小心套嵌的协程,如包含延迟调用的协程。
协程中的yield语句自身不会产生堆内存分配;尽管如此,我们通过yield传递的值可能会产生不必要的堆内存分配。例如下面的代码就会产生垃圾。
yield return 0;
这会产生垃圾是因为发生了装箱,如果我们只是想要等待一帧,而不产生垃圾,最好是使用下面的代码:
yield return null;
另一个使用协程的常见错误是在yield返回相同的值得时候,多次使用new。例如下面代码中,每次循环都会创建和释放WaitForSeconds对象:
while (!isComplete) { yield return new WaitForSeconds(1f); }
如果缓存了WaitForSeconds,可以减少很多垃圾:
WaitForSeconds delay = new WaitForSeconds(1f); while (!isComplete) { yield return delay; }
如果我们的代码因为协程产生了很多的垃圾,我们也许需要考虑重构代码去使用其他方法来代替协程。重构是很复杂的工作,并且每个项目都有自己的独特性,但是一些关于协程的通用的改进方法,我们应该铭记在心。例如,如果我们使用协程只是为了管理时间,我们可以直接在Update()方法中处理时间相关的内容。如果我们使用协程是为了管理函数的执行顺序,我们可以创建一个消息系统来使对象之间互相交流。这里没有万能的处理方法,但是请牢记,在代码中实现相同的功能,通常并不只有一种方法。
foreach循环
在Unity5.5之前,foreach循环数组以外的集合时,每次循环都会产生垃圾。这是因为幕后的装箱操作。每次循环开始System.Object都会在堆上被创建,在循环结束时被销毁。这个问题已经在Unity5.5版本中修复了。
例如,在5.5版本之前,下面的代码会产生垃圾:
void ExampleFunction(List listOfInts) { foreach (int currentInt in listOfInts) { DoSomething(currentInt); } }
如果我们不能升级Unity的版本,这有简单的解决方法。使用for或者while循环代替,不会产生垃圾。我们应该在循环非数组集合时使用。
下面代码不会产生垃圾:
void ExampleFunction(List listOfInts) { for (int i = 0; i < listOfInts.Count; i ++) { int currentInt = listOfInts[i]; DoSomething(currentInt); } }
函数引用
函数引用,不论是匿名方法还是命名的方法,在Uniyt中都是引用类型的变量。他们会引起堆内存分配。把匿名方法转换为闭包会显著的增加内存占用和堆内存分配的大小。
函数引用和闭包具体怎么明确的分配内存,取决于不同的平台和编译设置,但是考虑到垃圾回收,我们最好少使用函数引用和闭包。
LINQ和正则表达式
他们都会产生垃圾,因为需要装箱操作,如果需要考虑性能问题,那么最好不要使用他们。
组织我们的代码以便最小化垃圾回收的影响
我们代码的组织方式可以影响垃圾回收。甚至我们的代码没有产生堆内存分配,也可以增加垃圾回收器的负载。
我们不必要的增加垃圾回收器的负载的一种情况是,我们要求他去检查原本不必要检查的东西。结构体是值类型,但是如果我们在结构体中包含了引用类型的变量,那么垃圾回收器就需要检查整个结构体。如果我们有一个由大量这种结构体组成的数组,那么将使得垃圾回收器做了很多额外的工作。
下面的例子中,结构体包含了字符串,垃圾回收器必须要检查整个数组。
public struct ItemData { public string name; public int cost; public Vector3 position; }
private ItemData[] itemData;
在这个例子中,我们可以用三个独立的数组存储信息,这样垃圾回收器就只需要处理字符串数组了。
private string[] itemNames; private int[] itemCosts; private Vector3[] itemPositions;
另个增加没必要的垃圾回收器负载的情况是没必要的持有对象引用。当垃圾回收器在堆上检查对象的引用时,他必须在代码中检查每一个持有该对象的引用。我们持有堆中对象的引用越少,垃圾回收器的工作就会越少,哪怕我们并没有减少堆上的对象。
在下面的例子中,我们有一个用于填充对话框的类。当用户查看过后对话框时,另一个对话框会显示出来。我们的代码持有了一个用于填充下一个对话库的数据类DialogData,这意味着垃圾回收器必须检查这个引用。
public class DialogData { private DialogData nextDialog; public DialogData GetNextDialog() { return nextDialog; } }
这里,我们重构代码,返回下一个DialogData的实例id,替代返回实例本身。这就不需要引用对象,也就不会增加垃圾回收器的负载了。
public class DialogData { private int nextDialogID; public int GetNextDialogID() { return nextDialogID; } }
这个例子比较琐碎。尽管如此,如果我们的游戏拥有很多对象,并且持有很多其他对象,那么我们可以使用这种方式去重构代码,以便降低垃圾回收器的负载。
安排垃圾回收的时间
手工强制执行垃圾回收
最终,我们也许希望我们自己触发垃圾回收。如果我们知道堆内存被分配,但是已经不再使用了(例如,我们的代码在加载资源时产生的垃圾)并且我们知道垃圾回收在此时不会影响玩家体验(例如在显示加载界面的时候),我们可以用下面的代码强制执行垃圾回收:
System.GC.Collect();
这将会强制执行垃圾回收,释放未被使用的堆内存,我们可以在方便的时机调用。
总结
我们已经学习了垃圾回收在Unity中是怎样工作的,为什么他会引起性能问题,以及怎样去最小化他对我们游戏的影响。使用这些知识和性能分析工具,我们可以解决垃圾回收相关的性能问题并且组织我们的代码使得他们更有效的管理内存。
下面一些资源提供了相关主题的更多信息。