在本系列教程的第一篇中,我们讨论了.Net/Mono和unity的内存管理的基础知识,并提供了一些避免不必要的堆内存分配的技巧。第三篇文章将深入介绍对象池。
 
让我们仔细看看找到项目中非必须堆分配的两种方法。第一种方法非常简单,使用工具Unity Profiler。第二种,反编译.Net/Mono 程序集成公共中间语言(CIL)之后检查。如果你之前从未看过反编译的.Net代码,试着阅读一下,代码并不难。反编译后的代码是免费的而且有很多可以学习参考的地方。下面,我打算教会你CIL,这样就可以检查代码实际的内存分配情况。
 
 
简单方法:使用Unity profiler
 
 
Untiy的优秀工具Profiler主要用于分析游戏中多种类型assets的性能和资源消耗,例如:着色器,纹理,声音,游戏对象等。Profiler在挖掘C#代码(即使外部的.Net/Mono程序集没有引用UnityEngine.dll)的内存相关行为方面用处非常大。但是,在当前Unity版本(4.3)只有CPU分析器有该功能,而内存分析器没有。当检查C#代码时,内存分析器只显示总大小和Mono堆已使用大小。
UnityProfiler显示的太简单了,如果C#代码内存泄露,你根本发现不了。即使没有使用任何脚本,堆的“已用”大小也会一直持续地在增长和减少。如果使用脚本,可以使用CPU profiler查看在哪里发生堆内存分配。
 
 
让我们看看一些示例代码,将下面脚本附加到某个游戏对象上。
 
