您的位置:首页 > 移动开发 > Unity3D

Unity中的C#内存管理(二)

2016-05-19 15:46 253 查看
在本系列教程的第一篇中,我们讨论了.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#] 纯文本查看 复制代码

?
脚本功能只是以循环方式从一堆整数生成字符串("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#] 纯文本查看 复制代码

?
如果你愿意,可以拷贝上面这段代码保存到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#] 纯文本查看 复制代码

?
这几句代码生成的CIL代码很多,我只摘录了关键部分:

[C#] 纯文本查看 复制代码

?
正如我们怀疑的那样,使用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的版本号。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: