[Unity] 简述性能优化2 – 垃圾回收

因为这篇文章不是原创,仅仅是对原文的主要内容进行了删减翻译和添加了一些个人观点,因此欢迎转载。码字不容易,转载请务必注明如下信息:

作者:图林根の 文章来源: https://www.mahong.me/archives/523

上一篇 文章简述了最基本的 Unity 优化方法,接下来继续讲述垃圾回收的有关优化

当我们的游戏运行时,Unity 主要会使用内存来存储运行的数据。 当不再需要此数据时,内存中存储的数据将会释放,我们称这些被释放的数据为 “垃圾” ,而垃圾回收指的是这些数据的再利用过程。这个垃圾回收机制指的是 Unity 管理内存的一个指令,该指令主要由 CPU 来处理执行,编写的代码或多或少决定了垃圾回收机制运行的频率,由于该机制涉及到了内存的分配和释放,在一定程度上是一件十分费时的操作,如果垃圾回收发生得太频繁或有太多工作要做,我们的游戏性能可能会很差,只有彻底了解垃圾回收机制的原理,才能更深入的研究性能优化。

1. 诊断垃圾收集问题

由垃圾收集引起的性能问题主要表现为帧速率过低,性能不稳定或是间歇性卡顿, 但是,其他问题也可能导致类似的症状。 如果我们的游戏遇到上面出现的性能问题,那么我们应该做的第一件事就是使用 Unity 的 Profiler 来确定出现的问题是否是由于垃圾收集引起的。
要了解如何使用 Profiler 查找性能问题的原因,可以查看 这个教程

2. Unity 中的内存管理

要了解垃圾收集的工作原理,我们必须首先了解 Unity 是如何使用内存的。 首先,我们必须了解到,Unity 使用不同的方法来运行我们编写的脚本以及它,自己的核心引擎代码。
在 Unity 运行自己的核心引擎代码时,管理内存的方式被称为 手动内存管理(manual memory management)。 这意味着核心引擎代码必须明确知道内存的使用情况以及如何使用内存。 由于 手动内存管理不使用垃圾收集,因此本文将不对其进行进一步介绍。
Unity在运行我们自己编写的脚本时,管理内存的方式称为 自动内存管理(automatic memory management)。 这意味着我们自己编写的代码无需明确地告诉 Unity 如何详细管理内存,Unity 会为我们自动解决这个问题。下面为 Unity 执行 自动内存管理 的简要步骤:

  1. Unity 可以访问两个内存池:栈(Stack)和堆(Heap),堆有时候也被称作托管堆(managed heap)。栈通常用于短期存储小块数据,堆用于长期存储和大块数据。
  2. 根据我们编写的脚本,创建变量后,Unity会从栈或堆中请求一块内存来分配变量。
  3. 只要变量在作用域范围内(in scope),这里指的是我们可以通过脚本来访问该内存,即表示分配给它的内存仍在使用。这时候我们就可以说这个内存已经分配初始化了(allocated)。我们将在栈中的变量描述为栈上的对象,将保存在堆内存中的变量描述为堆栈上的对象。
  4. 当变量超出作用域后(goes out of scope),该变量便不再需要内存空间,就可以释放(deallocated)出 栈所占用的内存空间,并使其返回到之前的内存池(pool)中。 与此同时,堆占用的空间并不会在这时候释放,即使所引用的变量超出作用域范围,堆所占用的空间仍处于分配状态。
  5. 垃圾收集器识别并释放未使用的堆内存,并且定期运行以清理堆所占用的空间。

在初步了解了 Unity 的内存事件流后我们就可以进一步观察 Unity 是如何对 堆和栈 来进行不同的内存创建和释放了。

2.1 在栈的分配和释放期间会发生什么?

