版本:Unity 6 (6000.0)
语言:English
禁用垃圾回收
原生内存

垃圾回收最佳实践

垃圾回收 是自动的,但此过程需要大量的 CPU 时间。

与其他编程语言(如 C++,您必须手动跟踪和释放所有分配的内存)相比,C# 的自动内存管理降低了内存泄漏和其他编程错误的风险。

自动内存管理允许您快速轻松地编写代码,并且错误很少。但是,这种便利性可能会影响性能。为了优化代码的性能,您必须避免应用程序频繁触发垃圾回收器 的情况。本节概述了一些影响应用程序何时触发垃圾回收器的常见问题和工作流程。

临时分配

应用程序在每一帧中将临时数据分配到托管堆是很常见的;但是,这会影响应用程序的性能。例如

  • 如果程序每帧分配 1 千字节 (1KB) 的临时内存,并且以 60 帧/秒运行游戏时连续显示帧的频率。 更多信息
    参见 词汇表
    的速度运行,则它必须每秒分配 60 千字节的临时内存。在一分钟内,这累积到 3.6 兆字节的内存,可供垃圾回收器使用。
  • 每秒调用一次垃圾回收器会对性能产生负面影响。如果垃圾回收器仅每分钟运行一次,则它必须清理分布在数千个单独分配中的 3.6 兆字节,这可能会导致大量的垃圾回收时间。
  • 加载操作会影响性能。如果您的应用程序在繁重的资源加载操作期间生成大量临时对象,并且 Unity 在操作完成之前引用这些对象,则垃圾回收器无法释放这些临时对象。这意味着托管堆需要扩展,即使 Unity 在短时间后会释放其中包含的大量对象。

为了解决此问题,您应该尽量减少频繁的托管堆分配:理想情况下,每帧为 0 字节,或尽可能接近零。

可重复使用的对象池

在很多情况下,您可以减少应用程序创建和销毁对象的次数,以避免生成垃圾。游戏中某些类型的对象,例如弹丸,可能会反复出现,即使一次只有少量对象处于活动状态。在这种情况下,您可以重用对象,而不是销毁旧对象并用新对象替换它们。

例如,每次发射弹丸时都从预制件一种资产类型,允许您存储一个完整的 GameObject,包括组件和属性。预制件充当模板,您可以从中在场景中创建新的对象实例。 更多信息
参见 词汇表
实例化一个新的弹丸对象并不是最佳做法。相反,您可以计算游戏过程中可能同时存在的最大弹丸数量,并在游戏首次进入游戏场景场景包含游戏环境和菜单。将每个唯一的场景文件视为一个唯一的关卡。在每个场景中,您放置环境、障碍物和装饰,从本质上讲,将您的游戏分成部分进行设计和构建。 更多信息
参见 词汇表
时实例化一个正确大小的对象数组。为此

  • 将所有弹丸游戏对象Unity 场景中的基本对象,可以表示角色、道具、场景、摄像机、路径点等。游戏对象的功用由附加在其上的组件定义。 更多信息
    参见 词汇表
    设置为非活动状态。
  • 发射弹丸时,搜索数组以查找数组中第一个非活动弹丸,将其移动到所需位置并将游戏对象设置为活动状态。
  • 弹丸被销毁时,再次将游戏对象设置为非活动状态。

您可以使用ObjectPool 类,它提供了这种可重复使用对象池技术的实现。

以下代码显示了一个基于堆栈的对象池的简单实现。如果您使用的是不包含 ObjectPool API 的旧版 Unity,或者如果您想查看自定义对象池的实现示例,您可能会发现它很有用。

using System.Collections.Generic;
using UnityEngine;

public class ExampleObjectPool : MonoBehaviour {

   public GameObject PrefabToPool;
   public int MaxPoolSize = 10;
  
   private Stack<GameObject> inactiveObjects = new Stack<GameObject>();
  
   void Start() {
       if (PrefabToPool != null) {
           for (int i = 0; i < MaxPoolSize; ++i) {
               var newObj = Instantiate(PrefabToPool);
               newObj.SetActive(false);
               inactiveObjects.Push(newObj);
           }
       }
   }

   public GameObject GetObjectFromPool() {
       while (inactiveObjects.Count > 0) {
           var obj = inactiveObjects.Pop();
          
           if (obj != null) {
               obj.SetActive(true);
               return obj;
           }
           else {
               Debug.LogWarning("Found a null object in the pool. Has some code outside the pool destroyed it?");
           }
       }
      
       Debug.LogError("All pooled objects are already in use or have been destroyed");
       return null;
   }
  
