基于2019.3版本解决性能问题(Part 2 优化垃圾回收)

本文详细介绍了Unity中的垃圾回收机制,包括其工作原理、触发条件和对游戏性能的影响。通过分析内存管理和堆分配,提出了减少垃圾回收频率和影响的策略,如优化代码结构、减少不必要的分配、缓存对象和使用对象池。文章还提醒开发者注意字符串操作、装箱、协程和循环中的内存分配问题,以及如何控制垃圾回收的时机,以提升游戏性能。
摘要由CSDN通过智能技术生成

unity官方文档翻译 + 笔记 |基于2019.3版本解决性能问题(Part 2 优化垃圾回收)

本文翻自Unity官网:https://learn.unity.com/tutorial/fixing-performance-problems-2019-3-1#5e85ad9dedbc2a0021cb81aa

当我们的游戏运行时,它使用内存来存储数据。当这些数据不再需要时,存储该数据的内存被释放,以便可以重新使用。垃圾是指已被设置用于存储数据但不再使用的内存。垃圾回收是使该内存再次可用以供重用的过程的名称。
Unity在管理内存时使用垃圾回收。如果垃圾回收发生太频繁或需要执行的工作过多,我们的游戏可能会表现不佳,这意味着垃圾回收是性能问题的常见原因。
在本文中,我们将学习垃圾回收的工作原理,垃圾回收发生的时机以及如何有效使用内存,以减少垃圾回收对我们的游戏的影响。

诊断垃圾回收问题

由垃圾回收引起的性能问题可能表现为帧率低、性能卡顿或间歇性卡死。然而,其他问题也可能导致类似的症状。如果我们的游戏存在此类性能问题,我们应该首先使用Unity的Profiler窗口来确定我们所看到的问题是否确实是由垃圾回收引起的。
要了解如何使用Profiler窗口找出性能问题的原因,请按照本教程进行操作。

Unity中的内存管理简介

为了理解垃圾回收的工作原理以及何时发生,我们首先必须了解Unity中内存使用的工作方式。首先,我们必须明白Unity在运行其自身核心引擎代码和运行我们在脚本中编写的代码时采用了不同的方法。

当运行自身核心Unity引擎代码时,Unity使用的内存管理方式称为手动内存管理。这意味着核心引擎代码必须明确指明如何使用内存。手动内存管理不使用垃圾回收,本文不会过多讨论。

当运行我们的代码时,Unity采用的内存管理方式称为自动内存管理。这意味着我们的代码不需要以详细的方式显式告诉Unity如何管理内存,Unity会为我们处理这一切。

在Unity中,自动内存管理的基本工作方式如下:

  • Unity可以访问两个内存池:栈(stack)和堆(heap)(也称为托管堆)。栈用于短期存储小型数据片段,而堆用于长期存储和较大的数据片段。
  • 当创建一个变量时,Unity会从栈或堆中请求一块内存空间。
  • 只要变量在作用域内(仍然可以被我们的代码访问),分配给它的内存就会保持使用。我们称此内存已被分配。我们将保存在栈内存中的变量描述为栈上的对象,将保存在堆内存中的变量描述为堆上的对象。
  • 当变量超出作用域时,该内存不再需要,可以返回到它所属的内存池中。当内存返回到其池中时,我们称该内存已被释放。栈内存的内存在其所引用的变量超出作用域时立即被释放。然而,堆内存在此时不会被释放,并且仍处于分配状态,即使它所引用的变量超出了作用域。
  • 垃圾回收器会识别并释放未使用的堆内存。垃圾回收器定期运行以清理堆内存。

现在我们了解了事件流程,让我们更详细地了解一下与堆分配和释放不同的栈分配和释放。

在栈的分配和释放过程中会发生什么?

栈上的内存分配和释放非常快速简单。这是因为栈只用于短时间存储小型数据。分配和释放始终按照可预测的顺序进行,并且具有可预测的大小。

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

在堆上进行内存分配时会发生什么?