的内存分配和释放快速而简单。 这是因为栈仅用于在短时间内存储少量数据。 栈的内存分配和释放始终以可预测的顺序发生,并且具有可预测的大小。
栈的工作方式可以简述为:它是元素的简单集合,在这种情况下,它是一个内存块,里面的元素只能严格按照顺序添加和删除(个人理解为先进先出的原则)。 这种简单性和严格性使其操作变得如此之快:将变量存储在栈中时,只需从栈的“末端”分配其内存即可。 当栈变量超出作用域范围时,用于存储该变量的内存将立释放以供重用。

2.2 在栈的分配和释放期间会发生什么?

堆的内存分配比堆分配要复杂得多。 这是因为堆可用于存储长期和短期数据,以及许多不同类型和大小的数据。 内存的分配和释放并非总是以可预测的,主要原因是这些堆的内存占用空间可能需要大小不同的内存块。
在内存中创建堆后,将会执行以下步骤:

  1. Unity 必须检查堆中是否有足够的可用内存。 如果堆中有足够的可用内存,则会为该变量分配内存。
  2. 如果堆中没有足够的可用内存,则 Unity 会触发垃圾收集器机制,用来尝试释放未使用的堆内存。 这个操作可能十分缓慢。 这时候如果堆中现在有足够的可用内存,则会为该变量分配内存。
  3. 如果垃圾收集后内存中仍然没有足够的内存提供给堆使用,那么 Unity 则会增加内存,来提供给堆使用,但是这个操作可能需要耗费相对大量的时间。

另外需要注意的是,堆的内存分配可能会很慢,尤其是在必须运行垃圾收集器并且必须扩展堆的情况下。

2.3 在垃圾收集的时候会发生什么?

当堆变量不在作用域范围内时,用于存储该变量的内存不会立即释放。 仅当垃圾收集器运行时,才会释放未使用的堆内存。
每次运行垃圾收集器时,都会发生以下步骤:

  1. 垃圾收集器检查堆上的每个对象。
  2. 垃圾收集器搜索所有当前对象的引用,以确定堆上的对象是否仍在作用域内。
  3. 任何不再在作用域中的对象都被标记为删除。
  4. 标记的对象将被删除,分配给它们的内存将返回到堆中。
  5. 垃圾收集可能是一项费时的操作。 堆上的对象越多,它必须执行的工作就越多,而在我们的代码中引用的对象越多,则它必须执行的工作就越多。

2.4 时候发会执行垃圾收集?

  1. 每当可用内存无法完成的堆分配时,垃圾收集器就会运行。
  2. 垃圾收集器会不时自动运行(频率随平台而异)。
  3. 可以强制垃圾收集器手动运行。

垃圾收集可能是经常进行的操作。 每当内存中没有足够的空间分配给堆的时候,就会触发垃圾收集器,这意味着如果频繁进行堆的分配和释放,同样会导致频繁的垃圾收集。

3. 垃圾收集的问题

既然我们了解了垃圾收集在 Unity 的内存管理中所扮演的角色,那么我们可以考虑解析来可能会发生的问题。
最明显的问题是,执行垃圾收集可能需要大量时间。如果垃圾收集器发现在堆上有很多对象和/或要检查的对象引用很多,那么检查这些所有对象的过程就可能会很慢很慢,从而会导致我们的游戏出现卡顿或运行缓慢的现象。
另一个问题是垃圾收集器可能会在不恰当的时间运行。例如,游戏中恰巧对 CPU 对某一关键帧进行逻辑计算,然后这时候却运行了垃圾收集处理,即使垃圾收集产生少量的额外性能开销,让然可能会导致游戏出现帧速率下降,或者性能发生明显变化的现象出现。
另一个不那么明显的问题是 堆碎片(heap fragmentation)。当从内存中为堆分配内存的时候,会从内存中不同大小的空闲块空间中为堆分配内存(类似于把堆的数据打散,然后分别在不同的地方存储似的)。这个方法会出现一个特别情况,例如尽管内存的总量可能很高,但由于没有一块连续并且足够大的内存来存放数据(figure 3),这时候我们就只能通过运行垃圾收集器和/或扩展堆的方法来分配较大的内存块。堆碎片会导致两个结果,首先是我们游戏的内存使用量将超过需要的内存使用量;其次是垃圾收集器将更频繁地运行。有关堆碎片的详细讨论,可以参考 这篇文章

