理解自动内存管理

当创建一个对象、字符串或数组时,会从名为 的中央池中分配一块内存,用来所存储创建的值。当这些值不再被使用时,被占用的内存可以被回收,并用于存储其他的值。在过去,是由程序员显示地调用相应的函数分配和释放堆内存。如今,像 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 pageSourcemaking.com 上找到。