堆上的内存分配比栈上的分配复杂得多。这是因为堆可以用于存储长期和短期数据,以及各种不同类型和大小的数据。分配和释放的顺序不总是可预测的,可能需要非常不同大小的内存块。

当创建堆上的变量时,会执行以下步骤:

  • Unity必须检查堆中是否有足够的空闲内存。如果堆中有足够的空闲内存,就会分配变量的内存。
  • 如果堆中没有足够的空闲内存,Unity会触发垃圾回收器,尝试释放未使用的堆内存。这可能是一个缓慢的操作。如果在垃圾回收后堆中有足够的空闲内存,就会分配变量的内存。
  • 如果在垃圾回收后堆中仍然没有足够的空闲内存,Unity会增加堆中的内存量。这可能是一个缓慢的操作。然后会分配变量的内存。

堆分配可能会很慢,尤其是如果需要运行垃圾回收器和扩展堆的情况下。

在垃圾回收过程中会发生什么?

当堆变量超出作用域时,用于存储它的内存不会立即被释放。未使用的堆内存只有在垃圾回收器运行时才会被释放。

每当垃圾回收器运行时,会执行以下步骤:

  • 垃圾回收器会检查堆上的每个对象。
  • 垃圾回收器搜索所有当前对象引用,以确定堆上的对象是否仍然在作用域内。
  • 任何不再在作用域内的对象都被标记为待删除。
  • 被标记的对象会被删除,并且分配给它们的内存会返回到堆中。

垃圾回收可能是一个昂贵的操作。堆上的对象越多,垃圾回收器需要做的工作就越多;我们代码中的对象引用越多,垃圾回收器需要做的工作也就越多。

垃圾回收何时发生?

有三种情况会触发垃圾回收器的运行:

  • 当请求堆分配时,无法使用堆中的空闲内存满足要求时,垃圾回收器会运行。
  • 垃圾回收器会定期自动运行(尽管频率因平台而异)。
  • 可以手动强制运行垃圾回收器。

垃圾回收可能是频繁的操作。当堆分配无法从可用的堆内存中满足时,垃圾回收器就会被触发,这意味着频繁的堆分配和释放可能导致频繁的垃圾回收。

垃圾回收的问题

既然我们了解了在Unity中垃圾回收在内存管理中的作用,我们可以考虑可能出现的问题类型。
最明显的问题是,垃圾回收可能需要很长时间才能运行。如果堆上有大量对象和/或需要检查的对象引用,检查所有这些对象的过程可能会很慢。这可能导致游戏出现卡顿或运行缓慢的情况。
另一个问题是垃圾回收可能在不方便的时间运行。如果CPU已经在游戏中的性能关键部分工作得很辛苦,即使是来自垃圾回收的少量额外开销也会导致帧率下降,性能明显变化。
另一个不太明显的问题是堆碎片化。当从堆中分配内存时,它是从不同大小的块中的空闲空间中取出的,具体取决于需要存储的数据大小。当这些内存块返回到堆中时,堆可能被划分为许多小的空闲块,这些块被已分配的块分隔开。这意味着虽然总的空闲内存量可能很高,但我们无法分配大的内存块,除非运行垃圾回收器和/或扩展堆,因为现有的块都不够大。
堆碎片化有两个后果。第一个是我们游戏的内存使用量将高于实际需要的水平,第二个是垃圾回收器将更频繁地运行。有关堆碎片化的更详细讨论,请参阅Unity关于性能的最佳实践指南

查找堆分配

如果我们知道垃圾回收在游戏中引起了问题,我们需要知道哪些代码部分正在产生垃圾。垃圾是在堆变量超出作用域时产生的,因此首先我们需要知道是什么导致变量在堆上分配。

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

在Unity中,值类型的局部变量分配在堆栈上,而其他内容则分配在堆上。以下代码是堆栈分配的示例,因为变量localInt既是局部的又是值类型的。为该变量分配的内存将在此函数运行结束后立即从堆栈上被释放。

void ExampleFunction()
{
    int localInt = 5;
}