Figure 3. 内存空间(too small 白色区域)太小,不足以存放 int[]Array

3.1 堆的内存分配

如果我们知道,导致游戏中出现性能问题的原因是由垃圾收集引起的,那么我们就可以修改脚本代码,来阻止其生成垃圾。 从前面介绍的内容我们了解到,当堆的变量超出作用域时会生成垃圾,因此,我们需要知道什么会导致变量在堆上分配。

3.1.1 在堆栈和堆上分配了什么?

在 Unity 中,值类型的局部变量分配在栈上,其他所有内容分配在堆上。 下面的代码是栈的分配示例,因为变量 localInt 既是局部变量,也是值类型的。 当方法 ExampeFunction 完成运行后,将立即从栈中释放该变量占用的内存。

void ExampleFunction()
{
    int localInt = 5;
}

以下代码是堆分配示例,因为变量 localList 是局部引用类型(value-typed local)。 当垃圾收集器运行时,为该变量分配的内存将被释放。

void ExampleFunction()
{
    List localList = new List();
} 

3.1.2 使用 Profiler 窗口来查找堆的内存分配

我们可以在 Profiler 窗口中查找我们的代码脚本在哪里创建堆分配。 打开方法为 Window > Analysis > Profiler

figure 3.1.2 Profiler

在运行程序,并且点击了录制后,在 CPU Useage 那一栏,我们就可以轻松查看GC分配了。 Profiler 的具体使用方法这里就不详细介绍了,感兴趣的朋友可以查阅其他教程。 一旦我们知道函数中的哪些代码导致产生垃圾,就可以决定如何解决此问题并最大程度地减少垃圾的产生。

Figure 3.1.2a GarbageCollector 在 Profiler 显示

3.2 降低垃圾收集的影响

广义上讲,我们可以通过三种方式减少垃圾收集对游戏的影响:

  1. 减少垃圾收集器运行所需的时间。
  2. 减少垃圾收集器运行的频率。
  3. 手动触发垃圾收集器,以便它在对性能不重要的时间(例如在加载屏幕期间)运行。

因此对于上面这些要点,我们可以采取对应的策略来优化我们的项目

  1. 优化项目,减少堆分配和更少的对象引用,避免产生过多垃圾。 堆上的对象越少,要检查的引用越少,这就意味着在触发垃圾收集的时候,额外开销运行的时间会减少。
  2. 减少堆分配和释放的频率,特别是在性能关键的时刻。 更少的堆分配意味着垃圾收集会更少的触发,从而降低了堆碎片的风险。
  3. 尝试在可预测或者方便的时间执行垃圾收集和堆扩展,但是这个方法不容易实现,而且并不是总是十分可靠。

接下来会对上面这三点进行详细的介绍:

3.2.1 减少垃圾的产生

3.2.1.1 缓存 (Caching)

如果我们的代码脚本在调用堆分配的变量或者函数后,反复丢弃运行结果,则会创建不必要的垃圾。 所以我们应该存储对这些对象的引用并重用它们,这种技术称为缓存技术。
在下面的示例中,代码在每次调用时都会导致堆分配。 这是因为创建了一个新数组。

// 错误的做法,应该尽量避免
void OnTriggerEnter(Collider other)
{
    Renderer[] allRenderers = FindObjectsOfType<Renderer>();
    ExampleFunction(allRenderers);
}

正确的做法是,首先创建数组一次然后将其缓存(cached)。 缓存的数组可以一次又一次地重用,而不会产生更多的垃圾。

// 正确的做法,简要来说就是把反复出现的变量提前创建,而不是在每一次调用的时候创建新变量
private Renderer[] allRenderers;

void Start()
{
    allRenderers = FindObjectsOfType<Renderer>();
}


