理解自动内存管理

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

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

    分配和垃圾回收

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

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

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

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

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

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

    每次 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. void Update() {
    9. if (score != oldScore) {
    10. scoreText = "Score: " + score.ToString();
    11. scoreBoard.text = scoreText;
    12. }
    13. }
    14. }
    15. //JS script example
    16. var scoreBoard: GUIText;
    17. var scoreText: String;
    18. var score: int;
    19. var oldScore: int;
    20. function Update() {
    21. if (score != oldScore) {
    22. scoreText = "Score: " + score.ToString();
    23. scoreBoard.text = scoreText;
    24. oldScore = score;
    25. }
    26. }

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

    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. for (int i = 0; i < arrayToFill.Length; i++) {
    6. arrayToFill[i] = Random.value;
    7. }
    8. }
    9. //JS script example
    10. function RandomList(arrayToFill: float[]) {
    11. for (i = 0; i < arrayToFill.Length; i++) {
    12. arrayToFill[i] = Random.value;
    13. }
    14. }

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

    请求一个集合

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

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

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

    这种策略对于分配和回收相对不频繁、可以在游戏暂停期间处理的游戏非常有效。在分配尽可能大的堆后,有些操作系统会因为系统内存不足而杀死应用,这种策略对于不会杀死应用的操作系统非常有用。不过,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;

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

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

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

    补充信息

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