以下代码是堆分配的示例,因为变量localList是局部的但是引用类型的。为该变量分配的内存将在垃圾回收器运行时被释放。

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

使用Profiler窗口来查找堆分配

我们可以通过Profiler窗口查看我们的代码在哪里创建了堆分配。您可以通过转到"Window > Analysis > Profiler"来访问该窗口。

img

选择CPU使用率分析器后,我们可以选择任何一帧,在Profiler窗口的底部查看该帧的CPU使用率数据。其中的一列数据称为GC分配(GC allocation)。该列显示了在该帧中进行的堆分配。如果我们选择该列的标题,我们可以按照此统计数据对数据进行排序,从而轻松地看到游戏中哪些函数引起了最多的堆分配。一旦我们知道哪个函数引起了堆分配,我们可以检查该函数。
一旦我们知道函数内的哪段代码引起了垃圾生成,我们可以决定如何解决这个问题并最小化生成的垃圾量。

减少垃圾回收的影响

从总体上来说,我们可以通过以下三种方式来减少垃圾回收对游戏的影响:

  • 减少垃圾回收的运行时间
  • 减少垃圾回收的频率
  • 有意触发垃圾回收,使其在不影响性能的时间运行。例如,在加载屏幕期间触发垃圾回收可以避免在关键时刻出现性能下降的问题。

考虑到这一点,这里有三种策略可以帮助我们解决这个问题:

  • 优化游戏结构,减少堆分配和对象引用。堆上的对象越少,需要检查的引用越少,这意味着当触发垃圾回收时,它所需的时间更短。
  • 减少堆分配和回收的频率,特别是在性能关键时刻。减少分配和回收的次数意味着减少了触发垃圾回收的机会。这也减少了堆碎片化的风险。
  • 尽量控制垃圾回收和堆扩展的时机,使其在可预测且方便的时间发生。这是一种更具挑战性和不太可靠的方法,但当作为整体内存管理策略的一部分使用时,可以减少垃圾回收的影响。

减少垃圾生成量的方法

一些能帮助我们减少代码生成的垃圾量的技巧:

缓存(Caching)

如果我们的代码反复调用导致堆分配的函数,然后丢弃结果,这会产生不必要的垃圾。相反,我们应该存储对这些对象的引用并重复使用它们。这种技术被称为缓存。
在下面的示例中,每次调用代码时都会导致堆分配。这是因为每次都创建了一个新的数组。

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。
在以下示例中,每次使用new都会导致新的堆分配。

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

在以下示例中,只有在创建集合或在幕后需要调整集合大小时才会发生分配。这大大减少了生成的垃圾数量。

private List myList = new List();

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

对象池

即使我们在脚本中减少了分配,如果在运行时创建和销毁大量对象,仍可能存在垃圾收集问题。对象池是一种技术,通过重用对象而不是重复创建和销毁它们,可以减少分配和释放。对象池在游戏中被广泛使用,特别适用于频繁生成和销毁类似对象的情况,例如从枪中射出子弹。

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

非必要的堆分配的常见原因

我们知道,本地值类型变量在堆栈上分配,而其他所有内容都在堆上分配。然而,有很多情况下,堆分配可能会让我们感到意外。让我们看看一些造成不必要堆分配的常见原因,并考虑如何最好地减少这些分配。

Strings

在C#中,字符串是引用类型而不是值类型,尽管它们似乎保存了字符串的"值"。这意味着创建和丢弃字符串会产生垃圾。由于字符串在许多代码中常被使用,这些垃圾可能会累积起来。
在C#中,字符串也是不可变的,这意味着它们在首次创建后无法更改其值。每当操作一个字符串(例如,通过使用+运算符连接两个字符串),Unity会创建一个新的带有更新值的字符串,并丢弃旧的字符串。这会产生垃圾。
我们可以遵循一些简单的规则,将字符串产生的垃圾最小化。让我们考虑这些规则,然后看一个应用它们的示例。

  • 减少不必要的字符串创建。如果我们多次使用相同的字符串值,应该只创建一次字符串并缓存该值。
  • 减少不必要的字符串操作。例如,如有一个经常更新并包含连接字符串的Text组件,可以考虑将其拆分为两个Text组件。
  • 如需要在运行时构建字符串,应该使用StringBuilder类。StringBuilder类专门用于构建字符串而不进行分配,可以减少在连接复杂字符串时产生的垃圾量。
  • 在不再需要进行调试的情况下立即删除对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;

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()。