void OnTriggerEnter(Collider other)
{
    ExampleFunction(allRenderers);
}

3.2.1.2 避免过多的分配经常调用的函数方法

除非有必要,否则我们应该尽量避免每一帧 (例如在 Update() 和LateUpdate())中调用函数。如果我们的脚本代码在此循环中处生成垃圾,那么它将迅速产生过量的垃圾。 这时候我们就应该考虑在可能的情况下,把需要分配内存的代码写在 Start() 或者 Awake() 中。
让我们来看一个非常简单的代码示例,它会在每一帧调用 ExampleGarbageGeneratingFunction 外部方法,去更改 transform 的 x 的位置,但是这样做不论 transform 的位置是否已发生变化(即使是静止状态),每一帧都会重新给 trasform 赋值:

void Update()
{
    ExampleGarbageGeneratingFunction(transform.position.x);
}

通过下面的简单修改,我们现在确保仅当 transform.position.x 的值更改时才调用分配函数。 现在,我们仅在必要时进行堆分配,而不是在每个帧中进行分配。

private float previousTransformPositionX;

void Update()
{
    float transformPositionX = transform.position.x;
    // 仅当 transformPositionX 变化的时候才会更改,减少了运行次数
    if (transformPositionX != previousTransformPositionX)
    {
        ExampleGarbageGeneratingFunction(transformPositionX);
        previousTransformPositionX = transformPositionX;
    }
}

减少 Update() 中生成的垃圾的另一种技术是使用计时器。 我们可以尝试,把一个函数定期运行(例如每1秒运行一次),这样会生成定期运行的垃圾,而不是在每一帧产生垃圾。
在以下示例代码中,生成垃圾的函数每帧运行一次:

void Update()
{
    ExampleGarbageGeneratingFunction();
}

在下面的代码中,我们使用计时器来确保生成垃圾的函数每秒运行一次。

private float timeSinceLastCalled;

private float delay = 1f;

void Update()
{
    timeSinceLastCalled += Time.deltaTime;
    // 每一秒运行一次,当该脚本频繁运行时,可以极大地减少产生的垃圾量
    if (timeSinceLastCalled > delay)
    {
        ExampleGarbageGeneratingFunction();
        timeSinceLastCalled = 0f;
    }
}

3.2.1.3 清空集合 (Clearing collections)

创建新集合会导致在堆上进行分配。 如果发现在代码中多次创建新的集合,则应缓存对该集合的引用,并使用 Clear() 清空其内容,而不是重复调用 new。
在以下示例中,每次使用 new 时都会进行新的堆分配。

void Update()
{
    List myList = new List();
    PopulateList(myList);
}

在以下示例中,仅当创建集合或必须在后台调整集合的大小时才进行分配。 这大大减少了产生的垃圾量。

private List myList = new List();

void Update()
{
    myList.Clear();
    PopulateList(myList);
}

3.2.1.4 对象池 (Object pooling)

即使我们减少了脚本代码中的内存分配,但是如果在运行时创建和销毁许多对象,我们仍然可能会遇到垃圾收集问题。 这就引入了另一个概念,对象池,对象池是一种可以通过重用对象而不是重复创建和销毁对象来减少分配和释放的技术。(好比从泳池里拿海洋球不断往天空上抛,但是池子里面的海洋球总量是固定的) 对象池在游戏中被广泛使用,最适合于我们频繁生成和销毁相似对象就是游戏中子弹。
关于对象池的使用指南不在本文讨论范围之内,但这是一种非常有用的技术,值得深入学习。 这里有一篇 Unity 教程,可以参考学习。

3.2.2 产生不必要的堆分配的常见原因

从前面的内容我们可以知道,值类型的局部变量分配在栈上,其他类型的变量都会分配在堆上。 但是,在某些情况下下,堆的分配很可能会让我们感到惊讶。 接下来让我们看一下产生这些不必要的堆分配一些常见原因,并考虑如何尽可能地减少这些分配。