   public void ReturnObjectToPool(GameObject objectToDeactivate) {
       if (objectToDeactivate != null) {
           objectToDeactivate.SetActive(false);
           inactiveObjects.Push(objectToDeactivate);
       }
   }
}

重复的字符串连接

C# 中的字符串是不可变的您无法更改不可变(只读)包的内容。这与可变相反。大多数包都是不可变的,包括从包注册表或通过 Git URL 下载的包。
参见 词汇表
引用类型。引用类型意味着 Unity 将它们分配到托管堆上,并且会受到垃圾回收的影响。不可变意味着一旦创建了字符串,就无法更改它;任何尝试修改字符串的操作都会导致一个全新的字符串。因此,您应该尽可能避免创建临时字符串。

考虑以下示例代码,该代码将字符串数组组合成单个字符串。每次在循环内添加新字符串时,result 变量的先前内容都变得冗余,并且代码会分配一个全新的字符串。

// Bad C# script example: repeated string concatenations create lots of
// temporary strings.
using UnityEngine;

public class ExampleScript : MonoBehaviour {
    string ConcatExample(string[] stringArray) {
        string result = "";

        for (int i = 0; i < stringArray.Length; i++) {
            result += stringArray[i];
        }

        return result;
    }

}

如果 input stringArray 包含{ “A”, “B”, “C”, “D”, “E” },则此方法会在堆上为以下字符串生成存储空间

  • “A”
  • “AB”
  • “ABC”
  • “ABCD”
  • “ABCDE”

在此示例中,您只需要最终字符串,其他字符串都是冗余分配。输入数组中的项目越多,此方法生成的字符串就越多,每个字符串都比上一个长。

如果您需要将许多字符串连接在一起,则应使用 Mono 库的System.Text.StringBuilder 类。上面脚本的改进版本如下所示

// Good C# script example: StringBuilder avoids creating temporary strings,
// and only allocates heap memory for the final result string.
using UnityEngine;
using System.Text;

public class ExampleScript : MonoBehaviour {
    private StringBuilder _sb = new StringBuilder(16);

    string ConcatExample(string[] stringArray) {
        _sb.Clear();

        for (int i = 0; i < stringArray.Length; i++) {
            _sb.Append(stringArray[i]);
        }

        return _sb.ToString();
    }
}

除非重复连接被频繁调用(例如,在每次帧更新时),否则它不会过多降低性能。以下示例在每次调用 Update 时分配新字符串,并生成垃圾回收器必须处理的连续对象流

// Bad C# script example: Converting the score value to a string every frame
// and concatenating it with “Score: “ generates strings every frame.
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

为了防止这种持续的垃圾回收需求,您可以配置代码,以便文本仅在分数更改时更新

// Better C# script example: the score conversion is only performed when the
// score has changed
using UnityEngine;
using UnityEngine.UI;

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;
        }
    }
}

为了进一步改进这一点,您可以将分数标题(显示“Score: ”的部分)和分数显示存储在两个不同的UI.Text 对象中,这意味着无需进行字符串连接。代码仍然必须将分数值转换为字符串,但这比以前的版本有所改进

// Best C# script example: the score conversion is only performed when the
// score has changed, and the string concatenation has been removed
using UnityEngine;
using UnityEngine.UI;

public class ExampleScript : MonoBehaviour {
   public Text scoreBoardTitle;
   public Text scoreBoardDisplay;
   public string scoreText;
   public int score;
   public int oldScore;

   void Start() {
       scoreBoardTitle.text = "Score: ";
   }

   void Update() {
       if (score != oldScore) {
           scoreText = score.ToString();
           scoreBoardDisplay.text = scoreText;
           oldScore = score;
       }
   }
}

返回数组值的方法

有时,编写一个创建新数组、用值填充数组然后返回它的方法可能很方便。但是,如果重复调用此方法,则每次都会分配新的内存。

以下示例代码显示了一个每次调用时都会创建数组的方法示例

// Bad C# script example: Every time the RandomList method is called it
// allocates a new array
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;
    }
}

避免每次都分配内存的一种方法是利用数组是引用类型这一事实。您可以修改作为参数传递给方法的数组,并且结果在方法返回后仍然存在。为此,您可以配置示例代码如下