Boxing(装箱)

装箱(Boxing)是指当值类型变量被用作引用类型变量时发生的情况。装箱通常发生在我们将值类型变量(例如int或float)传递给带有对象参数(如Object.Equals())的函数时。
例如,函数String.Format()接受一个字符串和一个对象参数。当我们向它传递一个字符串和一个整数时,整数必须进行装箱。因此,以下代码包含了一个装箱的示例:

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

装箱(Boxing)会导致垃圾产生,原因是背后所发生的情况。当值类型变量被装箱时,Unity会在堆上创建一个临时的System.Object对象来包装值类型变量。System.Object是一个引用类型变量,因此当临时对象被销毁时会产生垃圾。

装箱是造成不必要堆内存分配的极为常见的原因。即使我们在代码中没有直接进行装箱,但我们可能使用的插件会引起装箱,或者它可能在其他函数的背后发生。在可能的情况下,最好避免装箱,并消除导致装箱的任何函数调用。

当在C#中将值类型(如int、float、struct等)转换为对象类型(如object)时,就会发生装箱。装箱是通过将值类型封装在一个堆分配的对象中来实现的,以便在需要引用类型的上下文中使用。

装箱的主要概念是将值类型的数据封装在堆上分配的引用类型对象中。封装后,该值类型将被视为引用类型,可以按引用方式传递、存储在集合类中(如List)或用作泛型类型参数。装箱会带来一些性能开销,因为它涉及堆内存的分配和对象的创建。

装箱通常在以下情况下发生:

  1. 当值类型被赋值给一个对象类型的变量时。
  2. 当值类型作为实参传递给接受对象类型参数的方法时。
  3. 当值类型存储在集合类(如ArrayList)中或通过接口(如IList)引用时。

然而,装箱也可能带来一些性能损失和内存开销。装箱后的对象需要进行堆分配,这会增加垃圾回收的工作量。此外,由于装箱引入了类型转换,因此在拆箱(将装箱的对象重新转换为值类型)时,还会产生一些性能开销。

在一般的使用场景中,应尽量避免不必要的装箱操作,以提高代码的性能和效率。可以通过以下方式来避免或减少装箱的发生:

  • 使用泛型集合(如List)而不是非泛型集合(如ArrayList)来存储值类型数据。
  • 避免在值类型和引用类型之间进行不必要的转换。
  • 使用值类型参数的方法而不是对象类型参数的方法,除非有必要。
  • 使用value关键字来引用值类型,以避免不必要的装箱和拆箱操作。

总之,装箱在某些情况下是必要的,但在高性能的应用程序中,应当注意避免不必要的装箱操作,以提高代码的性能和效率。

Coroutines(协程)

调用StartCoroutine()会产生少量的垃圾,因为Unity必须创建实例来管理协程。考虑到这一点,在游戏交互和性能是关注点的情况下,应限制对StartCoroutine()的调用。为了减少这种方式创建的垃圾,任何在性能关键时刻必须运行的协程都应提前启动,而且在使用可能包含对StartCoroutine()的延迟调用的嵌套协程时要特别小心。

协程中的yield语句本身不会创建堆内存分配;然而,我们使用yield语句传递的值可能会创建不必要的堆内存分配。例如,以下代码会创建垃圾:

yield return 0;

这段代码会创建垃圾,因为值为0的int会被装箱。在这种情况下,如果我们只是想等待一帧而不产生任何堆内存分配,最好的方法是使用以下代码:

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 loops(Foreach循环)

