本文翻译自 Unity 手册文档 - Understanding Automatic Memory Management

创建对象、字符串或数组时,从称为堆的中央池分配存储它所需的内存。当项目不再使用时,它曾经占用的内存可以回收并用于其他用途。在过去,通常由程序员使用适当的函数显式地调用分配和释放这些堆内存块。现在,像 Unity 的 Mono 引擎这样的运行时系统会自动为你管理内存。与显式分配/释放相比,自动内存管理需要更少的编码工作,并且极大地降低了内存泄漏的可能性(即内存被分配,但从未随后释放)。

0×01 值和引用类型

当调用一个函数时,它的参数值被复制到为该特定调用保留的内存区域。只占用几个字节的数据类型可以非常快速和容易地复制。然而,对象、字符串和数组通常要大得多,如果定期复制这些类型的数据,效率会非常低。幸运的是,这没有必要;大项目的实际存储空间从堆中分配,并使用一个小的 “指针” 值来记住它的位置。从那时起,在传递参数时只需要复制指针。只要运行时系统能够找到指针标识的项,就可以经常使用数据的一个副本。

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

0×02 分配和垃圾回收

内存管理器跟踪堆中未使用的区域。当请求一个新的内存块时(例如实例化一个对象时),管理器选择一个未使用的区域来分配该块,然后从已知的未使用空间中删除已分配的内存。随后的请求以相同的方式处理,直到没有足够大的空闲区域来分配所需的块大小。此时,从堆中分配的所有内存仍在使用的可能性非常小。堆上的引用项只能在仍然有可以定位它的引用变量的情况下访问。如果对内存块的所有引用都消失了(例如,引用变量已经重新分配,或者它们是超出范围的局部变量),那么它占用的内存就可以安全地重新分配。

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

0×03 优化

垃圾收集是自动的,程序员看不到它,但是收集过程实际上需要大量的 CPU 时间。如果使用正确,自动内存管理通常会在总体性能上与手动分配相当或更好。然而,对于程序员来说,避免错误是很重要的,这些错误将触发收集器的次数超过必要的次数,并在执行过程中引入暂停。

有一些臭名昭著的算法可能成为 GC 噩梦,尽管乍一看它们似乎是无辜的。重复字符串连接是一个经典的例子:

//C# script example
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;
    }
}
​
​
//JS script example
function ConcatExample(intArray: int[]) {
    var 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# script example
using UnityEngine;
using System.Collections;
​
public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}
​
​
//JS script example
var scoreBoard: GUIText;
var score: int;
​
function Update() {
    var scoreText: String = "Score: " + score.ToString();
    scoreBoard.text = scoreText;
}

每次调用Update时都会分配新的字符串,并生成持续的新垃圾流。只有在分数发生变化时才能通过更新文本来保存大部分内容:

//C# script example
using UnityEngine;
using System.Collections;
​
public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;
    
    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}
​
​
//JS script example
var scoreBoard: GUIText;
var scoreText: String;
var score: int;
var oldScore: int;
​
function Update() {
    if (score != oldScore) {
        scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
        oldScore = score;
    }
}

当函数返回数组值时,会出现另一个潜在问题:

//C# script example
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;
    }
}
​
​
//JS script example
function RandomList(numElements: int) {
    var result = new float[numElements];
    
    for (i = 0; i < numElements; i++) {
        result[i] = Random.value;
    }
    
    return result;
}

当创建一个充满值的新数组时,这种类型的函数非常优雅和方便。但是,如果重复调用它,那么每次都会分配新的内存。由于数组可能非常大,空闲堆空间可能很快被耗尽,从而导致频繁的垃圾收集。避免这个问题的一种方法是利用数组是引用类型这一事实。作为参数传递给函数的数组可以在该函数中修改,并且在函数返回后,结果仍然保留。像上面这样的函数通常可以替换为:

//C# script example
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;
        }
    }
}
​
​
//JS script example
function RandomList(arrayToFill: float[]) {
    for (i = 0; i < arrayToFill.Length; i++) {
        arrayToFill[i] = Random.value;
    }
}

这只是用新值替换数组的现有内容。虽然这需要在调用代码中完成数组的初始分配(看起来有点不优雅),但是在调用它时函数不会产生任何新的垃圾。

禁用垃圾回收

如果您使用的是 Mono 或 IL2CPP 脚本后端脚本 ,你可以通过在运行时禁用垃圾回收来避免垃圾回收期间的 CPU 峰值。禁用垃圾回收时,内存使用量不会减少,因为垃圾回收器不会收集不再有任何引用的对象。事实上,当你禁用垃圾收集时,内存使用量只会增加。为了避免随着时间的推移内存使用量增加,请在管理内存时注意。理想情况下,在禁用垃圾收集器之前分配所有内存,并在禁用垃圾收集器时避免额外的分配。

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

