Version: 2020.1
稳定脚本运行时:已知限制
引用其他类库程序集

了解自动内存管理

创建对象、字符串或数组时,用于存储它的内存是从称为的中央池分配的。当此项不再使用时,其先前占用的内存可被回收并用于其他目的。在过去,通常由程序员通过适当的函数调用显式地分配和释放这些堆内存块。如今,Unity 的 Mono 引擎等运行时系统会自动为您管理内存。自动内存管理比显式分配/释放的做法需要更少的编码工作,并且大大降低了内存泄漏的可能性(即分配了内存但后续从未释放的情况)。

值和引用类型

调用某个函数时,其参数的值将复制到为该特定调用保留的内存区域。只占几个字节的数据类型可以非常快速和轻松地完成复制。但是,对象、字符串和数组通常要大得多,如果需要经常复制这些类型的数据,效率会非常低。幸运的是,并不是非要这样做;可从堆中分配大项的实际存储空间,并使用小“指针”值来记住它的位置。此后,在参数传递期间只需要复制指针。只要运行时系统能找到该指针所标识的项,就可以根据需要频繁使用该数据的同一个副本。

在参数传递期间直接存储和复制的类型称为值类型。这些类型包括整数、浮点数、布尔值和 Unity 的结构类型(例如,__Color__ 和 __Vector3__)。在堆上分配后再通过指针访问的类型称为引用类型,因为在变量中存储的值仅“引用”实际数据。引用类型的示例包括对象、字符串和数组。

分配和垃圾收集

内存管理器跟踪已知未使用的堆区域。当请求新的内存块时(例如,当实例化对象时),管理器选择一个未使用的区域来分配内存块,然后从已知的未使用空间中移除分配的内存。后续请求以相同的方式处理,直到没有足够大的可用区域来分配所需的块大小。此时极不可能从堆中分配的所有内存都仍在使用中。若要访问堆上的引用项,前提是仍有引用变量可以定位到该项。如果对内存块的所有引用都消失(即,引用变量已被重新分配,或者引用变量是局部变量但现在已超出范围),则可安全地重新分配其占用的内存。

为确定哪些堆块已不再使用,内存管理器会搜索所有当前处于活动状态的引用变量,并将它们引用的块标记为“实时”。在搜索结束时,内存管理器会认为实时块之间的任何空间都是空的并可用于后续分配。由于显而易见的原因,定位和释放未使用的内存的过程称为垃圾收集(或简称 GC)。

Unity 使用 Boehm–Demers–Weiser 垃圾回收器,这是一种可停止所有工作的垃圾回收器。每当 Unity 需要执行垃圾收集时,它都会停止运行程序代码,并且仅在垃圾回收器完成所有工作后才恢复正常执行。此中断可能会导致游戏执行延迟,持续时间从不到一毫秒到几百毫秒不等,这取决于垃圾回收器需要处理多少内存以及运行游戏的平台。对于像游戏这样的实时应用程序,这可能会成为一个重大问题,因为垃圾回收器暂停游戏的执行时,您无法维持平滑动画所需的稳定帧率。这些中断也被称为 GC 尖峰,因为它们在性能分析器帧时间图中显示为尖峰。在接下来的部分中,您将更详细了解如何编写代码以避免在运行游戏时对分配的内存进行不必要的垃圾收集,从而减少垃圾回收器的工作量。

优化

垃圾收集是自动完成的,对程序员来说不可见,但收集过程实际上在后台需要耗费大量 CPU 时间。如果使用得当,自动内存管理通常在整体性能上能达到或超过手动分配。但是,程序员必须避免错误以免导致不必要的频繁触发垃圾回收器并在执行中引起暂停。

有一些臭名昭着的算法虽然一眼看上去好像是无辜的,但可能成为 GC 的噩梦。重复的字符串连接便是一个典型的例子:

//C# 脚本示例
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void ConcatExample(int[] intArray) {
        string line = intArray[0].ToString();
        
        for (i = 1; i < intArray.Length; i++) {
            line += ", " + intArray[i].ToString();
        }
        
        return line;
    }
}

此处的关键细节是新的部分不会逐一添加到字符串。实际情况的是,每次循环时,line 变量的先前内容变为死亡状态:分配的整个新字符串将包含原始部分加上末尾的新部分。由于字符串随着 i 值的增加而变长,因此消耗的堆空间量也会增加,所以每次调用此函数时都很容易用掉数百个字节的空闲堆空间。如果需要将大量字符串连接在一起,那么 Mono 库的 System.Text.StringBuilder 类将是更好的选择。

但是,即使重复的连接也不会造成太大麻烦,除非频繁调用,而在 Unity 中这通常意味着帧更新。类似以下脚本:

