Unity游戏中GC的优化

主要内容翻译自这篇文章,另外会加入自己的说明和理解。

介绍

当我们的游戏运行时,使用内存来存储数据。当数据不再使用,我们需要将内存释放以便能够重新被使用。那些存在于内存中却不再被使用的数据被称为垃圾(garbage),垃圾回收(garbage collection)就是让内存空间能够重新被使用的过程。

GC是Unity内存管理的一个部分。垃圾回收过于频繁或者负担过重会让影响游戏的运行,垃圾回收也是造成性能问题的一个常见原因。

 诊断

由GC引起的性能问题表现为低帧数或者卡顿。很多问题都会有类似的迹象,当出现类似问题时,应该先使用Unity's Profiler来确认问题是否是由GC引起的。

Unity内存管理的简单介绍

在理解GC如何工作以及它何时发生之前,需要了解内存在unity中是如何被使用的。首先要明确的是,Unity在引擎内核和脚本上使用不同的方法。

对于Unity的内核引擎,它使用的是手动的内存管理 (底层是C++的所以这里需要手动分配释放内存),所有的内存必须被精确的分配和释放,它不需要GC。

在脚本端,Unity会为我们自动管理内存。不需要代码明确的告诉Unity内存的管理,Unity会自动帮我们完成这个工作。 

简单的说来,Unity自动内存管理的工作方式如下:

  • Unity有两块内存区域堆和栈。栈用于短时间内存储小块的数据,而堆被用于长时间的存储大块的数据。
  • 当一个值被创建,Unity从堆或者栈中申请一块内存。
  • 只要值仍被代码使用(in scope),那么分配给它的内存就会一直存在。这块内存被称作已分配。
  • 当值不再被使用时, 内存空间不再被需要,可以被返还给内存池。内存被返回给内存池的过程,称为内存释放。栈上的内存一旦不被使用就会立刻被释放,而堆上的内存则会持续存在,即使它不再被使用。
  • 垃圾收集器(garbage collector)判断并释放不被使用的堆内存。垃圾回收会周期性的运行以对堆进行清理。

栈内存的分配和释放

栈内存的分配和释放都是快速且简单的。栈只用于短时间存储小块的数据,分配和释放的顺序和空间大小都是已知的。栈内存的分配和释放总发生于栈顶,分配时从栈顶获取需要的内存空间,释放之后再重新使用。

堆内存的分配和释放

 堆上的内存分配要复杂得多。 堆既可以短时间存储也可以长时间存储,数据的类型和大小也各有不同。分配和释放的顺序是不定的,且分配的内存空间的大小也是不固定的。

当一个堆变量被创建时:
  1. Unity检查堆上是否有足够的空间。如果有则分配内存。
  2. 如果堆上没有足够的内存空间,Unity会触发垃圾回收,释放不再被使用的堆内存。这个操作可能会十分缓慢,GC结束时如果堆上有足够的内存空间,那么会为变量分配内存。
  3. 如果GC之后堆上仍没有足够的内存空间,Unity会尝试扩大堆内存的空间。这个操作可能是十分缓慢的,完成后为值分配内存空间。
堆内存的分配可能会是非常缓慢的,特别是当需要垃圾回收和扩充堆内存时。


垃圾回收

当值不再被引用时,垃圾回收不会立即运行。不被使用的堆内存只有在垃圾回收运行时才会被释放。
每次垃圾回收时
  1. 检查堆上的所有object。
  2. 搜索所有对当前object的引用来判断这个object是否仍然被使用
  3. 任何不被引用的object会被标记为可删除的
  4. 删除被标记的object,它们占用的内存被重新归还到堆中。
垃圾回收可能是非常昂贵的操作,堆上的object越多,工作就越繁重。
 

垃圾回收何时运行

  1. 当一个堆上剩余的内存空间无法满足内存分配请求时。
  2. GC会时不时的自动运行。
  3. 可以手动运行GC。

内存请求无法被满足时GC会运行,这意味着GC运行的频率和堆内存分配释放的频率有关。

 垃圾回收的相关问题

现在我们对GC在Unity内存管理中所扮演的角色有了一定的了解,可以开始思考可能发生问题的地方。

首先最明显的问题是GC可能会花费过多的时间。如果有大量的在堆上有大量的object或者object间有大量的引用关系需要去判定,那么GC检查所有的object的过程将会变得缓慢。

另一个问题是GC可能会发生在不恰当的时机。如果你的游戏本身正在执行一个非常消耗性能的操作,那么此时如果发生GC显然会引起掉帧。

另一个容易忽视的问题是堆内存碎片。堆上的内存空间,根据数据所需要尺寸来分配,当一部分内存块被返还给堆,由于连续内存空间上还有一部分内存仍然被使用,这些仍在使用的内存会把连续的内存空间分成小块。这意味着虽然整体的内存数量很高,但如果我们不运行GC或者扩展堆空间,我们将无法分配大的内存块,因为没有足够大小的内存块。

内存碎片会产生两个后果,其一是游戏的内存使用会比实际它所需要的要高,另一个是GC会更加频繁的运行。更多的细节可以看Unity的相关文档。

找出使用堆内存的地方

如果我们知道GC在游戏中产生了问题,我们需要知道哪部分的代码产生了这样子的问题。堆变量在不再被引用时产生垃圾,所以我们首先要了解怎样的值会被分配在堆上。

Unity中,局部的值类型位于栈上,而其他的则位于堆上。

当然也可以使用Profiler来找出堆内存分配的地方。

减少垃圾回收带来的影响

一般说来有三种方法可以减少GC带来的影响:

  • 减少GC所需要的时间
  • 减少GC的频率
  • 在合适的时机手动的触发GC