0×04 请求回收

如上所述,最好尽可能避免分配。然而,考虑到它们不能被完全消除,你可以使用两种主要策略来尽量减少它们对游戏玩法的干扰。:-

小型堆,具有快速和频繁的垃圾回收

这种策略通常最适合具有较长时间游玩的游戏,其中流畅的帧率是主要关注点。像这样的游戏通常会频繁地分配小块内存,但这些块只会被短暂使用。在 iOS 上使用此策略时的典型堆大小大约 200 KB,垃圾回收在 iPhone 3G 上大约需要 5 ms 的时间。如果堆增加到 1 MB,回收将花费大约 7 ms。因此,有时以常规帧间隔请求垃圾回收是有利的。这通常会使回收的频率超过需求,但是它们将被快速处理并且对游戏的体验影响最小:

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

但是,您应该谨慎地使用这种技术,并检查 Profiler 的统计数据,以确保它确实减少了游戏的垃圾回收时间。

大型堆,缓慢但不频繁的垃圾回收

这种策略最适合分配(以及回收)相对较少,并且可以在游戏暂停时处理的游戏。堆要尽可能大,但又不能大到让你的应用程序因为系统内存不足而被操作系统杀死。但是,Mono 运行时应尽可能避免自动扩展堆。可以通过在启动期间预先分配一些占位符空间来手动扩展堆(例如,实例化一个 “无用的” 对象,该对象的分配纯粹是为了实现对内存管理器的影响)。

//C# script example
using UnityEngine;
using System.Collections;
​
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;
    }
}
​
​
//JS script example
function 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 (var i : int = 0; i < 1024; i++)
        tmp[i] = new byte[1024];
​
    // release reference
        tmp = null;
}

一个足够大的堆不应该在游戏中可以容纳集合的那些暂停之间完全填满。发生此类暂停时,可以明确请求集合:

System.GC.Collect();

同样,在使用此策略时应注意 Profiler 统计信息,而不是仅仅假设它具有你所需要的效果。

可重用的对象池

在许多情况下,可以通过减少创建和销毁的对象数量来避免生成垃圾。游戏中有一些特定类型的物体,如子弹,可能会一次又一次地遇到,即只有少数物体会同时发挥作用。在这种情况下,通常可以重复使用用对象,而不是销毁旧对象并用新对象替换它们。

0×05 增量垃圾回收(实验性)

注意:这是一个预览功能,可能会更改。任何使用此功能的项目都可能需要在将来的版本中进行更新。在正式发布之前,不要依赖这个特性来进行全面的生产工作。

增量垃圾回收 将执行的工作分散到多个帧上执行垃圾收集。

对于增量式垃圾回收,Unity 仍然使用 Boehm-Demers-Weiser 垃圾回收器,但是是以增量模式运行的。Unity 没有在每次运行时都进行完整的垃圾回收,而是将垃圾回收工作负载划分为多个帧。 因此,程序执行上不会有一个单一的长时间中断来允许垃圾回收器执行其工作,而是有多个更短的中断时间来执行。虽然这并不能使垃圾速度的速度更快,但通过将工作负载分配到多个帧上,可以显著减少垃圾回收 “峰值” 所带来的破坏游戏流畅性的问题。

下面是 Unity Profiler 的截图,在没有启用增量垃圾回收,以及启用增量垃圾回收的情况演示,演示了增量回收如何减少帧率问题。在这些图表中,淡蓝色的部分显示多少时间使用脚本操作;黄色部分显示直到 Vsync(等待下一帧开始) 的剩余时间,和深绿色的部分显示了垃圾回收的时间。

huozk-spike
非增量垃圾收集统计信息

如果没有增量 GC (如上面),可以看到一个峰值中断了原本流畅的 60 FPS 帧速率。这个峰值将垃圾回收发生的帧推到远远超过维持 60 FPS 所需的 16 毫秒限制。(事实上,由于垃圾回收,本例删除了不止一帧。)

huozk-incrementa
增量垃圾收集统计信息

启用增量垃圾回收(如上图)后,同一项目保持其一致的 60 FPS 帧速率,因为垃圾回收操作在多个帧上进行分解,只使用每个帧的一小段时间(在黄色 Vsync 跟踪上方的深绿色边缘)。

huozk-auto
在帧中使用剩余时间增量垃圾回收