在Unity 5.5之前的版本中,除了数组以外的任何类型的foreach循环,在每次循环结束时都会产生垃圾。这是由于在幕后发生的装箱操作所导致的。当循环开始时,会在堆上分配一个System.Object对象,并在循环结束时进行销毁。这个问题在Unity 5.5中得到了修复。

例如,在Unity 5.5之前的版本中,以下代码中的循环会产生垃圾:

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

只要您使用的是Unity 2019.3版本,就没有问题。但如果我们无法升级Unity版本,这个问题有一个简单的解决方案。for循环和while循环在幕后不会发生装箱操作,因此不会产生任何垃圾。在迭代非数组的集合时,我们应该优先使用它们。

以下代码中的循环不会产生垃圾:

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

Function references(函数引用)

函数引用是指对函数的引用,无论是匿名方法还是具名方法,在Unity中都是引用类型的变量。它们会导致堆内存分配。将匿名方法转换为闭包(即匿名方法能够访问其创建时所在作用域中的变量)会显著增加内存使用量和堆内存分配次数。

函数引用和闭包在内存分配方面的具体细节因平台和编译器设置而异,但如果垃圾回收是一个问题,最好在游戏过程中尽量减少使用函数引用和闭包。Unity关于性能的最佳实践指南会对这个主题提供更详细的技术细节。

LINQ and Regular Expressions

无论是LINQ还是正则表达式,在幕后都会产生装箱(boxing)而生成垃圾。在性能至关重要的情况下,最好避免使用它们。关于这个主题,Unity的最佳实践指南提供了更多的技术细节。

结构化代码以最小化垃圾回收的影响

代码结构可以影响垃圾回收的工作。即使我们的代码不会创建堆分配,它仍可能增加垃圾回收器的工作量。

代码可能不必要地增加垃圾回收器的工作量的一种方式是要求它检查本不应该检查的内容。结构体是值类型变量,但如果我们有一个包含引用类型变量的结构体,那么垃圾回收器必须检查整个结构体。如果我们有一个大型结构体数组,那么这会给垃圾回收器带来很多额外的工作量。

在这个例子中,结构体包含一个字符串,而字符串是引用类型。当垃圾回收器运行时,必须检查整个结构体数组。

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的实例,而不是实例本身。这不是一个对象引用,因此不会增加垃圾回收器的执行时间。

public class DialogData
{
    private int nextDialogID;

    public int GetNextDialogID()
    {
        return nextDialogID;
    }
}

单独看这个例子可能相对简单。然而,如果游戏包含许多持有对其他对象的引用的对象,通过以这种方式重构代码,可以大大降低堆的复杂性。

控制垃圾回收的时机

手动强制进行垃圾回收

最后,如需自己触发垃圾回收。如果我们知道堆内存已经被分配但不再使用(例如,当我们的代码在加载资产时生成了垃圾),同时垃圾回收的冻结不会影响玩家(如在加载屏幕仍然显示时),我们可以使用以下代码请求垃圾回收:

System.GC.Collect();

这将强制垃圾回收器运行,在合适时机释放未使用的内存。
了解了Unity中垃圾回收的工作原理,以及为什么它会导致性能问题,以及如何最小化对游戏性能的影响。利用这些知识和性能分析工具,可以解决与垃圾回收相关的性能问题,并优化游戏结构,以实现高效的内存管理。


本博客中反复提到了This Unity best practice guide on performance ,遂一并记录

托管内存

Unity的托管内存系统是基于Mono或IL2CPP虚拟机(VMs)的C#脚本环境。托管内存系统的好处是它管理内存的释放,因此您无需通过代码手动请求释放内存。

Unity的托管内存系统使用垃圾回收器和托管堆,在您的脚本不再持有对这些内存分配的引用时自动释放内存。这有助于防止内存泄漏。内存泄漏发生在内存被分配后,对其的引用丢失,然后由于需要引用才能释放它,所以内存永远不会被释放。

