创建对象、字符串或数组时,用于存储它的内存是从称为堆的中央池分配的。当此项不再使用时,其先前占用的内存可被回收并用于其他目的。在过去,通常由程序员通过适当的函数调用显式地分配和释放这些堆内存块。如今,Unity 的 Mono 引擎等运行时系统会自动为您管理内存。自动内存管理比显式分配/释放的做法需要更少的编码工作,并且大大降低了内存泄漏的可能性(即分配了内存但后续从未释放的情况)。
调用某个函数时,其参数的值将复制到为该特定调用保留的内存区域。只占几个字节的数据类型可以非常快速和轻松地完成复制。但是,对象、字符串和数组通常要大得多,如果需要经常复制这些类型的数据,效率会非常低。幸运的是,并不是非要这样做;可从堆中分配大项的实际存储空间,并使用小“指针”值来记住它的位置。此后,在参数传递期间只需要复制指针。只要运行时系统能找到该指针所标识的项,就可以根据需要频繁使用该数据的同一个副本。
在参数传递期间直接存储和复制的类型称为值类型。这些类型包括整数、浮点数、布尔值和 Unity 的结构类型(例如,__Color__ 和 __Vector3__)。在堆上分配后再通过指针访问的类型称为引用类型,因为在变量中存储的值仅“引用”实际数据。引用类型的示例包括对象、字符串和数组。
内存管理器跟踪已知未使用的堆区域。当请求新的内存块时(例如,当实例化对象时),管理器选择一个未使用的区域来分配内存块,然后从已知的未使用空间中移除分配的内存。后续请求以相同的方式处理,直到没有足够大的可用区域来分配所需的块大小。此时极不可能从堆中分配的所有内存都仍在使用中。若要访问堆上的引用项,前提是仍有引用变量可以定位到该项。如果对内存块的所有引用都消失(即,引用变量已被重新分配,或者引用变量是局部变量但现在已超出范围),则可安全地重新分配其占用的内存。
为确定哪些堆块已不再使用,内存管理器会搜索所有当前处于活动状态的引用变量,并将它们引用的块标记为“实时”。在搜索结束时,内存管理器会认为实时块之间的任何空间都是空的并可用于后续分配。由于显而易见的原因,定位和释放未使用的内存的过程称为垃圾收集(或简称 GC)。
Unity 使用 Boehm–Demers–Weiser 垃圾回收器,这是一种可停止所有工作的垃圾回收器。每当 Unity 需要执行垃圾收集时,它都会停止运行程序代码,并且仅在垃圾回收器完成所有工作后才恢复正常执行。此中断可能会导致游戏执行延迟,持续时间从不到一毫秒到几百毫秒不等,这取决于垃圾回收器需要处理多少内存以及运行游戏的平台。对于像游戏这样的实时应用程序,这可能会成为一个重大问题,因为垃圾回收器暂停游戏的执行时,您无法维持平滑动画所需的稳定帧率。这些中断也被称为 GC 尖峰,因为它们在性能分析器帧时间图中显示为尖峰。在接下来的部分中,您将更详细了解如何编写代码以避免在运行游戏时对分配的内存进行不必要的垃圾收集,从而减少垃圾回收器的工作量。
垃圾收集是自动完成的,对程序员来说不可见,但收集过程实际上在后台需要耗费大量 CPU 时间。如果使用得当,自动内存管理通常在整体性能上能达到或超过手动分配。但是,程序员必须避免错误以免导致不必要的频繁触发垃圾回收器并在执行中引起暂停。
有一些臭名昭着的算法虽然一眼看上去好像是无辜的,但可能成为 GC 的噩梦。重复的字符串连接便是一个典型的例子:
//C# 脚本示例
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void ConcatExample(int[] intArray) {
string line = intArray[0].ToString();
for (i = 1; i < intArray.Length; i++) {
line += ", " + intArray[i].ToString();
}
return line;
}
}
此处的关键细节是新的部分不会逐一添加到字符串。实际情况的是,每次循环时,line 变量的先前内容变为死亡状态:分配的整个新字符串将包含原始部分加上末尾的新部分。由于字符串随着 i 值的增加而变长,因此消耗的堆空间量也会增加,所以每次调用此函数时都很容易用掉数百个字节的空闲堆空间。如果需要将大量字符串连接在一起,那么 Mono 库的 System.Text.StringBuilder 类将是更好的选择。
但是,即使重复的连接也不会造成太大麻烦,除非频繁调用,而在 Unity 中这通常意味着帧更新。类似以下脚本:
//C# 脚本示例
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public Text scoreBoard;
public int score;
void Update() {
string scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
}
}
…在每次调用 Update 时都会分配新的字符串,并生成源源不断的垃圾。通过仅在 score 发生变化时才更新 text,可避免大部分的垃圾:
//C# 脚本示例
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public Text scoreBoard;
public string scoreText;
public int score;
public int oldScore;
void Update() {
if (score != oldScore) {
scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
oldScore = score;
}
}
}
另一个潜在问题是在函数返回数组值时出现的问题:
//C# 脚本示例
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
float[] RandomList(int numElements) {
var result = new float[numElements];
for (int i = 0; i < numElements; i++) {
result[i] = Random.value;
}
return result;
}
}
在新建包含值的数组时,这种类型的函数非常从容和方便。但是,如果重复调用这种函数,则每次都会分配全新的内存。由于数组可能非常大,因此空闲堆空间可能会迅速耗尽,导致频繁进行垃圾收集。避免此问题的一种方法是利用数组为引用类型这一特点。作为参数传入该函数的数组可在该函数内予以修改,且结果在函数返回后仍然保留。像上面这样的函数通常可替换为如下所示的函数:
//C# 脚本示例
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void RandomList(float[] arrayToFill) {
for (int i = 0; i < arrayToFill.Length; i++) {
arrayToFill[i] = Random.value;
}
}
}
此函数仅将数组的现有内容替换为新值。虽然这需要在调用代码中完成数组的初始分配(看起来有点不方便),但在调用该函数时不会产生任何新的垃圾。
如果使用的是 Mono 或 IL2CPP 脚本后端,则可以通过在运行时禁用垃圾收集来避免垃圾收集期间的 CPU 使用率激增。禁用垃圾收集时,内存使用量完全不会减少,因为垃圾回收器不会收集不再有任何引用的对象。事实上,禁用垃圾收集时,内存使用量只会增加。为避免一段时间内内存使用量增加,请在管理内存时慎重操作。理想情况下,在禁用垃圾回收器之前分配所有内存,并在禁用垃圾回收器时避免进行其他分配。
有关如何在运行时启用和禁用垃圾收集的更多详细信息,请参阅 GarbageCollector 脚本 API 页面。
还可以尝试增量垃圾收集选项。
如上所述,最好尽量避免内存分配。但是,鉴于无法完全消除这些行为,可采用两种主要策略来最小化这些行为对游戏运行过程的干扰。
这种策略通常最适合游戏运行过程很长且主要关注帧率平滑性的游戏。像这样的游戏通常会频繁分配小块,但这些块的使用时间很短暂。在 iOS 上使用此策略时的典型堆大小约为 200KB,在 iPhone 3G 上的垃圾收集时间大约需要 5ms。如果堆大小增加到 1MB,则收集时间将大约需要 7ms。因此,有时,以定期的帧间隔请求进行垃圾收集可能是有利的。这种情况下通常会使垃圾收集频率高于严格意义上的要求,但是这些行为将得到快速处理,并且对游戏运行过程的影响极小:
if (Time.frameCount % 30 == 0)
{
System.GC.Collect();
}
但是,应谨慎使用此技术并检查性能分析器的统计信息,以确保真正减少了游戏的垃圾收集时间。
这种策略最适合内存分配(因此垃圾收集)相对不频繁并可在游戏运行过程的暂停期间进行处理的游戏。一种非常有用的方法是,尽可能增大堆的大小,但不至于因为系统内存不足而导致操作系统终止您的应用程序。但是,Mono 运行时会尽可能避免自动扩展堆。这种情况下,可通过在启动期间预先分配一些占位空间来手动扩展堆(即,实例化一个纯粹为了影响内存管理器而分配的“无用”对象):
//C# 脚本示例
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void Start() {
var tmp = new System.Object[1024];
// 以较小的块进行分配,从而避免以适合大块的特殊方式处理它们
for (int i = 0; i < 1024; i++)
tmp[i] = new byte[1024];
// 释放引用
tmp = null;
}
}
一个足够大的堆不应在游戏运行过程中配合进行垃圾收集的暂停期间完全耗尽。发生此类暂停时,可显式请求垃圾收集:
System.GC.Collect();
同样,在使用此策略时应谨慎,并注意性能分析器的统计信息,而不能仅仅期待其具有所需的效果。
在许多情况下,通过减少创建和销毁的对象数量即可避免生成垃圾。游戏中存在某些类型的对象,例如飞弹,可能会多次反复遇到,但是只有少数对象会同时处于游戏中。在这种情况下,通常可以重用对象,而不是销毁旧对象并替换为新对象。
增量式垃圾收集将垃圾收集过程分散到多个帧中。
增量式垃圾收集是 Unity 使用的默认垃圾收集方法。Unity 仍然使用 Boehm–Demers–Weiser 垃圾收集器,但是以增量模式运行。Unity 不会在每次运行时进行完整的垃圾收集,而是将垃圾收集工作负载拆分到多个帧中。这意味着,不必单次长时间中断程序的执行来让垃圾收集器完成工作,Unity 会进行多次短时间的中断。虽然这不能整体上加快垃圾收集速度,但将工作负载分布到多个帧可以极大减少垃圾收集“尖峰”破坏应用程序流畅性的问题。
下面的 Unity Profiler 屏幕截图说明了增量式收集如何减少帧率中断。在这些性能分析跟踪中,帧的浅蓝色部分显示脚本操作所用的时间,黄色部分显示在 Vsync 之前(等待下一帧开始)的帧中剩余时间,而深绿色部分显示垃圾收集花费的时间。
以下屏幕截图显示了在不使用增量式垃圾收集的应用程序中从 Unity Profiler 捕获的帧:
如果不进行增量式垃圾收集,尖峰中断了原本平滑的 60fps 帧率。这个尖峰将发生垃圾收集的帧推到了维持 60FPS 所需的 16 毫秒限制之上(本示例由于垃圾收集而丢弃了不止一帧。)
以下屏幕截图显示了在使用增量式垃圾收集的应用程序中从 Unity Profiler 捕获的帧:
启用增量垃圾收集(上图)后,同一项目将保持一致的 60fps 帧率,因为垃圾收集操作被分解到若干帧中,只占用每帧的一小段时间(位于黄色 Vsync 跟踪上方的深绿色条纹)。
下面的屏幕截图显示了同一项目的 Unity Profiler 中捕获的帧,该项目也在启用增量式垃圾收集的情况下运行,但是这次每帧脚本操作减少。
同样,垃圾收集操作也分解到若干帧中。差异在于这次垃圾收集占用每帧的更多时间,而需要更少的总帧数即可完成。这是因为如果应用程序使用 Vsync 或 Application.targetFrameRate,Unity 根据剩余的可用帧时间来调整分配给垃圾收集的时间。这样,Unity 可以及时运行垃圾收集(否则需要等待),从而以最小的性能影响执行垃圾收集。
除 WebGL 之外的所有平台都支持增量式垃圾收集。
此外,如果将 VSync Count 设置为 Don’t Sync 之外的选项(在项目的 Quality 设置中或通过 Application.VSync 属性),或者启用 Application.targetFrameRate 属性),则 Unity 会自动使用给定帧末尾剩余的空闲时间来进行增量式垃圾收集。
要对增量式垃圾收集行为进行更精确的控制,可以使用 Scripting.GarbageCollector 类。例如,如果不想使用 VSync 或目标帧率,则可以自行计算帧结束之前的可用时间,并将该时间提供给垃圾收集器使用。
在大多数情况下,增量垃圾收集可以减轻垃圾收集尖峰的问题。但是,在某些情况下,增量垃圾收集在实践中可能没有益处。
增量垃圾收集中断工作时,它将中断标记阶段(该阶段扫描所有托管对象以确定哪些对象仍在使用中以及可以清除哪些对象)。当对象之间的大多数引用在工作片段之间不变时,拆分标记阶段没有问题。对象引用会改变时,必须在下一次迭代中再次扫描那些对象。因此,太多的更改会使增量垃圾回收器不堪重负,并导致标记遍历永远不能完成,因为它总是有更多的工作要做;在这种情况下,垃圾收集会退回到进行完整的非增量收集。
此外,在使用增量垃圾收集时,只要引用发生更改,Unity 就需要生成其他代码(称为写屏障)来通知垃圾收集(因此垃圾收集将知道是否需要重新扫描对象)。更改引用时,这会增加一些开销,可能会对某些托管代码产生明显的性能影响。
尽管如此,大多数典型的 Unity 项目(如果有这样的“典型”Unity 项目)仍可从增量垃圾收集中受益,尤其是项目遭受垃圾收集尖峰时。
始终使用性能分析器来验证您的游戏或程序是否按预期执行。
内存管理是一个微妙而复杂的主题,业界已投入了大量的学术努力。如果有兴趣了解这一主题,memorymanagement.org 将是极好的资源,其中列出了大量出版物和在线文章。如需了解对象池的更多信息,请访问 Wikipedia 页面以及 Sourcemaking.com。