Unity开发者的C#内存管理(中篇)
第一篇介绍了在 .NET/Mono 和Unity里内存管理的基础,并且提供了一些避免不必要的堆分配的建议。第三篇会深入到对象池。所有的都主要是面向中级的C#开发者。
我们现在来看看两种发现项目中不想要的堆分配的方法。第一种-Unity profiler-实在是太简单了,但是却相当费钱,得买’pro‘版的。第二种是讲你的.NET/Mono程序集反汇编成中间语言(CIL)然后再检查。如果你从没见过反汇编的.NET代码,继续看下去,不难,而且免费还很有启发意义。
容易的方法:使用Unity profiler
Unity优秀的分析器主要被用来分析游戏中各种资源需要的性能和资源:着色器,纹理,音频,游戏对象等等。然而分析器在发掘内存上也一样有用-跟你的C#代码的行为有关-甚至是外部的 没引用UnityEngine.dll的.NET/Mono程序集!在当前Unity版本中(4.3),这个功能不是来自内存分析器,而是CPU分析器。到C#代码的时候,内存分析器只是展示Mono堆的总大小和已使用的量。
这样让你看你的C#代码是否有嫩村泄露实在太粗糙了。即使不适用任何脚本,已使用的堆大小也会持续增长和缩减。只要你使用脚本,你需要一个看哪里分配了内存的途径,然后CPU分析器刚好给你提供这个。
让我们来看看一些实例代码。假设下面的脚本绑定到了一个GameObject上。
1 using UnityEngine;using System.Collections.Generic; 2 public class MemoryAllocatingScript : MonoBehaviour{ void Update() { 3 List<int> iList = new List<int>(new int[] { 4 072, 101, 108, 108, 111, 032, 119, 111, 114, 108, 100, 033 }); 5 string result = ""; 6 foreach (int i in iList.ToArray()) 7 result += ((char)i).ToString(); 8 Debug.Log(result); }}
它所做的就是通过一组整数用一种绕的方法创建了一个字符串("Hello world!"),一路上造成了不必要的内存分配。多少呢?很高兴你问了,但是我很懒,就让我们看看CPU分析器吧。选中窗口顶部的”Deep Profiler“,可以跟踪到每帧的调用树。
正如你所见,堆内存在Update()函数过程中的5个不同位置被分配。这个列表的初始化,foreach循环里到数组的转换是多余的,每一个数字到字符的转换以及连接都需要分配内存。有趣的是,仅仅是调用Debug.Log()也会分配一大块内存-这点值得记下来,即使在生产环境中这段代码会被剔除。
如果你没有Unity Pro,但是恰巧有Microsoft Visual Studio,那就有替代Unity Profiler的方法来发掘调用堆栈。Telerik 告诉我他们的 JustTrace Memory profiler 有相似的功能 (see here). 然而, 我不知道它模仿Unity每帧记录调用树到了什么程度。更进一步,尽管对Unity项目的远程调试(通过UnityVS) 是可以的,我还是没有成功的把JustTrace用来分析被Unity调用的程序集。
只是稍微难一点点的方法:反汇编你的代码
CIL的背景知识
如果你已经有了一个.NET/Mono的反汇编器,开始用吧,不然我推荐ILSpy. 这个工具不仅是免费的,它还非常干净简单,但是刚好包含下面我们会用到的一个特殊功能。
你也许知道C#编译器不会将你的代码编译成机器语言,而是公共中间语言。这种语言是被原.NET团队作为一种包含两种来自高级语言特性的低级语言开发出来的。一方面,它与硬件无关,另一方面,它包含最适合被称为’面向对象’的特性,比如可以引用其他模块或者类的能力。
没有经过代码模糊处理( code obfuscator )的CIL代码是异常容易反向工程的。 许多情况下,结果几乎和原始的C#(VB)代码一样。ILSpy 可以替你做这件事,但是我们仅仅反汇编代码就可以了(ILSpy通过调用ildasm.exe来实现,.它是NET/Mono的一部分)。让我们从一个加两个整数的函数开始。
1 int AddTwoInts(int first, int second){ 2 int result = first + second; 3 return result; 4 }
如果你愿意,你可以将这段代码粘贴到MemoryAllocatingScript.cs文件里。然后确保Unity编译了它,再用ILSpy打开编译了的库Assembly-Csharp.dll。如果你选择AddTwoInts() 方法,你会看到下面的:
除了蓝色的关键字 hidebysig,我们可以忽略掉,方法签名应该看起来差不多。要了解到方法里主要发生了什么,你需要知道CIL把CPU看成一个堆栈式机器stack machine 而不是寄存器机器register machine。CIL假设CPU可以处理非常基础,非常算法的指令,例如”将两个整数相加“,而且它可以处理任何内存地址的随机访问。CIL还假设CPU不直接在RAM上进行算术操作,而是首先需要将数据装载进概念上的计算堆栈。(注意计算堆栈和你你知道的C#堆栈没有任何关系。CIL计算堆栈只是一个抽象的,并且预设很小。)在行IL_0000到IL_0005发生了:
- 两个整型参数被推进堆栈。
- 加法被调用然后从堆栈里弹出开始位置的两个对象,自动将记过压进堆栈。
- 第3和4行可以忽略,因为在发行版本里会被优化掉。
- 这个方法返回堆栈的第一个值。
找到CIL里面的内存分配
CIL代码美在它不会隐藏任何堆分配。而且,堆分配会严格按照以下三个顺序分配,在你的反汇编代码里能看到。
- newobj <constructor>:这创建了一个由constructor指定类型的未初始化的对象。如果这个对象是值类型,它就在堆栈上被创建。如果它是一个引用类型,就在堆上。你总是能从CIL代码知道类型,所以你可以容易的知道内存分配产生的地方。
- newarr <element type>:这条指令在堆上创建了一个新的数组。Element的类型由参数指定。
- box <value type token>:这条特殊的指令执行装箱操作,我们已经在第一篇帖子里说过。
Let's look at a rather contrived method that performs all three types of allocations.
然我们来看一个人为的执行这三种内存分配的方法。
1 void SomeMethod(){ 2 object[] myArray = new object[1]; 3 myArray[0] = 5; 4 Dictionary<int, int> myDict = new Dictionary<int, int>(); 5 myDict[4] = 6; 6 foreach (int key in myDict.Keys) 7 Console.WriteLine(key); 8 }
有这几行代码产生的CIL代码很多,所以这里我们只看关键部分:
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()
正如我们怀疑过的,对象的数组(SomeMethod()里的第一行)导致newarr指令。整数5被赋给数组的第一个元素需要装箱。Dictionary<int, int>是被newobj指令分配的。
但是还有第四个堆分配!正如我在第一篇帖子里提到的,Dictionary<K, V>. KeyCollection被声明为一个类,不是结构。这个类的一个实例会被创建,这样foreach蓄奴换才有迭代的对象。不幸的是,分配发生在Keys属性的getter方法里。正如你在CIL代码里看到,这个方法的名字是get_Keys(),而且它的返回值是一个类。
作为一个查找内存泄露的通用方法,你可以生成一个对你的整个程序集反汇编的CIL文件,只要在ILSpy按下Ctrl+S。然后用你喜欢的文本编辑器打开这个文件,搜索上面提到的三种指令。查出其他程序集里的内存泄露是有难度。我唯一知道的办法就是仔细检查你的C#代码,确认所有的外部方法调用,并且一个个地查看它们的CIL代码。你怎么知道什么时候就完成了?很简单:你的游戏可以流畅的运行好几个小时,不因为垃圾收集造成任何的性能瓶颈。
PS:在之前的帖子里,我答应要向你们展示如何确认你们系统上的Mono版本。只要装了ILSpy,没有比这更简单的了。在ILSpy里,点击打开然后找到Unity根目录。找到Data/Mono/lib/mono/2.0然后打开mscorlib.dll。在层级视图里,找到mscorlib/-/Consts,然后那儿你能找到MonoVersion作为一个字符串常量。