• 理解自动内存管理
    • 值和引用类型
    • 分配和垃圾回收
    • 优化
    • 请求一个集合
      • 快节奏地分配小堆 + 频繁地内存回收
      • 慢节奏地分配大堆 + 不频繁地内存回收
    • 可复用的对象池
    • 补充信息

    理解自动内存管理

    当创建一个对象、字符串或数组时,会从名为 的中央池中分配一块内存,用来所存储创建的值。当这些值不再被使用时,被占用的内存可以被回收,并用于存储其他的值。在过去,是由程序员显示地调用相应的函数分配和释放堆内存。如今,像 Unity Mono 引擎这样的运行时系统,可以自动地管理内容。相比显示地分配和释放内存,自动内存管理需要的编码工作更少,并且大大降低了发生内存泄露的可能性(例如,分配内存后一直不释放的情况)。

    值和引用类型

    当调用一个函数时,参数值被复制到一块专门用于本次调用的内存区。对于数据类型,它们只占用很少的字节,可以非常迅速和容易地复制。但是,常见的对象、字符串和数组则大得多,如果频繁地复制这些类型的数据,是非常低效的。幸运的是,没必要这么做;大型值的实际存储空间从堆分配,然后用一个小巧的『指针』值记录下它的存储位置。这样,在传递参数的过程中,只有这个指针被复制。既然运行时系统可以通过这个指针定位到实际的值,那么,在必要时可以使用它的副本。

    在传递参数的过程中,直接存储和复制的类型称为『值类型』,包括整型、浮点型、布尔型和 Unity 的结构类型(例如 Color、Vector3)。在堆中存储、然后用一个指针访问的类型成为『引用类型』,因为存储在变量中的值只是『指向』了真实值。引用类型的例子包括对象、字符串和数组。

    分配和垃圾回收

    内存管理器会一直跟踪堆的状态,知道哪些区域是闲置的。当请求一块新的内存区域时(意味着一个新对象被创建),管理器从闲置区域中选择一块,并从闲置区域中移除它。后续的请求被执行同样的处理,直到闲置区域不足以满足请求的尺寸。所有堆内存都被使用的可能性极小。堆上的引用类型只能通过引用变量访问,如果对某块内存区域的引用全都消失了(例如,引用变量被重新赋值,或者它们只是局部变量并且离开了作用域),那么这块内存区域可以被安全地重新分配。

    为了确定哪些区域不再被使用,内存管理器会遍历当前所有有效的引用变量,并把他们所引用的区域标记为『活动』。遍历结束后,未被标记为『活动』的区域都被内存管理器认为是闲置的,可以用于后续的分配。定位和释放内存的过程被直观地称为垃圾回收(简写为 GC)。

    优化

    垃圾回收运行在后台,因此对于程序员来说是自动的、不可见的,但实际上,回收过程需要耗费相当的 CPU 时间。如果使用得当,自动内存管理的整体性能通常与手动分配相当或者更好。然后,程序员要注意避免频繁地触发不必要的回收,从而导致执行过程暂停。

    有一些臭名昭著的算法堪称是 GC 噩梦,即使它们初看似乎没什么问题。一个典型的例子是字符串重复拼接:

    1. //C# script example
    2. using UnityEngine;
    3. using System.Collections;
    4. public class ExampleScript : MonoBehaviour {
    5. void ConcatExample(int[] intArray) {
    6. string line = intArray[0].ToString();
    7. for (i = 1; i < intArray.Length; i++) {
    8. line += ", " + intArray[i].ToString();
    9. }
    10. return line;
    11. }
    12. }
    13. //JS script example
    14. function ConcatExample(intArray: int[]) {
    15. var line = intArray[0].ToString();
    16. for (i = 1; i < intArray.Length; i++) {
    17. line += ", " + intArray[i].ToString();
    18. }
    19. return line;
    20. }

    这里的关键细节是,新片段并没有被添加到已有的字符串之后。事情的真相是,每执行一次循环,变量 line 的旧内容被丢弃,一个全新的字符串被创建,用来包含旧内容和新增部分。随着变量 i 的增加,字符串变得越来越长,消耗的堆空间也随之增长;每当这个函数被调用,就会用掉成百上千个字节的闲置堆空间。如果你需要拼接许多字符串,更好的选择是使用 Mono 库的 System.Text.StringBuilder 类。

    不过,字符串反复拼接并不会造成太大的麻烦,除非你频繁地调用,而在 Unity 中,字符串拼接通常是为了帧更新,就像这样:

    1. //C# script example
    2. using UnityEngine;
    3. using System.Collections;
    4. public class ExampleScript : MonoBehaviour {
    5. public GUIText scoreBoard;
    6. public int score;
    7. void Update() {
    8. string scoreText = "Score: " + score.ToString();
    9. scoreBoard.text = scoreText;
    10. }
    11. }
    12. //JS script example
    13. var scoreBoard: GUIText;
    14. var score: int;
    15. function Update() {
    16. var scoreText: String = "Score: " + score.ToString();
    17. scoreBoard.text = scoreText;
    18. }

    每次 Update 被调用,将分配一个新字符串,以恒定地速率产生新垃圾。通常我们可以这样优化这种情况:只有当比分更新时,才更新文本。

    1. //C# script example
    2. using UnityEngine;
    3. using System.Collections;
    4. public class ExampleScript : MonoBehaviour {
    5. public GUIText scoreBoard;
    6. public string scoreText;
    7. public int score;
    8. public int oldScore;
    9. void Update() {
    10. if (score != oldScore) {
    11. scoreText = "Score: " + score.ToString();
    12. scoreBoard.text = scoreText;
    13. oldScore = score;
    14. }
    15. }
    16. }
    17. //JS script example
    18. var scoreBoard: GUIText;
    19. var scoreText: String;
    20. var score: int;
    21. var oldScore: int;
    22. function Update() {
    23. if (score != oldScore) {
    24. scoreText = "Score: " + score.ToString();
    25. scoreBoard.text = scoreText;
    26. oldScore = score;
    27. }
    28. }

    当函数返回数组时,会引发另外一个潜在问题:

    1. //C# script example
    2. using UnityEngine;
    3. using System.Collections;
    4. public class ExampleScript : MonoBehaviour {
    5. float[] RandomList(int numElements) {
    6. var result = new float[numElements];
    7. for (int i = 0; i < numElements; i++) {
    8. result[i] = Random.value;
    9. }
    10. return result;
    11. }
    12. }
    1. //JS script example
    2. function RandomList(numElements: int) {
    3. var result = new float[numElements];
    4. for (i = 0; i < numElements; i++) {
    5. result[i] = Random.value;
    6. }
    7. return result;
    8. }

    这种函数创建了一个填满值的数组,看起来非常优雅和方便。但是,如果反复调用它,那么每次都会分配新的内存。因为数组可能非常大,所以闲置堆空间可能很快被用完,进而导致频繁的垃圾回收。避免这个问题的方式是,利用数组是引用类型这一事实。把数组作为参数传入函数,在函数内部修改这个数组,当函数返回后,数组中的值依然有效。上面的函数可以替换为下面这个:

    1. //C# script example
    2. using UnityEngine;
    3. using System.Collections;
    4. public class ExampleScript : MonoBehaviour {
    5. void RandomList(float[] arrayToFill) {
    6. for (int i = 0; i < arrayToFill.Length; i++) {
    7. arrayToFill[i] = Random.value;
    8. }
    9. }
    10. }
    11. //JS script example
    12. function RandomList(arrayToFill: float[]) {
    13. for (i = 0; i < arrayToFill.Length; i++) {
    14. arrayToFill[i] = Random.value;
    15. }
    16. }

    在上面的代码中,用新值替换了数组中的已有内容。尽管这种方式需要在调用函数的代码中完成数组的初始化分配(看起来不怎么优雅),但是这个函数被调用时将不再产生任何新的垃圾。

    请求一个集合

    如上所述,最好是尽可能地避免分配。但是,鉴于不可能完全消除分配的事实,有两种主要策略可以最小化分配对游戏的影响:

    快节奏地分配小堆 + 频繁地内存回收

    这一策略对于需要平稳帧率、长时间运行的游戏非常有效。这类游戏通常会频繁地分配小块内存,并且只是短暂地使用这些小块内存。在 iOS 上使用这种策略时,典型的堆大小是 200KB 左右,以 iPhone 3G 为例,内存回收大约耗时 5ms;如果堆大小增加到 1MB,内存回收将耗时约 7ms。因此这种策略是有效的,最理想的情况是,有时内存回收会发生在常规帧之间。尽管这种策略会导致更频繁的内存回收,但是回收非常快,最小化了对游戏的影响:

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

    不过,你应该谨慎地使用这项技术,检查性能统计数据,以确保真的降低了内存回收时间。

    慢节奏地分配大堆 + 不频繁地内存回收

    这种策略对于分配和回收相对不频繁、可以在游戏暂停期间处理的游戏非常有效。在分配尽可能大的堆后,有些操作系统会因为系统内存不足而杀死应用,这种策略对于不会杀死应用的操作系统非常有用。不过,Mono 在运行时会尽可能不自动去扩展堆大小。你可以在启动时通过预分配占位空间的方式,手动扩展堆大小(例如,初始化一个纯粹是为了分配内存空间的无用对象):

    1. //C# script example
    2. using UnityEngine;
    3. using System.Collections;
    4. public class ExampleScript : MonoBehaviour {
    5. void Start() {
    6. var tmp = new System.Object[1024];
    7. // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
    8. for (int i = 0; i < 1024; i++)
    9. tmp[i] = new byte[1024];
    10. // release reference
    11. tmp = null;
    12. }
    13. }
    1. //JS script example
    2. function Start() {
    3. var tmp = new System.Object[1024];
    4. // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
    5. for (var i : int = 0; i < 1024; i++)
    6. tmp[i] = new byte[1024];
    7. // release reference
    8. tmp = null;
    9. }

    在游戏暂停之间,这个足够大的堆不应该被完全填满,因为会导致内存回收。当游戏暂停时,你可以明确地请求一次内存回收:

    1. System.GC.Collect();

    同样,你应该小心地使用这种策略,关注性能分析,而不仅仅是假设它有预期的效果。

    可复用的对象池

    在很多情况下,你可以简单地通过减少需要创建和销毁的对象数量来避免产生垃圾。游戏中某些类型的对象,例如射弹,它们可能在会反复出现,但是每次只会出现少数几个。在这种情况下,复用对象通常是可行的,而不是先销毁旧对象,然后创建新对象替换它们。

    补充信息

    内存管理是一个精细而复杂的课题,已经投入了大量学术上的努力。如果你有兴趣了解更多内容,memorymanagement.org 是一个很好的资源,上面列出了许多出版物和网络文章。关于对象池的更多信息,你可以在 Wikipedia page 和 Sourcemaking.com 上找到。