Unity游戏的GC(garbage collection)优化

发表于2018-09-18
评论0 8.3k浏览
引言

游戏运行时使用内存来存储数据,当这些数据不再被使用时,存储这些数据的内存被释放以便于之后这些内存可以被复用。垃圾(Garbage )是存储无用数据的内存的术语,GC(Garbage Collection 垃圾回收)是使这些内存可以再次使用的过程。

GC是Unity管理内存的一部分,我们的游戏可能因为GC负担过重而表现不佳,所以GC是引起性能问题的一个常见原因。

在这篇文章中,我们将介绍GC如何工作,在什么情况下会触发GC和如何高效的使用内存以减少GC对游戏的影响。

GC问题诊断

GC引起的性能问题可表现为帧率过低,帧率剧烈波动或者间歇性卡顿。但是其他问题也可能引起类似的症状。如果你的游戏有这些性能问题,首先需要使用Unity的Profiler工具来确定这些问题是由GC引起的。

如何使用Profiler工具来确定引起性能问题的原因,可以查看这篇教程。

Unity内存管理简介

在了解GC如何工作和何时触发之前,我们需要先了解Unity的内存使用情况。首先,我们要知道,在运行自己的核心引擎代码和运行我们在脚本中编写的代码时,Unity使用不同的方法。

当Unity在运行自己的核心引擎代码时使用手动内存管理,这意味着核心引擎代码必须明确地说明如何使用内存。手动内存管理不使用GC,本文不做介绍。

当Unity运行我们写的脚本代码时使用自动内存管理,这意味着我们写代码时不用明确的告诉Unity如何管理内存,Unity自动帮我们完成这些工作。

基本上来说,Unity自动内存管理像这样工作:
  • Unity可以访问两个内存池:栈和堆(也称为托管堆)。栈用于短期存储小块数据,堆用于长期存储和较大数据段。
  • 当创建变量时,Unity从栈或堆中申请内存
  • 只要变量在作用域内(仍然可以通过我们的代码访问),分配给它的内存仍然在使用中, 我们称这部分内存已被分配。 我们将栈中的变量称为栈对象,将堆中的变量称为堆对象。
  • 当变量超出作用域,该内存不再被使用并可以归还给原来的内存池。当内存被归还给原有的内存池里,我们称该内存被释放。栈内存在变量超出作用域时被实时释放,而堆内存在变量超出作用域之后并没有被释放并保持被分配的状态
  • 垃圾收集器(garbage collector)识别和释放未使用的堆内存。 垃圾收集器定期运行以清理堆。

现在我们了解事件的流程,让我们进一步了解栈分配和释放与堆分配和释放之间的区别。

在栈分配和释放时发生了什么

栈分配和释放简单快速。这是因为栈只用于在短时间内存储小数据。 分配和释放总是以可预测的顺序发生,并且具有可预测的大小。

栈的工作方式类似于栈数据类型:它是一个简单的元素集合,这种情况下的内存块,只能以严格的顺序添加和删除元素。 这种简单性和严格性使得它变得非常快速:当一个变量存储在栈上时,它的内存就是简单地从栈顶分配。 栈变量超出作用域时,用于存储该变量的内存将立即返回栈进行重用。

在堆分配时发生了什么

堆分配比栈分配复杂的多。因为堆可以用来存储长期和短期数据及各种不同类型大小的数据。分配和释放也并不总是按可预测的顺序进行且可能需要大小差距巨大的内存块。

当一个堆变量创建时,将执行以下步骤:
  • 首先,Unity检查堆上是否有足够的空闲内存,如果有,则该变量的内存被分配。
  • 如果没有,Unity触发GC试图释放未使用的堆内存,这个操作可能很慢。如果GC之后堆内存足够,则该变量的内存被分配。
  • 如果GC之后堆上还是没有足够的空闲内存,Unity将向操作系统申请更多内存以扩大堆大小。这个操作可能很慢。之后该变量的内存被分配。
  • 堆分配可能会很慢,特别在必须执行GC和扩大堆大小时。