// Good C# script example: This version of method is passed an array to fill
// with random values. The array can be cached and re-used to avoid repeated
// temporary allocations
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;
        }
    }
}

此代码使用新值替换数组的现有内容。此工作流程要求调用代码执行数组的初始分配,但函数在调用时不会生成任何新的垃圾。然后,可以在下次调用此方法时重用和重新填充数组,而无需在托管堆上进行任何新的分配。

集合和数组重用

当您使用来自System.Collection 命名空间的数组或类(例如,列表或字典)时,重用或池化已分配的集合或数组是有效的。集合类公开了一个 Clear 方法,该方法消除了集合的值,但不会释放分配给集合的内存。

如果您想要为复杂计算分配临时“辅助”集合,这将很有用。以下代码示例演示了这一点

// Bad C# script example. This Update method allocates a new List every frame.
void Update() {

    List<float> nearestNeighbors = new List<float>();

    findDistancesToNearestNeighbors(nearestNeighbors);

    nearestNeighbors.Sort();

    // … use the sorted list somehow …
}

此示例代码每帧分配一次 nearestNeighbors 列表以收集一组数据点。

您可以将此列表提升到方法之外并进入包含类,以便您的代码无需每帧分配一个新列表

// Good C# script example. This method re-uses the same List every frame.
List<float> m_NearestNeighbors = new List<float>();

void Update() {

    m_NearestNeighbors.Clear();

    findDistancesToNearestNeighbors(NearestNeighbors);

    m_NearestNeighbors.Sort();

    // … use the sorted list somehow …
}

此示例代码在多帧中保留并重用列表的内存。仅当列表需要扩展时,代码才会分配新内存。

闭包和匿名方法

通常,您应该尽可能避免在 C# 中使用闭包。您应该最大程度地减少在性能敏感代码中,尤其是在每帧执行的代码中使用匿名方法和方法引用的次数。

C# 中的方法引用是引用类型,因此它们分配在堆上。这意味着,如果您将方法引用作为参数传递,则很容易创建临时分配。无论您传递的方法是匿名方法还是预定义方法,都会发生此分配。

此外,当您将匿名方法转换为闭包时,传递闭包到方法所需的内存量会大幅增加。

这是一个代码示例,其中需要按特定顺序对随机数列表进行排序。这使用匿名方法来控制列表的排序顺序,并且排序不会创建任何分配。

// Good C# script example: using an anonymous method to sort a list. 
// This sorting method doesn’t create garbage
List<float> listOfNumbers = getListOfRandomNumbers();


listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/2)) 

);

为了使此代码段可重用,您可以将常量 2 替换为局部范围内的变量

// Bad C# script example: the anonymous method has become a closure,
// and now allocates memory to store the value of desiredDivisor
// every time it is called.
List<float> listOfNumbers = getListOfRandomNumbers();


int desiredDivisor = getDesiredDivisor();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/desiredDivisor))

);

匿名方法现在需要访问其作用域之外的变量的状态,因此该方法已成为闭包。desiredDivisor 变量必须传递到闭包中,以便闭包的代码可以使用它。

为了确保将正确的值传递到闭包中,C# 生成一个匿名类,该类可以保留闭包所需的外部作用域变量。当闭包传递到 Sort 方法时,会实例化此类的副本,并且副本会使用 desiredDivisor 整数的值进行初始化。

执行闭包需要实例化其生成类的副本,并且所有类都是 C# 中的引用类型。因此,执行闭包需要在托管堆上分配对象。

装箱

装箱是 Unity 项目中最常见的意外临时内存分配来源之一。它发生在值类型变量自动转换为引用类型时。这种情况最常发生在将原始值类型变量(如 int 和 float)传递给对象类型方法时。编写 Unity 的 C# 代码时,应避免装箱。

在这个示例中,整数 x 被装箱以便能够传递给 object.Equals 方法,因为对象上的 Equals 方法要求传递一个对象。

int x = 1;

object y = new object();

y.Equals(x);

即使装箱会导致意外的内存分配,C# IDE 和编译器也不会发出警告。这是因为 C# 假设小的临时分配可以由分代垃圾收集器和对分配大小敏感的内存池有效地处理。

