理解自动内存管理

在创建对象、字符串或数组时,其存储所需的内存将从一个被称为的中央池分配。若项目不再使用,它之前占用的内存可以释放回收,用来存储其他数据。过去通常由程序设计员使用相应的函数调用,明确分配并释放这些堆内存块。现在,像 Unity Mono 引擎之类的运行时间系统可以自动管理内存。自动内存管理比明确分配/释放需要的编码工作更少,并大大降低了潜在的内存泄漏(内存分配,但从未被释放的情况)。

值和引用类型

在调用函数时,其参数值将复制到专为此次调用保留的内存区域。数据类型只会占用几个字节,可以快速简单地复制。但在通常情况下,对象、字符串和数组要大得多,如果这些类型的数据定期复制的话将会变得相当低效。幸运的是,我们没必要这样做;大型项目的实际存储空间从堆中分配并且只使用一个小小的“指针”值来记住它的位置。自此,在参数传递期间只需复制指针。只要运行时间系统可以找到指针识别的项目,就可以在必要时经常使用单个数据副本。

直接存储并在参数传递过程中复制的类型被称为值类型。它包括整型、浮点型、布尔型和 Unity 的结构类型(例如,ColorVector3)。被分配在堆然后通过指针访问的类型被称为引用类型,因为存储在变量中的值只是“指向”真实的数据。引用类型包括对象、字符串和数组等。

分配和垃圾收集

内存管理会跟踪堆中已确定未使用的区域。在请求新的内存块时(例如当一个对象被实例化),管理器将选择未使用的区域分配给内存块,然后将分配的内存从已知未使用空间中删除。后续请求都以同样的方式处理,直到有没有足够大的未使用空间来分配所需的块大小。此时,堆中分配的所有内存几乎不可能都在使用中。只有在参考变量仍可找到引用时,才可以访问堆上的引用项目。如果内存块的所有引用都已经消失(例如,引用变量已经重新分配,或者它们成为超出范围的局部变量),那么,它所占用的内存可以安全地重新分配。

若要决定哪些堆块不再被使用,内存管理器将在所有当前活动的引用变量中搜索,并将其引用的块标记为“活动”块。完成搜索之后,活动块之间的所有空间都将被内存管理器视为未使用,可以用于后续分配。显然,定位和释放未使用内存的过程被称为垃圾收集(简称为 GC)。

优化

垃圾收集为自动执行,且程序设计员不可见,但实际上收集过程需要在后台占用大量 CPU 时间。若使用恰当,自动内存管理的整体性能一般会等于或高于手动分配。但是,程序设计员应避免失误引发不必要的回收器触发和执行停顿。

现在也存在一些非主流的算法,虽然第一眼看上去并无恶意,但却能成为的 GC 的噩梦。重复字符串串联便是一个典型的例子:-

 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 通常采用帧更新。如下所示:-

 var scoreBoard: GUIText;
 var score: int;
 
 function Update() {
 	var scoreText: String = "Score: " + score.ToString();
 	scoreBoard.text = scoreText;
 }

… 每次更新时都将分配新的字符串,并且持续产生新的垃圾。大多数字符串可以通过更新文本保存,除非 score 发生变化:-

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

另一个潜在问题发生在函数返回数组值时:-

 function RandomList(numElements: int) {
 	var result = new float[numElements];
 
 	for (i = 0; i < numElements; i++) {
 		result[i] = Random.value;
 	}
 
 	return result;
 }

在创建有填充值的新数组时,此类函数非常美观实用。但是,如果反复调用,每次都将分配新的内存。由于数组可能非常大,空闲的堆空间可能快速消耗完,导致频繁的垃圾收集。避免这个问题的一种方式是利用数组是一种引用类型。作为参数传递到函数的数组可以在函数内修改,在函数返回之后结果将保留。上述函数通常可作如下替换:-

 function RandomList(arrayToFill: float[]) {
 	for (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();
 }
 

但请应该谨慎使用该技巧,并检查分析器统计信息,以确保它可以真正减少游戏的垃圾收集时间。

缓慢但很少进行垃圾收集的大堆

此策略适用于很少进行分配(收集也相应减少)的游戏,可以在游戏暂停时处理。如果堆尽可能大,但不会大到系统内存低而导致 OS 无法运行游戏时,它非常有用。如果可能的话,Mono 运行时间会避免自动扩展堆。您可以在启动时预先分配某些占位符空间(例如,对于仅因影响内存管理器的而被分配的“无用”对象,可对其实例化),手动扩展堆:-

 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();
 

同样,也应该谨慎使用该策略,并注意分析器的统计信息,而是不是假设它已经取得预期效果。

可重复使用的对象池

在很多情况下,可以通过减少创建和销毁的对象数目来避免产生垃圾。您可能反复遇到游戏中的某些对象,比如爆弹,尽管只有少数对象会立即爆炸。这种情况通常可以重复使用对象而无需摧毁旧对象,然后使用新对象替换。

请参阅此处,了解更多有关对象池 (Object Pool) 及其实现的详细信息。

更多信息

内存管理是一个微妙复杂的话题,已经投入了大量的学术精力。如果有兴趣了解更多相关知识,那么 memorymanagement.org 是不错的资源网站,它刊登了大量出版物和在线文章。更多有关对象池的信息,请访问 Wikipedia 页面Sourcemaking.com

Page last updated: 2013-06-30