//C# 脚本示例
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

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

…在每次调用 Update 时都会分配新的字符串,并生成源源不断的垃圾。通过仅在 score 发生变化时才更新 text,可避免大部分的垃圾:

//C# 脚本示例
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

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

另一个潜在问题是在函数返回数组值时出现的问题:

//C# 脚本示例
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];
        
        for (int i = 0; i &lt; numElements; i++) {
            result[i] = Random.value;
        }
        
        return result;
    }
}

在新建包含值的数组时,这种类型的函数非常从容和方便。但是,如果重复调用这种函数,则每次都会分配全新的内存。由于数组可能非常大,因此空闲堆空间可能会迅速耗尽,导致频繁进行垃圾收集。避免此问题的一种方法是利用数组为引用类型这一特点。作为参数传入该函数的数组可在该函数内予以修改,且结果在函数返回后仍然保留。像上面这样的函数通常可替换为如下所示的函数:

//C# 脚本示例
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;
        }
    }
}

此函数仅将数组的现有内容替换为新值。虽然这需要在调用代码中完成数组的初始分配(看起来有点不方便),但在调用该函数时不会产生任何新的垃圾。

禁用垃圾收集

如果使用的是 Mono 或 IL2CPP 脚本后端,则可以通过在运行时禁用垃圾收集来避免垃圾收集期间的 CPU 使用率激增。禁用垃圾收集时,内存使用量完全不会减少,因为垃圾回收器不会收集不再有任何引用的对象。事实上,禁用垃圾收集时,内存使用量只会增加。为避免一段时间内内存使用量增加,请在管理内存时慎重操作。理想情况下,在禁用垃圾回收器之前分配所有内存,并在禁用垃圾回收器时避免进行其他分配。

有关如何在运行时启用和禁用垃圾收集的更多详细信息,请参阅 GarbageCollector 脚本 API 页面。

还可以尝试增量垃圾收集选项

请求进行垃圾收集

如上所述,最好尽量避免内存分配。但是,鉴于无法完全消除这些行为,可采用两种主要策略来最小化这些行为对游戏运行过程的干扰。

快速和频繁进行垃圾收集的小堆

这种策略通常最适合游戏运行过程很长且主要关注帧率平滑性的游戏。像这样的游戏通常会频繁分配小块,但这些块的使用时间很短暂。在 iOS 上使用此策略时的典型堆大小约为 200KB,在 iPhone 3G 上的垃圾收集时间大约需要 5ms。如果堆大小增加到 1MB,则收集时间将大约需要 7ms。因此,有时,以定期的帧间隔请求进行垃圾收集可能是有利的。这种情况下通常会使垃圾收集频率高于严格意义上的要求,但是这些行为将得到快速处理,并且对游戏运行过程的影响极小:

if (Time.frameCount % 30 == 0)
{
   System.GC.Collect();
}

但是,应谨慎使用此技术并检查性能分析器的统计信息,以确保真正减少了游戏的垃圾收集时间。

慢速但不频繁进行垃圾收集的大堆

这种策略最适合内存分配(因此垃圾收集)相对不频繁并可在游戏运行过程的暂停期间进行处理的游戏。一种非常有用的方法是,尽可能增大堆的大小,但不至于因为系统内存不足而导致操作系统终止您的应用程序。但是,Mono 运行时会尽可能避免自动扩展堆。这种情况下,可通过在启动期间预先分配一些占位空间来手动扩展堆(即,实例化一个纯粹为了影响内存管理器而分配的“无用”对象):

//C# 脚本示例
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // 以较小的块进行分配,从而避免以适合大块的特殊方式处理它们
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // 释放引用
        tmp = null;
    }
}

一个足够大的堆不应在游戏运行过程中配合进行垃圾收集的暂停期间完全耗尽。发生此类暂停时,可显式请求垃圾收集:

System.GC.Collect();

同样,在使用此策略时应谨慎,并注意性能分析器的统计信息,而不能仅仅期待其具有所需的效果。

可重用的对象池

在许多情况下,通过减少创建和销毁的对象数量即可避免生成垃圾。游戏中存在某些类型的对象,例如飞弹,可能会多次反复遇到,但是只有少数对象会同时处于游戏中。在这种情况下,通常可以重用对象,而不是销毁旧对象并替换为新对象。

增量垃圾收集

Incremental garbage collection spreads out the process of garbage collection over multiple frames.

Incremental garbage collection is the default garbage collection method Unity uses. This is still the Boehm-Demers-Weiser garbage collector, but Unity runs it in an incremental mode. Instead of doing a full garbage collection each time it runs, Unity splits up the garbage collection workload over multiple frames. This means that instead of one long interruption to your program’s execution to allow the garbage collector to do its work, Unity makes multiple, much shorter interruptions. While this does not make garbage collection faster overall, distributing the workload over multiple frames can significantly reduce the problem of garbage collection “spikes” that break the smoothness of your application.