对应的有三个主要的策略:

  • 组织游戏代码,使用更少的堆分配和更少的object的引用。更少的堆object和更少的引用判定意味着GC运行时间的减少。
  • 减少堆内存分配和释放的频率,特别是在一些性能的关键时刻。更少的分配和释放次数意味着触发GC 的可能减少,同时也有助于减少内存碎片。
  • 尝试手动控制GC和堆内存扩展,从而让他们发生在合适的可预测的时机。这是一个更加困难且更不可靠的方法,但是作为一个总体的内存管理机制,可以减少GC带来的问题。 

减少垃圾的常用手段

  • Caching缓存
  • 在经常运行的函数中不要分配堆内存或者减少分配的次数。
  •  对重复使用的collection,使用clear清空它而不是重新新建一个。
  • 使用object pooling对象池
 

不必要的堆内存的产生原因

 1. String

C#中的string也是不可变的,它一旦被创建,值就无法被改变。每次对字符串的操作都会创建新的字符串来更新值并丢弃旧的字符串值,从而产生垃圾。

以下一些简单的规则可以帮助我们把字符串产生的垃圾减少到最少。

  • 减少不必要的字符串创建。对相同的字符串值如果使用的次数较多,合理的办法是只创建一次并缓存它们。
  • 减少不必要的字符串的变化。例如,如果我们有一个频繁更新的文本组件,其中包含一个固定的级联,我们可以考虑把它分成两个组件。
  • 运行时的字符串构建,建议使用Stringbuilder。它是被设计专门用于在不分配额外内存的情况下构建字符串的,能够在串联复杂的字符串时减少产生的垃圾数量。
  • 移除Debug.Log()。即使它不输出任何内容,调用它还是会产生字符串。

 2. Unity的函数调用

无论是Unity自己的函数还是一个插件,都可能产生垃圾,所以对不了解实现的代码要额外小心。

有些Unity的函数调用也会产生堆内存的分配,所以需要小心使用来避免产生不必要的垃圾。

没有一个列表来告诉我们哪些是我们应该注意的,每个函数的调用都应该小心谨慎。正如前面所说的,最好方法是小心地剖析游戏,判断哪里产生了垃圾仔细思考如何解决它。有些情况下,缓存函数的结果是明智的选择;在另一些情况下,需要更少的去调用这些函数,而在另一些情况下,需要重构代码考虑使用别的函数。

  • 每次我们调用一个返回Array的函数,一个新的Array可能会被创建并作为返回值传给我们。这一行为不一定总是明确的,特别是函数是一个存取器(accessor)时。
  • 另一个意想不到的例子是访问 GameObject.name or GameObject.tag 时。这两个存取器都会返回两个新的字符串。缓存它们或许是一个有用的办法,但也可以用专门的函数替代,例如用GameObject.CompareTag()来tag进行比较。这不是一个特例,Unity中许多函数都有这样的可变的版本用于减少堆内存的分配,例如可以用Input.GetTouch()和Input.touchCount 来代替Input.touches, 或者使用Physics.SphereCastNonAlloc()来代替Physics.SphereCastAll().

 3. Boxing装箱

当一个值类型被转换为引用类型时会产生装箱,经常发生于将一个值类型作为参数传给一个以object为参数的函数,例如object.Equals().

当一个值类型被装箱时,Unity产生了一个临时的object用于包装值类型。

装箱是一个相当常见的非必须的内存分配场景,即使我们没有直接进行装箱的操作,插件中也不可避免的会发生。尽量避免,移除可能导致装箱的函数。

 4. 协程

StartCoroutine() 会产生少量的垃圾,因为Unity必须创建实例来管理协程。应该谨记在心的是,不要在性能关键位置调用StartCoroutine()。如果一定要在性能关键位置使用协程,请预先创建它,同时也要小心使用协程的嵌套。

yield本身不会分配堆空间,然而由yield传递的值可能会产生不必要的堆内存分配。例如yield return 0;

因为0被装箱了,所以会产生垃圾。可以使用以下的形式当我们不需要yield做任何事情时。

    yield return null;

如果协程产生了许多的垃圾,我们应该考虑重构我们的代码减少协程的使用。Update和Message System在某些场合下都能很好的代替协程。

 5. Foreach loops

在5.5之前下列的代码会产生垃圾

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

同样是由装箱所引起的。

 6. 函数引用

在Unity中,函数的引用是引用类型,会产生堆内存分配。将一个匿名函数转换成闭包的形式会显著的增加堆内存的使用。(?)

这部分产生的垃圾很大程度上依赖于平台实现,但如果关注GC的问题的话,还是尽量减少函数引用和闭包。

 7. LINQ和正则表达式


组织代码已减少对GC的影响

我们组织代码的方法也会对GC有所影响。即使我们的代码不产生堆内存的分配,它也会对GC产生负担。

结构体是值类型,如果一个结构体中包含了一个引用类型,那么GC就不得不去判定这整个结构体,如果有一个很大的结构体列表,那么就会极大的增加GC的工作量。

看一个例子

public struct ItemData
{
    public string name;
    public int cost;
    public Vector3 position;
}
private ItemData[] itemData;

上述代码可以优化成如下形式

private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
另一个方法是减少不必要的object的引用,例如用数字ID来代替直接的值引用。

手动触发GC的方法

System.GC.Collect();

手动扩大堆内存

可以在游戏设置阶段通过代码来手动的扩大堆内存

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // release reference
        tmp = null;
    }
}


猜你喜欢

转载自blog.csdn.net/Zealot_Alie/article/details/80530677