脚本概述 - 理解自动内存管理
理解自动内存管理
当创建一个对象、字符串或数组时,会从名为 堆 的中央池中分配一块内存,用来所存储创建的值。当这些值不再被使用时,被占用的内存可以被回收,并用于存储其他的值。在过去,是由程序员显示地调用相应的函数分配和释放堆内存。如今,像 Unity Mono 引擎这样的运行时系统,可以自动地管理内容。相比显示地分配和释放内存,自动内存管理需要的编码工作更少,并且大大降低了发生内存泄露的可能性(例如,分配内存后一直不释放的情况)。
值和引用类型
当调用一个函数时,参数值被复制到一块专门用于本次调用的内存区。对于数据类型,它们只占用很少的字节,可以非常迅速和容易地复制。但是,常见的对象、字符串和数组则大得多,如果频繁地复制这些类型的数据,是非常低效的。幸运的是,没必要这么做;大型值的实际存储空间从堆分配,然后用一个小巧的『指针』值记录下它的存储位置。这样,在传递参数的过程中,只有这个指针被复制。既然运行时系统可以通过这个指针定位到实际的值,那么,在必要时可以使用它的副本。
在传递参数的过程中,直接存储和复制的类型称为『值类型』,包括整型、浮点型、布尔型和 Unity 的结构类型(例如 Color、Vector3)。在堆中存储、然后用一个指针访问的类型成为『引用类型』,因为存储在变量中的值只是『指向』了真实值。引用类型的例子包括对象、字符串和数组。
分配和垃圾回收
内存管理器会一直跟踪堆的状态,知道哪些区域是闲置的。当请求一块新的内存区域时(意味着一个新对象被创建),管理器从闲置区域中选择一块,并从闲置区域中移除它。后续的请求被执行同样的处理,直到闲置区域不足以满足请求的尺寸。所有堆内存都被使用的可能性极小。堆上的引用类型只能通过引用变量访问,如果对某块内存区域的引用全都消失了(例如,引用变量被重新赋值,或者它们只是局部变量并且离开了作用域),那么这块内存区域可以被安全地重新分配。
为了确定哪些区域不再被使用,内存管理器会遍历当前所有有效的引用变量,并把他们所引用的区域标记为『活动』。遍历结束后,未被标记为『活动』的区域都被内存管理器认为是闲置的,可以用于后续的分配。定位和释放内存的过程被直观地称为垃圾回收(简写为 GC)。
优化
垃圾回收运行在后台,因此对于程序员来说是自动的、不可见的,但实际上,回收过程需要耗费相当的 CPU 时间。如果使用得当,自动内存管理的整体性能通常与手动分配相当或者更好。然后,程序员要注意避免频繁地触发不必要的回收,从而导致执行过程暂停。
有一些臭名昭著的算法堪称是 GC 噩梦,即使它们初看似乎没什么问题。一个典型的例子是字符串重复拼接:
//C# script example
using UnityEngine;
using System.Collections;
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;
}
}
//JS script example
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 中,字符串拼接通常是为了帧更新,就像这样:
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public GUIText scoreBoard;
public int score;
void Update() {
string scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
}
}
//JS script example
var scoreBoard: GUIText;
var score: int;
function Update() {
var scoreText: String = "Score: " + score.ToString();
scoreBoard.text = scoreText;
}
每次 Update 被调用,将分配一个新字符串,以恒定地速率产生新垃圾。通常我们可以这样优化这种情况:只有当比分更新时,才更新文本。
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
public GUIText scoreBoard;
public string scoreText;
public int score;
public int oldScore;
void Update() {
if (score != oldScore) {
scoreText = "Score: " + score.ToString();
scoreBoard.text = scoreText;
oldScore = score;
}
}
}
//JS script example
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;
}
}
当函数返回数组时,会引发另外一个潜在问题:
//C# script example
using UnityEngine;
using System.Collections;
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;
}
}
//JS script example
function RandomList(numElements: int) {
var result = new float[numElements];
for (i = 0; i < numElements; i++) {
result[i] = Random.value;
}
return result;
}
这种函数创建了一个填满值的数组,看起来非常优雅和方便。但是,如果反复调用它,那么每次都会分配新的内存。因为数组可能非常大,所以闲置堆空间可能很快被用完,进而导致频繁的垃圾回收。避免这个问题的方式是,利用数组是引用类型这一事实。把数组作为参数传入函数,在函数内部修改这个数组,当函数返回后,数组中的值依然有效。上面的函数可以替换为下面这个:
//C# script example
using UnityEngine;
using System.Collections;
public class ExampleScript : MonoBehaviour {
void RandomList(float[] arrayToFill) {
for (int i = 0; i < arrayToFill.Length; i++) {
arrayToFill[i] = Random.value;
}
}
}
//JS script example
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();
}
不过,你应该谨慎地使用这项技术,检查性能统计数据,以确保真的降低了内存回收时间。
慢节奏地分配大堆 + 不频繁地内存回收
这种策略对于分配和回收相对不频繁、可以在游戏暂停期间处理的游戏非常有效。在分配尽可能大的堆后,有些操作系统会因为系统内存不足而杀死应用,这种策略对于不会杀死应用的操作系统非常有用。不过,Mono 在运行时会尽可能不自动去扩展堆大小。你可以在启动时通过预分配占位空间的方式,手动扩展堆大小(例如,初始化一个纯粹是为了分配内存空间的无用对象):
//C# script example
using UnityEngine;
using System.Collections;
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;
}
}
//JS script example
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();
同样,你应该小心地使用这种策略,关注性能分析,而不仅仅是假设它有预期的效果。
可复用的对象池
在很多情况下,你可以简单地通过减少需要创建和销毁的对象数量来避免产生垃圾。游戏中某些类型的对象,例如射弹,它们可能在会反复出现,但是每次只会出现少数几个。在这种情况下,复用对象通常是可行的,而不是先销毁旧对象,然后创建新对象替换它们。
补充信息
内存管理是一个精细而复杂的课题,已经投入了大量学术上的努力。如果你有兴趣了解更多内容,memorymanagement.org 是一个很好的资源,上面列出了许多出版物和网络文章。关于对象池的更多信息,你可以在 Wikipedia page 和 Sourcemaking.com 上找到。