在GC时发生了什么

当堆变量超出作用域后,存储该变量的内存并没有被立即释放。无用的堆内存只在执行GC时被释放。

每次执行GC时,将执行以下步骤:
  • 垃圾收集器检索堆上的每个对象。
  • 垃圾收集器搜索所有当前对象引用以确定堆上的对象是否仍在作用域内。
  • 不在作用域内的对象被标记为删除。
  • 删除被标记的对象并将内存返回给堆。
GC是个费时的操作,堆上的对象越多,代码中的引用数越多,GC就越费时。

何时会触发GC

三种情况下会触发GC:
  • 堆分配时堆上的可用内存不足时触发GC。
  • GC会不时的自动运行(频率因平台而异)。
  • 手动强制调用GC

GC可能被频繁触发。每当无法从可用堆内存中实现堆分配时,就会触发GC,这意味着频繁的堆分配和释放可能导致GC频繁。

GC的问题

现在我们了解了GC在Unity内存管理中的作用,我们可以考虑可能发生的问题类型。

最明显的问题是GC可能花费相当长的时间来运行。 如果堆上有很多对象和大量的对象引用要检查,则检查所有这些对象的过程可能很慢。 这可能会导致我们的游戏卡顿或运行缓慢。

另一个问题是GC可能在不合时宜的时刻被触发。 如果CPU在我们游戏的性能关键部分已经满负荷了,那此时即使是少量的GC额外开销也可能导致我们的帧速率下降和性能问题。

另一个不太明显的问题是堆碎片。当从堆中分配内存时,会根据必须存储的数据大小从不同大小的块中的可用空间中获取内存。当这些内存块返回到堆时,堆可能分成很多由分配块分隔的小空闲块。这意味着虽然可用内存总量可能很高,但由于碎片化太过严重而无法分配一块连续的大内存块。导致GC被触发或不得不扩大堆大小。

堆内存碎片化有两个后果,一是游戏内存大小会远高于实际所需要的大小,二是GC会被更频繁的触发。 有关堆碎片的更详细讨论,请参阅这个Unity性能最佳实践指南。

查找堆分配

当我们的游戏因为GC而出现问题时,我们需要知道是由哪部分的代码引起的。当堆上的变量超出作用域后,这部分的内存变为待回收的垃圾内存,所以我们需要知道一个变量何时会被分配到堆上。

栈和堆上分配了什么?

Unity中值类型的局部变量分配在栈上,除此之外都分配在堆上。如果不清楚值类型和引用类型的区别,请看这篇教程。

下面这段代码是个栈分配的示例,localInt变量是个局部的值类型变量。分配给该变量的内存在该函数调用结束后立即被回收。
void ExampleFunction()
{
    int localInt = 5;
}

下面这段代码是个堆分配的示例,localList变量是局部变量但是引用类型。分配给该变量的内存在下次GC时被回收。
void ExampleFunction()
{
    List localList = new List();
}

使用Profiler工具来查找堆分配

我们可以使用Profiler工具来查看哪部分代码产生了堆分配

选中CPU Usage,然后选中任意帧就可以在Profiler窗口的下部查看到该帧的CPU使用数据。其中一列叫GC alloc,这一列显示了这帧中的堆分配信息。点击列头对该列进行排序,这样可以更直观的看出当前帧哪些函数产生了最多的堆分配。这样就可以检查这些产生堆分配的函数。

一旦我们知道函数内的什么代码导致生成垃圾,我们可以决定如何解决这个问题,并最大限度地减少垃圾的生成量。

减少GC的影响

概括的说,可以通过以下三中方式来减少GC对我们游戏的影响:
  • 减少GC的时间
  • 减少GC的频率
  • 故意触发GC,以避开游戏运行的性能关键点,比如加载场景时