The following screenshots from the Unity Profiler illustrate how incremental collection reduces framerate hiccups. In these profile traces, the light blue parts of the frame show how much time is used by script operations, the yellow parts show the time remaining in the frame until Vsync (waiting for the next frame to begin), and the dark green parts show the time spent for garbage collection.

The following screenshot displays a frame capture from the Unity Profiler in an application that does not use incremental garbage collection:

Profile without using incremental garbage collection
Profile without using incremental garbage collection

Without incremental garbage collection, a spike interrupts the otherwise smooth 60fps frame rate. This spike pushes the frame in which garbage collection occurs well over the 16 millisecond limit required to maintain 60FPS (this example drops more than one frame because of garbage collection.)

The following screenshot displays a frame capture from the Unity Profiler in an application that does use incremental garbage collection:

Incremental garbage collection profile
Incremental garbage collection profile

启用增量垃圾收集(上图)后,同一项目将保持一致的 60fps 帧率,因为垃圾收集操作被分解到若干帧中,只占用每帧的一小段时间(位于黄色 Vsync 跟踪上方的深绿色条纹)。

The following screenshot displays a frame capture in the Unity Profiler from the same project, also running with incremental garbage collection enabled, but this time with fewer scripting operations per frame.

Incremental garbage collection using left over time in frame
Incremental garbage collection using left over time in frame

Again, the garbage collection operation is broken up over several frames. The difference is that this time, the garbage collection uses more time each frame, and requires fewer total frames to finish. This is because Unity adjusts the time it allocates to garbage collection based on the remaining available frame time if your application uses Vsync or Application.targetFrameRate. This way, Unity can run the garbage collection in time it would otherwise spend waiting, and therefore carry out garbage collection with a minimal performance impact.

All platforms other than WebGL support incremental garbage collection.

Player Settings to disable incremental garbage collection
Player Settings to disable incremental garbage collection

In addition, if you set the VSync Count to anything other than Don’t Sync (in your project’s Quality settings or with the Application.VSync property) or you enable the Application.targetFrameRate property, Unity automatically uses any idle time left at the end of a given frame for incremental garbage collection.

To exercise more precise control over incremental garbage collection behavior, you can use the Scripting.GarbageCollector class. For example, if you do not want to use VSync or a target frame rate, you can calculate the amount of time available before the end of a frame yourself, and provide that time to the garbage collector to use.

增量垃圾收集的可能问题

在大多数情况下,增量垃圾收集可以减轻垃圾收集尖峰的问题。但是,在某些情况下,增量垃圾收集在实践中可能没有益处。

增量垃圾收集中断工作时,它将中断标记阶段(该阶段扫描所有托管对象以确定哪些对象仍在使用中以及可以清除哪些对象)。当对象之间的大多数引用在工作片段之间不变时,拆分标记阶段没有问题。对象引用会改变时,必须在下一次迭代中再次扫描那些对象。因此,太多的更改会使增量垃圾回收器不堪重负,并导致标记遍历永远不能完成,因为它总是有更多的工作要做;在这种情况下,垃圾收集会退回到进行完整的非增量收集。

此外,在使用增量垃圾收集时,只要引用发生更改,Unity 就需要生成其他代码(称为写屏障)来通知垃圾收集(因此垃圾收集将知道是否需要重新扫描对象)。更改引用时,这会增加一些开销,可能会对某些托管代码产生明显的性能影响。

尽管如此,大多数典型的 Unity 项目(如果有这样的“典型”Unity 项目)仍可从增量垃圾收集中受益,尤其是项目遭受垃圾收集尖峰时。

始终使用性能分析器来验证您的游戏或程序是否按预期执行。

其他信息

内存管理是一个微妙而复杂的主题,业界已投入了大量的学术努力。如果有兴趣了解这一主题,memorymanagement.org 将是极好的资源,其中列出了大量出版物和在线文章。如需了解对象池的更多信息,请访问 Wikipedia 页面以及 Sourcemaking.com


  • 2020–06–02
  • 在 Unity 2018.3 版中添加了在 Mono 和 IL2CPP 脚本后端禁用垃圾收集的功能 NewIn20183
  • 在 Unity 2019.1 中添加了实验性的“增量垃圾收集”功能 NewIn20191
  • 添加了其他平台(PS4、XBox One、Nintendo Switch 和 Unity Editor)上对增量垃圾收集的支持。2019.2 NewIn20192
稳定脚本运行时:已知限制
引用其他类库程序集