原文地址:http://www.gamasutra.com/blogs/WendelinReich/20131119/203842/C_Memory_Management_for_Unity_Developers_part_2_of_3.php
第一部分的译文地址:http://blog.csdn.net/icecracker/article/details/52596816
第三部分的译文地址:http://www.cnblogs.com/mezero/p/3955130.html
如有错误,欢迎指出
正文:
【三部曲中的第一篇文章讨论了.NET/Mono以及Unity中的内存管理的基础,并且提供了一些避免不必要的堆内存分配的小提示。第三篇则讲了对象池。所有的内容都是面向“中级”C#开发者】
现在,来仔细看看两种在你的项目中寻找不想要的堆内存分配的方法。第一个方法——Unity profiler——超级简单,但是有着不小的缺点——消耗可观的内存,所以它只能在项目的前期版本中使用。第二个方法是把你的.NET/Mono组件反汇编成通用中间语言(CTL),之后检查它们。如果你之前从来没有见过被拆分的.NET代码,读一读,这不会很难并且是免费的,还有着非常大的教育意义。在下面,我打算教你一些正好够用的CIL知识,这样你就能分析你自己代码中真正的内存分配行为。
简单的方法:使用Unity的profiler
Unity的优秀profiler主要面向分析你游戏中的各种类型的资源(assets)如:(渲染器)shaders,材质(textures),音频(sound),gameobjects等等的行为和资源需求。这个profiler对于挖掘你的C#代码中与内存相关的行为十分好用,甚至同样适用于与UnityEngine.dll无关的外部.NET/Mono组件。
这实在是太粗糙了,以至于你无法看出你有没有从你的C#代码封堵任何内存泄漏。甚至当你没有使用任何代码时,堆中‘Used’的大小依然在成长并且持续收缩。一旦你使用代码,你就需要一个途径去看哪里发生了分配,CPU profiler给了你这个方法。
让我们来看看一些简单的代码。假设下面的代码被附加给一些GameObject。
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。
如你所见,在Update()的进行中堆内存被分配到五个不同的地方。在列表的起始处,在foreach循环中有一个多余的向数组的转换,把每个数字转换成字符串类型以及串联都需要分配内存。有趣的是,仅仅是对Debug.Log()的调用也分配了一大块内存——要记住这一点,就算它在生产代码中被过滤掉。
如果你没有Unity Pro, 但是有微软的Visual Studio的话,记住:有一个代替使用Unity Profiler的方法,它有类似的能力去深入挖掘调用树。Telerik告诉我他们的JustTrace Memory profiler有类似的功能。然而,我不知道它对Unity记录每一帧的调用树的能力模仿得好不好。进一步说,就算在VisualStudio中实现对Unity项目的远程调试是可行的(通过UnityVS,我最喜欢的工具之一),我在分析被Unity调用的组件时也从来没有成功的使用过JustTrace。
那就只有一条更难一点的路了:反汇编你自己的代码
CIL背景
如果你已经有了一个.NET/Mono的反汇编程序,那就好了。否则我要推荐ILSpy。这个工具不仅免费,而且干净而又简单,但是又包括了我们在下面需要的一个特别的特性。你也许已经知道,C#编译器不会把你的代码翻译成机器语言,而是通用中间语言(CIL)。这一个语言被原来的.NET团队开发成一个结合了从高级语言来的两个特性的低级语言。从一方面说,它是独立于硬件的,另一方面说,它包括了一个最好能被称为“面向对象”的特性,比如说引用模块(其他组件)和类的能力。
没有被代码混淆器编辑过的CIL代码非常容易进行逆向工程。在许多例子中,产生的结果几乎可以被认为是原本的C#(VB,…)代码。ILSpy可以为你做到这些,但是我们对于这些仅仅是编译过的代码也感到满意(ILSpy通过使用ildasm.exe——.NET/Mono的一部分——来实现这些)。让我们从一个非常简单的方法开始,它计算两个整数的和:
int AddTwoInts(int first, int second)
{
int result = first + second;
return result;
}
如果你愿意,你可以把上面的代码复制到MemoryAllocatingScript.cs文件中。然后确保Unity编译了它,再用ILSpy打开被编译的库 Assembly-Csharp.dll(这个库文件应该在你Unity项目的Library\ScriptAssemblies目录下)。如果你在这里选择了AddTwoInts()方法,你会看到如下的东西:
除了可以忽略的关键字hidebysig,方法的签名看起来很熟悉。为了搞清楚在方法的主体部分发生了什么,你要知道CIL把你电脑的CPU当成了一个堆栈机而不是一个寄存器机。CIL假设CPU可以执行一些非常基础的指令(大多数是算术运算),比如“增加两个整数”,并且它可以对任意内存进行随机存储。CIL也假设CPU不会直接在RAM上执行计算,而是先将数据加载到概念上的‘计算堆栈’上。(注意这个计算堆栈和现在你所了解的C#的栈没有联系)。在IL_0000与IL_0005之间发生了这些事:
· 两个整形参数被压入栈中
· 计算栈中最上方的两个数据的和,并把结果自动压入栈中
· 第三行和第四行可以被忽略,因为它们在最终版本中是可被精简的
· 这个方法返回栈中的第一个值(计算出来的和)
探索CIL中的内存分配
CIL的代码之美在于它不会隐藏堆分配。堆分配出现在下面三种指令中,你可以在被你的被编译过的代码中看到。
· newobj<constructor>:它创建了一个未被初始化的对象,类型通过构造函数指定。如果对象是值类型(比如结构体),那它就在栈上被创建。若它是引用类型(比如类),那就在堆上。从CIL代码中你可以了解它的类型,所以你可以轻松的说出哪里出现了内存分配。
· newarr<element type>:这一条指令在堆上创建了一个新的数组。元素的类型被参数指定
· box<value type token>:这一个特殊的指令执行了打包,我们已经在这个系列的第一篇讨论过了
让我们来看看一个执行了这三种分配的方法:
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代码数量巨大,所以我在这里只展示其中的关键部分:
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()
就像我们预料的一样,一个objects类型的数组(SomeMethod()中的第一行)导致了newarr指令的产生。被分配到数组中的第一个元素的整数‘5’需要被打包。Dictionary<int,int>被分配为newobj。但是还有第四次的堆内存分配存在!就像我在第一篇文章中说过的那样,Dictionary<K,V>.KeyCollection被声明为类而不是结构体。这个类的一个实体被创建了,所以foreach循环必须要有什么东西去迭代它。不幸的是,分配发生在一个对Keys字段的特殊的获取方法。正如你在CIL代码中所见的,方法的名字叫做get_Keys(),它的返回类型为class。看完这个代码,你可能已经怀疑有什么可疑的事情发生了。但是为了看到分配了KeyCollection实例的实际newobj指令,你必须在ILSpy中查看它们,并且来到get_Keys()部分。
作为一种寻找内存泄漏的普遍方法,你可以通过在INSpy中按下Ctrl+S(或者File->Save Code)为全部的被解析的代码创建一个CIL-dump。然后用你喜欢的文本编辑器打开这个文件,并且搜索上面提到的三个指令。虽然在其他的被解析代码中寻找出现的分配是一件困难的工作。我唯一了解的方法是仔细观察你的C#代码,定位所有的外部方法调用,然后一个一个的查看它们的CIL代码。你怎么知道什么时候你完成了呢?很简单,你的游戏可以流畅的运行几个小时,不会产生任何因为垃圾回收而导致的性能峰值。
PS:在前一篇文章中,我向你们保证告诉你们如何确定你系统上安卓的Mono版本。如果你装了ILSpy,没什么比这更简单的方法了:在ILSpy中点击Open然后找到你的Unity的根目录。进入Data/Mono/lib/mono/2.0并且打开mscorlib.dll。在菜单面板中去到tomscorlib/-/Consts,在那里你将会找到MonoVersion,一段字符串文本。
原文:
[The first installment of this three-part series discussed the basics of memory management in .NET/Mono and Unity, and offered some tips for avoiding unnecessary heap allocations. The third dives intoobject pooling. All parts are intended primarily for 'intermediate'-level C# developers.]
Let's now take a close look at two paths to finding unwanted heap allocations in your project. The first path - the Unity profiler - is almost ridiculously easy to use, but has the not-so-minor drawback of costing a considerable amount of money, as it only comes with Unity's commercial 'Pro' version. The second path involves disassembling your .NET/Mono assemblies into Common Intermediate Language (CIL) and inspecting them afterwards. If you've never seen disassembled .NET code before, read on, it's not hard and it's also free and extremely educational. Below, I intend to teach you just enough CIL so you can investigate the real memory allocation behavior of your own code.
The easy path: using Unity's profiler
Unity's excellent profiler is chiefly geared at analyzing the performance and the resource demands of the various types of assets in your game: shaders, textures, sound, gameobjects, and so on. Yet the profiler is equally useful for digging into the memory-related behavior of your C# code - even of external .NET/Mono assemblies that don't reference UnityEngine.dll! In the current version of Unity (4.3), this functionality isn't accessible from the Memory profiler but from the CPU profiler. When it comes to your C# code, the Memory profiler only shows you the Total size and the Used amount of the Mono heap.
This is too coarse to allow you to see if you have any memory leaks stemming from your C# code. Even if you don't use any scripts, the 'Used' size of the heap grows and contracts continuously. As soon as you do use scripts, you need a way to see where allocations occur, and the CPU profiler gives you just that.
Let's look at some example code. Assume that the following script is attached to some GameObject.
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); } }
All it does is build a string ("Hello world!") from a bunch of integers in a circuitous manner, making some unnecessary allocations along the way. How many? I'm glad you asked, but as I'm lazy, let's just look at the CPU profiler. With "Deep Profile" checked at the top of the window, it traces the call tree as deeply as it can at every frame.
As you can see, heap memory is allocated at five different places during our Update(). The initialization of the list, it's redundant conversion to an array in the foreach loop, the conversion of each number into a string and the concatenations all require allocations. Interestingly, the mere call to Debug.Log() also allocates a huge chunk of memory - something to keep in mind even if it's filtered out in production code.
If you don't have Unity Pro, but happen to own a copy of Microsoft Visual Studio, note that there are alternatives to the Unity Profiler which have a similar ability to drill into the call tree. Telerik tells me that their JustTrace Memory profiler has similar functionality (see here). However, I do not know how well it replicates Unity's ability to record the call tree at each frame. Furthermore, although remote-debugging of Unity projects in Visual Studio (via UnityVS, one of my favorite tools) is possible, I haven't succeeded in bringing JustTrace to profile assemblies that are called by Unity.
The only slightly harder path: disassembling your own code
Background on CIL
If you already own a .NET/Mono disassembler, fire it up now, otherwise I can recommend ILSpy. This tool is not only free, it's also clean and simple, yet happens to include one specific feature which we need further below.
You probably know that the C# compiler doesn't translate your code into machine language, but into theCommon Intermediate Language. This language was developed by the original .NET team as a low-level language that incorporates two features from higher-level languages. On one hand, it is hardware-independent, and on the other, it includes features that might best be called 'object-oriented', such as the ability to refer to modules (other assemblies) and classes.
CIL code that hasn't been run through a code obfuscator is surprisingly easy to reverse-engineer. In many cases, the result is almost identical to the original C# (VB, ...) code. ILSpy can do this for you, but we shall be satisfied to merely disassemble code (which ILSpy achieves by calling ildasm.exe, which is part of .NET/Mono). Let's start with a very simple method that adds two integers.
int AddTwoInts(int first, int second) { int result = first + second; return result; }
If you wish, you can paste this code into the MemoryAllocatingScript.cs file from above. Then make sure that Unity compiles it, and open the compiled library Assembly-Csharp.dll in ILSpy (the library should be in the directory Library\ScriptAssemblies of your Unity project). If you select the AddTwoInts() method in this assembly, you'll see the following.
Except for the blue keyword hidebysig, which we can ignore, the method signature should look quite familiar. To get the gist of what happens in the method body, you need to know that CIL thinks of your computer's CPU as a stack machine as opposed to a register machine. CIL assumes that the CPU can handle very fundamental, mostly arithmetic instructions such as "add two integers", and that it can also handle random access of any memory address. CIL also assumes that the CPU doesn't perform arithmetic directly 'on' the RAM, but needs to load data into the conceptual 'evaluation stack' first. (Note that the evaluation stack has nothing to do with the C# stack that you know by now. The CIL evaluation stack is just an abstraction, and presumed to be small.) What happens in lines IL_0000 to IL_0005 is this:
- The two integer parameters get pushed on the stack.
- add get's called and pops the first two items from the stack, automatically pushing it's result back on the stack.
- Lines 3 and 4 can be ignored because they would be optimized away in a release build.
- The method returns the first value on the stack (the added result).
Finding memory allocations in CIL
The beauty of CIL-code is that it doesn't conceal heap allocations. Instead, heap allocations can occur in exactly the following three instructions, visible in your disassembled code.
- newobj <constructor>: This creates an uninitialized object of the type specified via the constructor. If the object is a value type (struct etc.), it is created on the stack. If it is a reference typ (classetc.) it lands on the heap. You always know the type from the CIL code, so you can tell easily where the allocation occurs.
- newarr <element type>: This instruction creates a new array on the heap. The type of elements is specified in the a parameter.
- box <value type token>: This very specialized instruction performs boxing, which we already discussed in the first part of this series.
Let's look at a rather contrived method that performs all three types of allocations.
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); }
The amount of CIL code generated from these few lines is huge, so I'll just show the key parts here:
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()
As we already suspected, the array of objects (first line in SomeMethod()) leads to a newarr instruction. The integer '5', which is assigned to the first element of this array, needs a box. The Dictionary<int, int> is allocated with a newobj.
But there is a fourth heap allocation! As I mentioned in the first post, Dictionary<K, V>. KeyCollection is declared as a class, not a struct. An instance of this class is created so that the foreachloop has something to iterate over. Unfortunately, the allocation happens in a special getter method for theKeys field. As you can see in the CIL code, the name of this method is get_Keys(), and its return value is a class. Looking through this code, you might therefore already suspect that something fishy is going on. But to see the actual newobj instruction that allocates the KeyCollection instance, you have to visit themscorlib assembly in ILSpy and navigate to get_Keys().
As a general strategy for finding memory leaks, you can create a CIL-dump of your entire assembly by pressing Ctrl+S (or File -> Save Code) in ILSpy. You then open this file in your favourite text editor and search for the three mentioned instructions. Getting at allocations that occur in other assemblies can be hard work, though. The only strategy I know is to look carefully through your C# code, identify all external method calls, and inspect their CIL code one-by-one. How do you know when you're done? Easy: your game can run smoothly for hours, without producing any performance spikes due to garbage collection.
***
PS: In the previous post, I promised to show you how you could verify the version of Mono installed on your system. With ILSpy installed, nothing's easier than that. In ILSpy, click Open and find your Unity base directory. Navigate to Data/Mono/lib/mono/2.0 and open mscorlib.dll. In the hierarchy, go tomscorlib/-/Consts, and there you'll find MonoVersion as a string constant.