虽然 Unity 的分配器确实使用不同的内存池来处理小分配和大分配,但 Unity 的 垃圾收集器 不是分代的,因此它无法有效地清除装箱产生的少量、频繁的临时分配。

识别装箱

根据使用的脚本后端,装箱在 CPU 跟踪中显示为对少数方法之一的调用。这些方法采用以下其中一种形式,其中 <示例类> 是类或结构体的名称,而 是多个参数

<example class>::Box(…)
Box(…)
<example class>_Box(…)

要查找装箱,您还可以搜索反编译器或 IL 查看器的输出,例如 ReSharper 中内置的 IL 查看器工具dotPeek 反编译器。IL 指令是 box

数组值 Unity API

意外分配数组的一个微妙原因是重复访问返回数组的 Unity API。所有返回数组的 Unity API 在每次访问时都会创建一个数组的新副本。如果您的代码比必要时更频繁地访问数组值的 Unity API,则可能会对性能产生不利影响。

例如,以下代码在每次循环迭代中不必要地创建了顶点数组的四个副本。每次访问 .vertices 属性时都会发生分配。

// Bad C# script example: this loop create 4 copies of the vertices array per iteration
void Update() {
    for(int i = 0; i < mesh.vertices.Length; i++) {
        float x, y, z;

        x = mesh.vertices[i].x;
        y = mesh.vertices[i].y;
        z = mesh.vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

您可以将此代码重构为单个数组分配,无论循环迭代次数多少。为此,请配置您的代码以在循环之前捕获顶点数组

// Better C# script example: create one copy of the vertices array
// and work with that
void Update() {
    var vertices = mesh.vertices;

    for(int i = 0; i < vertices.Length; i++) {

        float x, y, z;

        x = vertices[i].x;
        y = vertices[i].y;
        z = vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

更好的方法是维护一个缓存并在帧之间重复使用的顶点列表,然后在需要时使用 Mesh.GetVertices 来填充它。

// Best C# script example: create one copy of the vertices array
// and work with that.
List<Vector3> m_vertices = new List<Vector3>();

void Update() {
    mesh.GetVertices(m_vertices);

    for(int i = 0; i < m_vertices.Length; i++) {

        float x, y, z;

        x = m_vertices[i].x;
        y = m_vertices[i].y;
        z = m_vertices[i].z;

        // ...

        DoSomething(x, y, z);   
    }
}

虽然访问一次属性的 CPU 性能影响并不高,但在紧密循环中重复访问会创建 CPU 性能热点。重复访问会扩展 托管堆

这个问题在移动设备上很常见,因为 Input.touches API 的行为类似于上述情况。项目中也经常包含类似于以下代码的代码,其中每次访问 .touches 属性时都会发生分配。

// Bad C# script example: Input.touches returns an array every time it’s accessed
for ( int i = 0; i < Input.touches.Length; i++ ) {
   Touch touch = Input.touches[i];

    // …
}

为了改进这一点,您可以配置您的代码将数组分配提升到循环条件之外

// Better C# script example: Input.touches is only accessed once here
Touch[] touches = Input.touches;

for ( int i = 0; i < touches.Length; i++ ) {

   Touch touch = touches[i];

   // …
}

以下代码示例将前面的示例转换为无分配的 Touch API

// BEST C# script example: Input.touchCount and Input.GetTouch don’t allocate at all.
int touchCount = Input.touchCount;

for ( int i = 0; i < touchCount; i++ ) {
   Touch touch = Input.GetTouch(i);

   // …
}

注意: 属性访问 (Input.touchCount) 保留在循环条件之外,以节省调用属性的 get 方法的 CPU 影响。

替代的非分配 API

某些 Unity API 具有不会导致内存分配的替代版本。在可能的情况下,您应该使用这些版本。下表显示了一些常见的分配 API 及其非分配替代方案。此列表并不详尽,但应表明需要注意的 API 类型。

分配 API 非分配 API 替代方案
Physics.RaycastAll Physics.RaycastNonAlloc
Animator.parameters Animator.parameterCountAnimator.GetParameter
Renderer.sharedMaterials Renderer.GetSharedMaterials

空数组重用

一些开发团队更喜欢在数组值方法需要返回空集时返回空数组而不是 null。这种编码模式在许多托管语言中很常见,特别是 C# 和 Java。

通常,从方法返回零长度数组时,返回零长度数组的预分配静态实例比重复创建空数组更有效。

更多资源

禁用垃圾回收
原生内存