基于这些考虑,我们可以使用三种策略:
  • 我们可以组织我们的游戏使其更少的堆分配和更少的对象引用。 堆上更少的对象和更少的引用 检查意味着当GC触发时,运行时间更少。
  • 我们可以减少堆分配和释放的频率,特别是在性能点。 更少的分配和释放意味着更少的触发GC。 这也降低了堆碎片的问题。
  • 我们可以尝试手动触发GC和扩展堆大小以便GC可控并在合适的时候触发。这个方法更难且不可靠,但作为整体内存管理策略的一部分,可以减少GC的影响。

减少垃圾的产生量

可以使用一些技术来帮助我们减少代码中生成的垃圾量

缓存

如果我们的代码重复调用产生堆分配的函数,然后丢弃结果,这将产生不必要的垃圾。 对此,我们应该存储对这些对象的引用并复用它们。 这种技术被称为缓存。

下面的函数每次调用都会引起堆分配,因为每次调用都会生成一个新的数组。
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);
}

对象池

即使减少了脚本中的堆分配,在运行时大量对象的创建和销毁依然会引起GC问题。 对象池是一种通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术。对象池在游戏中广泛使用,最适合于频繁产生和销毁类似对象的情况;,例如,当枪射击子弹时。

对象池的完整指南超出了本文的范围,但它是一个非常有用的技术,值得一试。 关于Unity学习网站上的对象池的这个教程是在Unity中实现对象池系统的一个很好的指导

引起不必要堆分配的常见原因

我们知道局部的,值类型的变量被分配在栈上,其他的都在堆上分配。但是很多情况下的堆分配可能让人惊讶。我们来看看一些不必要的堆分配的常见原因,并考虑如何最好地减少这些。

字符串

在C#中,字符串是引用类型,而不是值类型,尽管它们似乎保持字符串的“值”. 这意味着创建和丢弃字符串会产生垃圾。由于字符串常用在很多代码中,所以这些垃圾可能累积。

C#中的字符串也是不可变的,这意味着它们的值在第一次创建之后不能再被更改。 每次我们操纵一个字符串(例如,通过使用+运算符来连接两个字符串),Unity将创建一个包含更新值的新字符串,并丢弃旧字符串。 这会产生垃圾。

我们可以遵循一些简单的规则,将字符串产生的垃圾减至最少。 我们来看看这些规则,然后看一下应用它们的例子。
  • 减少不必要的字符串创建。 如果多次使用相同的字符串值,应该创建一次该字符串并缓存该值。
  • 减少不必要的字符串操作。 例如,如果有一个经常更新的Text组件,并且包含一个连接的字符串,可以考虑将它分成两个Text组件。
  • 如果必须在运行时构建字符串,应该使用StringBuilder类。 StringBuilder类用于创建没有堆分配的字符串,并且在连接复杂字符串时减少生成的垃圾量。
  • 当不在需要调试时,立即删除对Debug.Log()的调用。即使没有输出任何内容,对Debug.Log()的调用依然会被执行。调用Debug.Log() 创建和处理至少一个字符串,所以如果我们的游戏包含许多这些调用,垃圾会累积

来看一个低效使用字符串而产生不必要垃圾的代码的例子。 在下面的代码中,在Update()中创建一个连接“TIME:”与浮点计时器的值的字符串来显示分数,这产生了不必要的垃圾。
public Text timerText;
private float timer;
void Update()
{
    timer += Time.deltaTime;
    timerText.text = "TIME:" + timer.ToString();
}

下面我们做些改进。我们把单词“TIME:”放在一个单独的文本组件中,并在Start()中设置它的值。 这样在Update()中,我们不再需要连接字符串。 可以大大减少垃圾的产生。
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
    timerHeaderText.text = "TIME:";
}
void Update()
{
    timerValueText.text = timer.toString();
}

Unity函数调用

重要的是要注意,每当我们调用不是自己写的代码时,无论是在Unity中还是在插件中,都可能会产生垃圾。 调用一些Unity函数会产生堆分配,因此应谨慎使用以避免产生不必要的垃圾。