这个内存管理系统还保护内存访问,这意味着您不能访问已被释放的内存,或者您的代码无权访问的内存。然而,这个内存管理过程会对运行时性能产生影响,因为分配托管内存对CPU来说是耗时的。垃圾回收也可能会使CPU停止其他工作,直到完成回收过程。

值类型和引用类型

当调用一个方法时,脚本后端会将其参数的值复制到为该特定调用保留的内存区域中,这个数据结构称为调用栈。脚本后端可以快速复制占用几个字节的数据类型。然而,对象、字符串和数组通常会更大,脚本后端定期复制这些类型的数据是低效的。

在托管代码中,所有非空引用类型对象和所有装箱的值类型对象都必须分配在托管堆上。

熟悉值类型和引用类型非常重要,这样你才能有效地管理你的代码。有关更多信息,请参阅Microsoft关于值类型和引用类型的文档。

自动内存管理

当一个对象被创建时,Unity会从一个称为堆(heap)的中央池中分配所需的内存空间,堆是Unity项目选择的脚本运行时(Mono或IL2CPP)自动管理的一段内存。当一个对象不再被使用时,它曾经占用的内存空间可以被回收并用于其他用途。

Unity的脚本后端使用垃圾回收器来自动管理应用程序的内存,因此您不需要通过显式的方法调用来分配和释放这些内存块。自动内存管理比显式的分配和释放需要更少的编码工作,并减少了内存泄漏的可能性。

托管堆(Managed Heap)概述

托管堆是Unity项目中所选的脚本运行时(Mono或IL2CPP)自动管理的一块内存区域。

img

一定数量的内存。图表上标记为A的是一些可用内存

在上面的图表中,蓝色方框代表Unity分配给托管堆的一定数量的内存。其中的白色方框表示Unity在托管堆的内存空间中存储的数据值。当需要额外的数据值时,Unity从托管堆的空闲空间(标记为A)中分配它们。

内存碎片化和堆扩展

img

一定数量的内存,其中一些已释放的对象用灰色虚线表示

上面的图表显示了内存碎片化的一个示例。当Unity释放一个对象时,该对象占用的内存被释放。然而,释放的空间并不成为一个单一的大型“可用内存”池的一部分。

释放对象的两侧可能仍在使用中。因此,释放的空间是其他内存段之间的“间隙”。Unity只能利用这个间隙来存储与释放对象相同或更小的数据。

这种情况被称为内存碎片化。当堆中有大量可用内存时,但这些内存仅存在于对象之间的“间隙”中时,就会发生这种情况。这意味着尽管总共有足够的空间进行大型内存分配,但托管堆无法找到足够大的连续内存块来分配给该分配项。

img

标记为A的对象是需要添加到堆中的新对象。标记为B的项目是已释放对象占用的内存空间,以及未被保留的空闲内存。尽管总的空闲空间足够,但由于没有足够的连续空间,标记为A的新对象的内存无法适应堆中,因此必须运行垃圾回收器。

如果分配了一个大型对象,并且没有足够的连续空闲空间来容纳它,如上所示,Unity内存管理器会执行两个操作:

  • 首先,如果垃圾回收器还没有运行过,它会被执行。这个过程试图释放足够的空间来满足分配请求。
  • 如果在垃圾回收器运行之后,仍然没有足够的连续空间来容纳所请求的内存量,那么堆必须扩展。堆扩展的具体量取决于平台;然而,在大多数平台上,当堆扩展时,它会扩展为上一次扩展量的两倍。
托管堆扩展的注意事项

堆的意外扩展可能会带来问题。Unity的垃圾回收策略往往更容易导致内存碎片化。您应该注意以下事项:

  • Unity在定期扩展堆时不会释放分配给托管堆的内存;相反,它保留扩展后的堆,即使其中的大部分都是空的。这是为了防止在出现进一步的大型分配时需要重新扩展堆的情况。
  • 在大多数平台上,Unity最终会将托管堆中空闲部分使用的内存释放回操作系统。释放的时间间隔没有保证,并且是不可靠的。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值