这个屏幕截图显示了相同的项目,也支持增量垃圾回收,但这次每帧脚本操作更少。同样,垃圾回收操作在几个帧上被分解。不同的是,这一次,垃圾回收使用更多的时间来完成每一帧,并且需要更少的总帧数来完成。这是因为如果 Vsync 我们会根据剩余的可用帧时间调整分配给垃圾回收的时间或 Application.targetFrameRate 正在使用。这样,我们就可以在本来需要等待的时间内运行垃圾回收,从而 “免费” 获得垃圾回收。

0×06 启用增量垃圾收集

目前,Mac、Windows 和 Linux 独立播放器以及 iOS、Android 和 Windows UWP 播放器都支持增量垃圾回收。未来将增加更多受支持的平台。增量式垃圾回收需要新的 .net 4.x 等以上脚本运行时版本。

在支持的配置上,Unity 在播放器设置(Player Setting) 的 “Other settings” 区域提供了一个实验性的增量垃圾回收选项窗口。只需启用 Use incremental GC (Experimental) 复选框。

huozk-setting
播放器设置以启用增量垃圾回收

此外,如果在项目质量设置或 Application.VSync 属性中将 VSync 计数设置为 “不同步” 以外的任何内容,或者设置了Application.targetFrameRate 属性,则 Unity 会自动使用在结束时留下的任何空闲时间调用增量垃圾回收。

还可以使用 Scripting.GarbageCollector 类对增量垃圾回收行为进行更精确的控制。例如,如果不想使用 VSync 或目标帧速率,则可以自己计算帧结束前的可用时间,并将该时间提供给垃圾回收器使用。

0×07 增量回收可能存在的问题

在大多数情况下,增量垃圾回收可以缓解垃圾回收峰值的问题。然而,在某些情况下,增量垃圾回收在实践中可能并不有益。

当增量垃圾回收中断其工作时,它会中断标记阶段,在该阶段扫描所有托管对象以确定哪些对象仍在被使用以及哪些对象可以清除。当对象之间的大多数引用在工作切片之间没有改变时,划分标记阶段很有效。当对象引用发生改变时,则必须在下一次迭代中再次扫描这些对象。因此,太多的更改可能会让增量垃圾回收器超负荷并导致出现标记传递永远不会完成的情况,因为它总是有更多工作要做 - 在这种情况下,垃圾回收会回退到执行完整的非增量回收阶段。

此外,当使用增量垃圾回收时,Unity 需要生成额外的代码(称为写入屏障),以便在引用发生更改时通知垃圾回收(让垃圾回收知道是否需要重新扫描对象)。这会在更改引用时增加一些性能开销,这些引用会对某些托管代码的性能产生一定的影响。

尽管如此,大多数典型的 Unity 项目(如果存在“典型”Unity项目这样的事情)都可以从增量垃圾回收中受益,特别是遭受垃圾回收峰值这个问题影响时。

应该始终使用 Profiler 来验证游戏或程序是否按预期执行。

0×08 实验状态

增量垃圾回收作为一个实验性的预览特性包含在 Unity 2019.1 中。这样做有很多原因:目前尚未支持所有平台。正如上面 “增量回收可能存在的问题” 部分所述,我们希望增量垃圾回收对大多数 Unity 内容有益,或至少不会对项目性能产生不利影响,对于我们正在测试的各种项目来说似乎也是如此。但是由于 Unity 内容非常多样化,我们希望确保这个假设在更大的 Unity 生态系统中保持正确,我们需要您对此多提出反馈。* 根据 Unity 代码和脚本 VM(Mono IL2CPP) 的要求添加写入屏障以便在托管内存中的引用发生更改时通知垃圾回收引入了潜在的错误源,我们错过了添加这样的写入障碍,这可能导致对象在仍然需要时被垃圾回收。现在,我们已经进行了大量的测试(包括手动和自动测试),我们不知道任何这样的问题,并且我们相信这个功能是稳定的(否则,我们不会发布)。但是,再一次,由于 Unity 内容的多样性,并且因为这些错误可能在实践中难以触发,我们不能完全排除可能存在问题的可能性。因此,总体而言,我们认为此功能可以按预期工作,并且没有任何已知问题。但是,由于Unity生态系统的复杂性,我们需要一些时间和检验来获得去掉实验标签的信心,我们将根据得到的反馈来做出这样的决定。

0×09 更多

内存管理是一个微妙而复杂的学科,学术界为此付出了大量的努力。如果你有兴趣了解更多相关信息,memorymanagement.org 是一个很好的资源,列出了许多出版物和文章。有关对象池的更多信息还可以在 Wikipedia页面Sourcemaking.com 上找到。

- Eof -