并没有一个应该避免使用的函数列表。 每个函数在某些情况下都是有用的,而在其他情况下则不太有用。所以最好仔细分析我们的游戏,确定垃圾的产生位置并仔细思考如何处理。 在某些情况下,可以缓存函数的结果; 在某些情况下,可以降低调用函数的频率; 在其他情况下,最好重构代码以使用不同的函数。 话虽如此,我们来看几个常见的会导致堆分配 的Unity函数,并考虑如何更好地处理它们。

每次访问返回值为数组的Unity函数时,都会创建一个新的数组,并将其作为返回值传递给我们。 这种行为并不总是显而易见的或可预期的,特别是当函数是访问器的时候(例如  Mesh.normals)。

下面的代码中,每次循环迭代都会生成一个新的数组
void ExampleFunction()
{
    for (int i = 0; i < myMesh.normals.Length; i++)
    {
        Vector3 normal = myMesh.normals[i];
    }
}

这种情况下很容易减少分配:我们可以简单地缓存对数组的引用。 这样可以只创建一个数组,并相应地减少了产生的垃圾量。

下面的代码演示了这一点。 在这种情况下,我们在循环之前调用Mesh.normals并缓存引用,这样就只创建一个数组。
void ExampleFunction()
{
    Vector3[] meshNormals = myMesh.normals;
    for (int i = 0; i < meshNormals.Length; i++)
    {
        Vector3 normal = meshNormals[i];
    }
}

访问GameObject.name或GameObject.tag也会有堆分配。 这两个都是返回新字符串的访问器,这意味着调用这些函数会产生垃圾。 缓存该值可能是有用的,但在这种情况下,可以使用相关的Unity函数。 要检查一个GameObject的标签的值而不产生垃圾,我们可以使用  GameObject.CompareTag()。

下面的示例代码中,访问GameObject.tag会产生垃圾内存:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
    bool isPlayer = other.gameObject.tag == playerTag;
}

如果使用 GameObject.CompareTag(),则该函数不会产生垃圾:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
    bool isPlayer = other.gameObject.CompareTag(playerTag);
}

GameObject.CompareTag并不是唯一的,很多Unity的函数都有无堆分配的替代版本。比如可以使用Input.GetTouch() 和 Input.touchCount 替换 Input.touches, 或者使用Physics.SphereCastNonAlloc() 替换 Physics.SphereCastAll()。

装箱

装箱是指当一个值类型变量被用作一个引用类型变量时所执行的操作。当我们将值类型的变量(如int或float)传递给具有object类型参数的函数时,通常会发生装箱,如Object.Equals()函数。

例如,函数String.Format()接受一个string和一个object参数。 当我们传递一个string和一个int时,int就会被装箱。 下面的代码包含了一个装箱的例子:
void ExampleFunction()
{
    int cost = 5;
    string displayString = String.Format("Price: {0} gold", cost);
}

装箱会产生垃圾源于其后台操作。当一个值类型变量被装箱时,Unity在堆上创建一个临时的System.Object来包装值类型变量。 一个System.Object是一个引用类型的变量,所以当这个临时对象被处理掉时会产生垃圾。

装箱是不必要的堆分配的常见原因。 即使我们不在我们的代码中直接装箱变量,我们可能也会使用导致装箱的插件,装箱也可能发生在其他函数的后台。 最好的做法是尽可能避免装箱,并删除导致装箱的任何函数调用。

协程

调用StartCoroutine()会产生少量的垃圾,因为Unity必须创建一些管理协程的实例的类。 所以,当游戏在交互时或在性能热点时应该限制对StartCoroutine()的调用。 为了减少这种方式产生的垃圾,必须在性能热点运行的协程应该提前启动,当使用可能包含对StartCoroutine()的延迟调用的嵌套协程时,我们应特别小心。

协程中的yield语句不会自己产生堆分配; 然而,我们传递给yield语句的值可能会产生不必要的堆分配。 例如,以下代码会产生垃圾:
yield return 0;