3.2.2.1 字符串 String

在C#中,字符串是引用类型,而不是值类型,即使它们似乎保留字符串的“值”也是如此。 这意味着创建和丢弃字符串会创建垃圾。 由于许多代码中通常都使用字符串,因此这种垃圾确实会越来越多。
C#中的字符串也是不可变的(immutable),这意味着它们的值在首次创建后就无法更改。 每次我们操作一个字符串(例如,使用+运算符连接两个字符串)时,Unity 都会使用更新后的值创建一个新字符串,并丢弃旧字符串,这个操作这会也会产生垃圾。

我们可以遵循一些简单的规则将字符串中的垃圾最小化,接下来会列出几个示例。

  1. 尝试减少不必要的字符串创建次数。 如果我们多次使用同一字符串,则应在循环外创建一次字符串并缓存该值(单独创建一个变量,然后反复引用)。
  2. 尝试应该减少不必要的字符串操作。 例如,如果我们有一个 Text 组件,该组件经常更新并且包含一个串联字符串,我们可以考虑将其分为两个 Text 组件。
  3. 如果必须在运行时构建字符串,则应使用 StringBuilder 类。 StringBuilder类设计用于构建没有分配的字符串,并节省了我们在连接复杂字符串时产生的大量垃圾。
  4. 避免使用 Debug.Log(), 一旦调试目结束,我们应该立即删除它们。 即使没有输出任何内容,对 Debug.Log() 的调用仍会在我们游戏的所有版本中执行。 调用 Debug.Log() 会创建并处理至少一个字符串,因此,如果我们的游戏包含许多此类调用,则垃圾会累加起来。

接下来我们来看一个示例代码,该示例降低了字符串的使用,从而达到了降低垃圾的目的。 在下面的代码中,字符串 “ TIME” 与计时器组合在一起,在 Update() 中为得分显示创建了一个字符串,这会产生不必要的垃圾。

public Text timerText;
private float timer;

void Update()
{
    timer += Time.deltaTime;
    timerText.text = "TIME:" + timer.ToString();
}

下面是修改后的代码,在下面的示例中,我们已经进行了很多改进。 我们可以将“TIME” 放在单独的 Text 组件中,然后在 Start() 中进行设置。 这意味着在Update() 中,我们不再需要组合字符串,就这大大减少了垃圾量的生产。

public Text timerHeaderText;
public Text timerValueText;
private float timer;

// 把 Text 组件分成两部分,一部分显示固定的 “TIME”,另一部分更新数值
void Start()
{
    timerHeaderText.text = "TIME:";
}

void Update()
{
    timerValueText.text = timer.toString();
}

3.2.2.2 Unity 函数调用

我们需要注意的是,某些 Unity 函数会创建堆分配,从而产生垃圾。可惜并没有一个具体的列表来展示到底是哪些 Unity 内置函数会产生垃圾,

重要的是要意识到,无论何时调用自己未编写的代码,无论是在Unity本身还是在插件中,我们都可能生成垃圾。一些Unity函数调用会创建堆分配,因此应谨慎使用,以避免产生不必要的垃圾,这里我们只能采用具体问题具体分析的办法了。