[C#] 纯文本查看 复制代码

using UnityEngine;

using System.Collections.Generic;
 
public class MemoryAllocatingScript : MonoBehaviour

{

      void Update() {

           List<int> iList = new List<int>(new int[]{ 072, 101, 108, 108, 111, 032, 119, 111, 114, 108, 100, 033});

           string result = "";

            foreach (int i in iList.ToArray())

                 result += ((char)i).ToString();

           Debug.Log(result);

      }

}

脚本功能只是以循环方式从一堆整数生成字符串("Hello world!"),过程中产生了一些不必要的分配。有多少?我很高兴你问了这个问题,但是我很懒,所以,我们使用CPU profiler来查看一下。选中窗口最上面的“Deep Profile”,它会在每一帧尽可能记录所有函数调用的深度,并以调用树的形式展示出来。
如你所见,我们的Update函数在5个不同地方分配了堆内存。初始化List,之后在foreach循环中转换成数组,每个数字都转成一个字符串,连接所有这些字符串产生的内存分配。有趣的是,不经常调用的Debug.Log()也分配了很大一块内存—即使Debug.Log在发布时会被过滤掉,我们也需要牢记这一点。
 
 
如果你没有专业版的Unity,但是碰巧有Microsoft Visual Studio,请注意,有一个与记录调用树功能类似的工具可以替代Unity Profiler。Telerik 告诉我他们的JustTrace内存分析器有这个类似的功能(见这里。然而,我不知道它替代Unity在每一帧记录函数调用树的能力是不是好于Unity。此外,虽然可以在Visual Studio(通过我最喜欢的工具UnityVS)中远程调试Unity工程,但是,我还没有成功地使用JustTrace来配置Unity调用的程序集。
 
 
稍微困难的方法:反编译自己的代码
 
 
CIL背景介绍
 
如果你已经有了.NET/Mono反编译器,现在开始反编译吧。如果没有,我推荐ILSpy。这个工具不但免费,而且界面简洁使用简单。我们需要深入了解下面一些特定功能。
 
 
C#编译器不会把C#代码编译成机器语言,而是编译成公共通用语言CIL。CIL是由.Net团队开发的一种底层语言,包含高级语言的两个特性。它在不同硬件平台不需要重新编译,同时还拥有面向对象的特性。例如,可以引用其他模块和类(其他程序集)。
 
 
没有经过混淆的CIL代码非常容易被逆向还原出源码。在许多情况下,逆向代码和原来C#代码几乎相同。ILSpy就是反编译的工具,它反编译之后的代码可读性高(ILSpy调用ildasm.exe,属于.Net/Mono的一部分)。让我们从一个非常简单的方法开始,将两个整数相加。
 
[C#] 纯文本查看 复制代码

int AddTwoInts(int first, int second) {
    
int result = first + second;
             
return result;

}

如果你愿意,可以拷贝上面这段代码保存到MemoryAllocatingScript.cs文件。确定使用Unity来编译,然后在ILSpy中打开编译后的库文件Assembly-Csharp.dll(一般在Unity工程目录的Library\ScriptAssemblies中)。在程序集中选择theAddTwoInts方法,你将会看到下面的反编译代码。
我们可以忽略蓝色关键字“hidebysig”,该方法看起来很熟悉。要明白函数体代码的意思,你需要了解,CIL把计算机的CPU当做一个堆栈栈机而不是寄存器机。CIL假定CPU可以处理非常基本的指令(主要是算数运算指令,如:“两个整数相加”),并且可以随机存取任何内存地址。CIL还假定CPU不直接在RAM执行算数运算,而是首先加载数据到“evaluation stack”(evaluation stack和C#堆栈不是一个概念,只是一个抽象概念,假定占用空间也不大)。从IL_0000 到IL_0005代码的意思是:
 
 
  •    两个整数参数被压入堆栈
  •    两数相加,弹出堆栈开始的两个单元,自动把计算结果压入堆栈
  •    第3、4行可以忽略,因为在发行版他们会被优化掉
  •    该方法返回堆栈的最上面第一个值(相加的结果)

 
 
在CIL中查找内存分配
 
 
CIL代码的优势在于堆分配代码不会被隐藏。相反,完全可以在反编译的代码中找到堆分配的三种指令。
 
 
  •    newobj <constructor>:通过构造函数创建一个指定类型的未初始化对象。如果对象是值类型(结构体等),则在堆栈创建。如果是引用类型(类等)则在堆上分配。可以从CIL代码知道对象类型,所以,可以很容易知道在哪分配。
  •    newarr <元素类型>:在堆上创建一个数组。元素类型在参数中指定。
  •    box <值类型标记>:装箱(传递数据)专用指令,在第一部分已经介绍过。

 
 
 
我们来看一个使用了上面三种分配类型的方法,代码如下:
 
[C#] 纯文本查看 复制代码
void SomeMethod() {    
object[] myArray = new object[1];    
myArray[0] = 5;     
Dictionary<int, int> myDict = new Dictionary<int, int>();    
myDict[4] = 6;     
foreach (int key in myDict.Keys)        
Console.WriteLine(key);
}
这几句代码生成的CIL代码很多,我只摘录了关键部分:
[C#] 纯文本查看 复制代码

IL_0001: newarr [mscorlib]System.Object

...

IL_000a: box [mscorlib]System.Int32

...
IL_0010: newobj instance void class [mscorlib]System.

    Collections.Generic.Dictionary'2<int32, int32>::.ctor()

...

IL_001f: callvirt instance class [mscorlib]System.

    Collections.Generic.Dictionary`2/KeyCollection<!0, !1>

    class [mscorlib]System.Collections.Generic.Dictionary`2<int32,

int32>::get_Keys()

正如我们怀疑的那样,使用newarr 指令(SomeMethod第一行代码)来分配数组对象。整数“5”是这个数组的第一个元素,需要使用“装箱操作(Box)”传递数据。使用newobj指令来分配Dictionary<int, int>。
 
 
但是,这里产生了第四个堆分配。我在第一篇文章说过,Dictionary<K, V>. KeyCollection被声明为类,而不是结构体。创建类的实例之后foreach才能循环遍历所有项。不幸的是,这个堆分配为了获取Keys 字段调用了一个特殊的getter方法,你可以在CIL代码中看到,这个方法的名字是get_Keys,它的返回值是类。看完这段代码,你可能已经对发生的事情有了些眉目。但是,为了弄清楚newobj 指令产生的KeyCollection 实例,你需要使用ILSpy反编译mscorlib ,并定位到 get_Keys()方法。
 
有一个查找内存泄露的基本策略,在ILSpy中通过快捷键Ctrl+S(File->Save Code)为整个程序集创建一个CIL-dump。之后在文本编辑器中打开这个dump,并搜索上面所说的三个指令定位到内存分配的代码。找到其它程序集中的内存分配是有难度的。我唯一知道的策略是仔细查看C#代码,找到所有调用外部方法的代码,逐一检查他们的CIL代码。
 
 
备注:如何验证系统安装的Mono版本呢?有了ILSpy事情变得非常简单。在ILSpy中单击打开Unity根目录,定位到Data/Mono/lib/mono/2.0(在Unity 5.1 Mac版本中没有该目录,Windows没有验证。可能是老版本的Unity才有)目录,然后选择mscorlib.dll,在层次导航中找到“Consts”类,你将会发现一个字符串常量MonoVersion ,即Mono的版本号。
 
 
原文作者:Wendelin Reich
 
 
 
感谢蛮牛译员“Jingli”对本文翻译所做的贡献~~~

 
 
本文由蛮牛译馆倾情奉献,除 合作社区 及 合作媒体 外,禁止转载。
 
关注蛮牛,更新中...