Unity Manual 【翻译】—— 理解自动内存管理

原文地址: Understanding Automatic Memory Management


本文翻译自Unity Manual 的 Understanding Automatic Memory Management 一节,主要介绍Unity中的内存管理、垃圾回收,及优化的做法。本文以翻译原文+注解的方式书写。


理解自动内存管理

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


值类型和引用类型

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

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


内存分配和垃圾回收

内存管理器追踪堆中已知的未使用区域。当请求分配一个新的内存块时(比如实例化一个对象时),管理器会选择一个未使用的区域来分配该块,然后从已知的未使用空间中删除这块内存。后续请求以相同的方式处理,直到没有足够大的空闲区域来分配所需的块大小。这个时候,所有从堆中分配的内存都还在使用是不太可能的。只有当还存在指向堆上这块内存的引用变量时,才可以访问这块内存。如果对这块内存的所有引用都消失了(例如,引用变量已经赋值,或者它们是局部变量并且已经超出作用域),那么就可以安全地重新分配它所占用的内存。

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


优化

垃圾收集是自动进行的,程序员看不到,但是收集过程实际上需要大量的CPU时间。如果使用得当,自动内存管理将在总体性能上与手动分配不相上下。但是,对于程序员来说,重要的是要避免错误,这些错误将不必要的更频繁地触发收集器,并在GC执行过程中导致暂停。

以下这些操作尽管乍一看没什么,但是会严重的影响GC性能。重复的字符串拼接就是一个经典的例子:

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中,在Update中调用尤其得小心注意,比如:

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public int score;

    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

每帧都会调用并且产生一个新的字符串(新分配一块内存),然后就源源不断的产生垃圾。这种情况可以通过只在 score 改变时才更新 text 来优化:

void Update() {
     if (score != oldScore) {
         scoreText = "Score: " + score.ToString();
         scoreBoard.text = scoreText;
         oldScore = score;
     }
 }

另一个潜在的问题是使用函数返回一个数组:

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

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

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}

这只是用新值替换数组的现有内容,没有反复申请内存。尽管这需要在调用代码中执行数组的初始分配(看起来有点不优雅),但函数在被调用时不会生成任何新的垃圾。


请求垃圾回收

如上所述,最好尽可能避免分配。然而,考虑到它们不能被完全消除,有两个主要策略可以用来最小化它们对游戏的干扰:

小内存:频繁但快速的回收策略

这种策略通常最适合那些游戏时间较长的游戏,这种游戏最主要的是保持稳定的帧率。像这样的游戏通常会频繁地分配小块内存,但这些内存只会短暂地使用。在iOS上使用这种策略时,堆大小通常是200KB左右,而在iPhone 3G上,垃圾收集这块内存大约需要5ms。如果堆增加到1MB,收集将需要大约7ms。因此,有时在常规的帧间隔请求垃圾收集是有利的。这通常会增加垃圾回收执行的频率,但它们将被快速处理,并且对游戏的影响很小:

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

然而,你应该谨慎的使用这个技巧,并仔细的检查性能统计器来保证这样做确实减轻了游戏中垃圾回收带来的影响。

译注: 垃圾回收一小块内存的速度非常快,在毫秒之间就可以完成,完全不会被玩家所察觉到。对于会持续运行很久的游戏(中间没有场景切换),可能在运行一段时间后累积了大量的垃圾内存,这时执行垃圾回收收回这大量的内存会需要很久的时间,可能数百毫秒,这时游戏便会卡顿一下,带来不好的体验。所以这个策略就是将垃圾回收的任务均摊到帧间隔中,每隔一段时间便手动的执行垃圾回收,避免垃圾内存积累太多回收时负担大而造成的卡顿。虽然这样做会增加垃圾回收执行的次数,但可以维持更稳定的游戏帧率,所以是值得的。不过,使用这个策略仍需谨慎,确保这样做真的有好处。

大内存:缓慢但少次的回收策略

这种策略最适合于分配内存(因此还有回收内存)次数相对较少的游戏,并且可以在游戏暂停期间进行处理。这样的做法很有用:尽可能去分配最大的堆内存,当然也别大到导致系统内存不足而被操作系统强制关闭游戏。但是,Mono运行时尽可能的避免了自动扩展堆内存。你可以通过在启动期间预先分配一些内存来手动扩展堆(也就是说,你实例化了一个“无用”对象,这个对象纯粹是为了对内存管理器产生影响而分配的):

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

分配一个足够大的堆内存,在遇到游戏中的暂停之前内存不会用尽。当出现这种暂停时,你就可以显式地执行回收:

System.GC.Collect();

同样,在使用此策略时,你应该小心,并注意检查性能统计器的数据,而不是仅仅假设它具有预期的效果。

译注: 有的游戏会有明显的暂停时间(比如过关、场景切换),你可以利用这个时间来集中回收内存,也就是说每一关的场景加载时,预先分配一大块内存,本关的活动需求的内存不会超过这个预分配的值,那么也就不会产生任何的垃圾回收操作,当切换场景读条时,你就足够的时间去回收这大块内存而不用担心对游戏帧率造成影响。


可复用对象池

在许多情况下,你可以通过减少创建和销毁的对象的数量来避免生成垃圾。游戏中有种特殊的对象,比如投射物,它们可能会被反复地使用,尽管同一时间内只使用很少的数量。在这种情况下,复用对象通常是更好的选择,而不是不用时销毁旧的对象,然后要使用时又去创建新的对象。

对象池是游戏开发中很常见一种设计模式,我计划为此写一篇博客,届时地址更新在这里


更多信息

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

你可能感兴趣的:(Unity,Manuals,阅读及翻译,Unity,GC,内存管理,游戏开发)