没有应避免的功能列表。每个功能在某些情况下可能有用,而在其他情况下则没有那么有用。与以往一样,最好仔细分析我们的游戏,确定在何处创建垃圾并仔细考虑如何处理。话虽如此,让我们再看一些 Unity 函数的常见示例,并考虑如何最好地处理它们。
Unity 访问返回数组时,都会创建一个新数组并将其作为返回值传递给我们。这种行为并不总是显而易见的或无法预期的,尤其是当该函数是访问器时(例如Mesh.normals。

在下面的代码中,循环的每次 for 迭代都会创建一个新的数组(由于 myMesh.normals),我们应该尽量避免这种情况的发生。

void ExampleFunction()
{
    for (int i = 0; i < myMesh.normals.Length; i++)
    {
        Vector3 normal = myMesh.normals[i];
    }
}

4. Unity游戏中的垃圾收集

对上一节的最后一部分代码优化十分容易,我们仅仅需要把 Mesh.normals 设置在 for 循环运行之前调用即可。

void ExampleFunction()
{
    Vector3[] meshNormals = myMesh.normals;
    for (int i = 0; i < meshNormals.Length; i++)
    {
        Vector3 normal = meshNormals[i];
    }
}

另外需要注意的一点就是,GameObject.name 或 GameObject.tag 都会返回新字符串的访问器,这意味着调用这些函数将产生垃圾。 在这种情况下,如果要进行比对,我们可以使用 GameObject.CompareTag()来减少垃性能损耗。

private string playerTag = "Player";

void OnTriggerEnter(Collider other)
{
    // 尽量不要使用 “==”,而是使用 CompareTag,该示例是错误的!
    bool isPlayer = other.gameObject.tag == playerTag;
}

下面这个示例是正确的用法,用来比较 GameObject 的 Tag 或者 Name

private string playerTag = "Player";

void OnTriggerEnter(Collider other)
{
   // 不实用 “==” 而是使用 CompareTag 来比较
    bool isPlayer = other.gameObject.CompareTag(playerTag);
}

其它类似的 Unity 函数,也具有类似 CompareTag 不产生堆垃圾的功能。例如 Input.GetTouch()Input.touchCount(), 都可以用来 替代 Input.touches()。另一个就是可以使用 Physics.SphereCastNonAlloc()替代 Physics.SphereCastAll().

4.1 装箱(Boxing)

装箱 是指使用值类型的变量代替引用类型的变量时发生的情况。 当我们将值类型变量(例如 ints 或 float)传递给具有对象参数(例如 Object.Equals())的函数时,通常会发生装箱操作。
例如,函数String.Format()需要一个字符串和一个对象参数。 当我们传递一个string 和一个 int 时,必须将 int 装箱。 因此,以下代码包含装箱示例:

void ExampleFunction()
{
    int cost = 5;
    string displayString = String.Format("Price: {0} gold", cost);
}

将值类型的变量装箱后,Unity 会在堆上创建一个临时 System.Object 来包装值类型的变量。 System.Object 是一个引用类型的变量,因此,在处理此临时对象时会创建垃圾。
装箱是造成不必要的堆分配的一个常见原因。 即使我们没有在代码中直接对变量进行装箱,也可能会使用导致装箱的插件,或者它可能发生在其他功能的幕后。 最佳做法是尽可能避免装箱并删除导致装箱的任何函数调用。

4.2 协程(Coroutines)

调用 StartCoroutine() 会产生少量垃圾,因为 Unity 必须创建其类来管理协程的类。 考虑到这一点,当我们的游戏是交互式的并且性能是一个问题时,应该限制对 StartCoroutine() 的调用。 为了减少以这种方式创建的垃圾,必须提前在关键帧之前运行的协程。
虽然协程中的 yield 语句本身不会创建堆分配, 但是我们在yield语句中传递的值可能会创建不必要的堆分配。 例如,下面的案例是一个 错误示范

yield return 0;

这段代码会产生垃圾,因为装箱的值是一个为 0 的 int。 在这种情况下,如果我们只想等待一帧而不会引起任何堆分配,那么最好的返回一个 null 而不是 int。

yield return null;

使用协程的另一个常见错误是在多次使用 new。 例如,以下代码将在每次循环迭代时创建 WaitForSeconds 对象:

while (!isComplete)
{
    yield return new WaitForSeconds(1f);
}

如果我们缓存并重用 WaitForSeconds 对象,则将创建更少的垃圾。 下面的代码为正确示例:

WaitForSeconds delay = new WaitForSeconds(1f);

while (!isComplete)
{
    yield return delay;
}

如果我们的脚本代码由于协程而产生大量垃圾,不妨考虑将代码重构为使用协程以外的东西。 这里有一些协程的常见替代方法。 例如,如果我们主要使用协程来管理时间,则我们可能希望在 Update() 函数中简单地跟踪时间。 如果我们主要使用协程来控制游戏中事物发生的顺序,我们可能希望创建某种消息传递系统以允许对象进行通信。

4.3 Foreach 循环

在 Unity 5.5版本之前,每次foreach循环都会生成垃圾, 这是由于幕后运行的装箱(Boxing)造成的。 循环开始时会在堆上分配一个 System.Object,在循环结束时会将其丢弃。 。
例如,在 5.5 之前版本中,以下代会在循环中生成垃圾:

void ExampleFunction(List listOfInts)
{
    foreach (int currentInt in listOfInts)
    {
            DoSomething(currentInt);
    }
}

在 Unity 2019.3 以上的版本中,foreach 循环就可以放心使用。但是如果您无法升级到该版本,可以使用 for 和 while 方法,该方法不会在后台造成装箱,因此不会产生任何垃圾。

void ExampleFunction(List listOfInts)
{
    for (int i = 0; i < listOfInts.Count; i ++)
    {
        int currentInt = listOfInts[i];
        DoSomething(currentInt);
    }
}

4.4 引用方法 (Function references)

在 Unity 当中,无论匿名方法还是命名方法,对该方法的引用都是引用类型的变量(reference-typed variables),它们都会进行堆分配。 如果将匿名函数转换为闭包,同样会大大增加内存使用量和堆分配数量,如果考虑到垃圾收集,那么最好在游戏过程中尽量减少使用函数引用和闭包,更详细的介绍可以参考 这篇文章

4.5 LINQ 和正则表达式

LINQ 和正则表达式都会在后台发生装箱而生成垃圾,同样的 这篇文章 详细介绍了优化的具体方法,非常值得一看。

4.6 优化化代码来减少运行垃圾收集带来得影响

即使我们的代码没有主动创建堆分配,它也可能会在后台增加垃圾收集器的工作量。因此,减少垃圾收集器工作量的一种方法是,让它只去检查需要检查的必要内容,避免不必要的操作。 Struct 是值类型的变量,但是如果我们使用了一个包含引用类型的变量,则垃圾收集器必须检查整个 Struct。
在下面的示例中,该 Struct 包含一个字符串,该字符串是引用类型的。 运行该代码会导致垃圾收集器在运行时必须检查整个 struct。

public struct ItemData
{
    // 引用了 string,因此垃圾收集器必须检查整个 struct
    public string name;
    public int cost;
    public Vector3 position;
}
private ItemData[] itemData;

在接下来的示例中,我们将数据存储在单独的数组中。 当垃圾收集器运行时,它只需要检查字符串数组即可忽略其他数组。 这减少了垃圾收集器必须执行的工作。

private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;

增加垃圾收集器工作量的另一种常见方法是使用了对象引用。 当垃圾收集器搜索堆上对象的引用时,它必须检查当前对象的每一个引用。 即使我们不减少堆中的对象总数,减少代码中对象引用的也会减少垃圾收集器的工作。
在下面代码中,我们有一个 DialoagData 类。 在这个类里面的 nextDialog 同样会让垃圾收集器检查此引用。

public class DialogData
{
    // 这里垃圾收集器同样会检测这个引用
    private DialogData nextDialog;

    public DialogData GetNextDialog()
    {
        return nextDialog;
    }
}

我们可以稍微简单地修改下代码,让其返回一个 Int 类型的 ID 标示来替代该引用。

public class DialogData
{
    private int nextDialogID;

    public int GetNextDialogID()
    {
        return nextDialogID;
    }
}

5. 定时垃圾收集 (Timing garbage collection)

5.1 手动运行垃圾收集器

如果我们知道堆所占用的内存不必再使用,我们可以在系统资源使用不紧张的情况下手动启用垃圾收集,下面的指令将会运行垃圾收集器,并在方便的时候释放未使用的内存。

System.GC.Collect();

未完待续。。。。