该代码产生垃圾,因为int变量0被装箱。 在这种情况下,如果我们希望只是等待一个帧而不会导致任何堆分配,那么最好的方法是使用以下代码:
yield return null;

协程的另一个常见错误是在多次使用相同的值时使用了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对象被分配在堆上。 Unity 5.5中已修复此问题。

在5.5之前的Unity版本中,以下代码中的循环会生成垃圾:
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);
    }
}

函数引用

函数引用,无论是引用匿名函数还是命名函数,都是Unity中的引用类型变量。 它们将导致堆分配。 将匿名函数转换为 闭包(匿名函数可在其创建时访问范围中的变量)显着增加了内存使用量和堆分配数量。

函数引用和闭包如何分配内存的精确细节因平台和编译器设置而异,但是如果GC是一个问题,那么最好在游戏过程中尽量减少使用函数引用和闭包。 这个Unity性能最佳实践指南 在这个主题上有更多的技术细节。

LINQ和正则表达式

LINQ和正则表达式由于在后台会有装箱操作而产生垃圾。在有性能要求的时候最好不使用。 同样,这个Unity性能最佳实践指南 提供了有关此主题的更多技术细节。

构建代码以最小化GC的影响

代码的构建方式可能会影响GC。即使代码中没有堆分配,也有可能增加GC的负担。

可能增加GC的负担之一是要求它检查它不应该检查的东西。Structs是值类型变量,但是如果有一个包含引用类型变量的struct,那么垃圾收集器必须检查整个结构体。 如果有大量这样的结构体,那么垃圾回收器将增加大量额外的工作。

在这个例子中,下面的struct包含了一个引用类型的字符串。 现在在垃圾回收器运行时必须检查结构体的整个数组。
public struct ItemData
{
    public string name;
    public int cost;
    public Vector3 position;
}
private ItemData[] itemData;

在这个例子中,我们将数据存储在单独的数组中。 当垃圾收集器运行时,它只需要检查字符串数组,并且可以忽略其他数组。 这减少了垃圾收集器的工作。
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;

另一个可能增加GC负担的操作是使用不必要的对象引用,当垃圾收集器搜索对堆上对象的引用时,它必须检查代码中的每个当前对象引用。 更少的对象引用意味着更少的工作量,即使我们不减少堆上的对象总数。

在这个例子中,我们有一个类填充一个对话框。 当用户查看对话框时,会显示另一个对话框。 我们的代码包含对应该显示的DialogData的下一个实例的引用,这意味着垃圾回收器必须在其操作中检查此引用:
public class DialogData
{
    private DialogData nextDialog;
    public DialogData GetNextDialog()
    {
        return nextDialog;
    }
}

这里我们重构下代码,以便它返回一个用于查找下一个DialogData实例的标识符,而不是实例本身。 这不是一个对象引用,所以它不会增加垃圾收集器所花费的时间。
public class DialogData
{
    private int nextDialogID;
    public int GetNextDialogID()
    {
        return nextDialogID;
    }
}

这是个小例子。 然而,如果我们的游戏中有许多包含对其他对象引用的对象,那么我们可以通过以这种方式重构代码来大大降低堆的复杂性。

定时GC

手动强制GC

最后,我们可能希望自己触发GC。 如果我们知道堆内存已被分配但不再使用(例如,如果我们的代码在加载资源时生成垃圾),并且我们知道垃圾收集冻结不会影响播放器(例如,当加载界面还显示时),我们可以使用以下代码请求GC:
System.GC.Collect();
这将强制运行GC,在我们方便的时候释放未使用的内存。

结论

我们已经了解了GC在Unity中的工作原理,为什么会导致性能问题,以及如何最大限度地减少对我们游戏的影响。 使用这些知识和分析工具,我们可以解决与GC相关的性能问题,并构建我们的游戏,以便有效地管理内存。
原文:https://unity3d.com/cn/learn/tutorials/topics/performance-optimization/optimizing-garbage-collection-unity-games

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引