1
|
foreach (SomeType s in someList)
s.DoSomething();
|
1
2
3
4
5
6
|
using (SomeType.Enumerator enumerator = this .someList.GetEnumerator())
{
while (enumerator.MoveNext()){
SomeType s = (SomeType) enumerator.Current;
s.DoSomething();
}
}
|
1
|
foreach (SomeType s in someList)
s.DoSomething();
|
1
2
3
4
5
6
7
|
using (SomeType.Enumerator enumerator = this .someList.GetEnumerator())
{
while (enumerator.MoveNext())
{
SomeType s = (SomeType)enumerator.Current;
s.DoSomething();
}
}
|
1
2
3
4
5
6
7
|
int result = 0;
void Update()
{
for ( int i = 0; i < 100; i++){
System.Func< int , int > myFunc = (p) => p * p;
result += myFunc(i);
}
}
|
1
2
3
4
5
6
7
8
|
int result = 0;
void Update()
{
for ( int i = 0; i < 100; i++)
{
System.Func< int , int > myFunc = (p)=> p * p;
result += myFunc(i);
}
}
|
|
System.Func< int , int > myFunc = (p) => p * i++;
|
1
2
3
4
5
6
7
8
|
int [] array = { 1, 2, 3, 6, 7, 8 };
void Update()
{
IEnumerable< int > elements = from element in array
orderby element descending
where element > 2
select element;
...
}
|
1
2
3
4
|
void Update()
{
string string1 = "Two" ;
string string2 = "One" + string1 + "Three" ;
}
|
1
2
3
4
|
void Update()
{
string string1 = "Two" ;
string string2 = "One" + string1 + "Three" ;
}
|
|
string result = string .Format( "{0} = {1}" , 5, 5.0f);
|
|
public static string Format( string format, params Object[] args )
|
01
02
03
04
05
06
07
08
09
10
11
12
13
|
public static class ListExtensions
{
public static void Reverse_NoHeapAlloc this List
{
int count = list.Count;
for ( int i = 0; i < count / 2; i++)
{
T tmp = list[i];
list[i] = list[count - i - 1];
list[count - i - 1] = tmp;
}
}
}
|
01
02
03
04
05
06
07
08
09
10
11
12
13
|
public static class ListExtensions
{
public static void Reverse_NoHeapAlloc this List
{
int count = list.Count;
for ( int i = 0; i < count / 2; i++)
{
T tmp = list[i];
list[i] = list[count - i - 1];
list[count - i - 1] = tmp;
}
}
}
在本系列教程的第一篇中,我们讨论了.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代码的意思是:
在CIL中查找内存分配
CIL代码的优势在于堆分配代码不会被隐藏。相反,完全可以在反编译的代码中找到堆分配的三种指令。
我们来看一个使用了上面三种分配类型的方法,代码如下:
[C#] 纯文本查看 复制代码
这几句代码生成的CIL代码很多,我只摘录了关键部分:
[C#] 纯文本查看 复制代码
正如我们怀疑的那样,使用newarr 指令(SomeMethod第一行代码)来分配数组对象。整数“5”是这个数组的第一个元素,需要使用“装箱操作(Box)”传递数据。使用newobj指令来分配Dictionary
但是,这里产生了第四个堆分配。我在第一篇文章说过,Dictionary
有一个查找内存泄露的基本策略,在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的版本号。
本系列文章的 第一部分 讨论了.Net/Mono和unity内存管理的基本知识。 第二部分 深入介绍了Unity Profiler和CIL相关知识,以发现C#代码中不必要的内存分配。
第三篇文章即本文将介绍对象池。到目前为止,我们一直关注的是堆分配。现在我们还想要避免不必要的内存释放,以至于在游戏运行时不会因为垃圾回收器(GC)回收内存而产生帧率下降。对象池是解决这个问题的理想方案。我将展示三种对象池的完整代码(你可以在GithubGist中找到这些代码)。
从一个非常简单的对象池类开始
对象池背后的理念非常简单。不再使用new创建新的对象,而是在对象池中储存用过的对象,允许他们之后再被回收,从而在需要时重复使用他们。对象池最重要的一个特性、真正的对象池设计模式的本质是当我们需要获得一个新的对象时,不需要关心对象是重新创建的还是循环使用的原来对象。下面几行代码就是对象池设计模式的具体实现:
[C#] 纯文本查看 复制代码
非常简单,但这的确是核心模式的完美实现。(如果你不懂"where T..."语法,下面将会解释)。想用这个类,就不能像下面这样用new操作符来创建类:
[C#] 纯文本查看 复制代码
而是成对使用 New() 和 Store()方法:
[C#] 纯文本查看 复制代码
这样比较繁琐,因为你需要记住在New()方法之后在正确的位置调用Store()方法。不幸的是,没有一种通用的方法来简化此设计模式的使用,因为不管是ObjectPool还是C#编译器都不知道对象什么时候可以被重新使用。恩,其实还有一种通过垃圾回收器自动管理内存的方法。这种方法的缺点在文章开始处你已经读过。也就是说,在幸运的情况下,你可以使用文章最后说明的“对象池全部重置”模式。那里,所有的Store()调用都会被替换为调用ResetAll()方法。
增加ObjectPool 类的复杂度
我是简洁代码(simplicity)的粉丝,大道至简。但是,ObjectPool 类现阶段可能有些太简单了。如果你搜索C#的对象池库,会找到很多解决方案,其中有些方案相当精妙且复杂。因此,退一步来思考我们需要或者不需要什么功能,比如通用的对象池查找功能。
积极重置(每次存储时重置)或者延迟重置(对象使用前重置)。
重置由池(由池处理,对类来说透明)或者类(对池对象的声明者透明)来管理。
上面这些特性哪些是值得实现的呢?我们都有自己的看法。但是,我来解释一下我自己的优先级。
同样,我们假定没有其它进程正在等待你尽快释放内存。这意味着重置是可以延迟的,而且对象池没有动态减少占用内存的功能。 带有初始化和重置功能的基本对象池
我们修正的ObjectPool
[C#] 纯文本查看 复制代码
这种实现非常简单明了,参数T通过“whereT:class, new()”指定了两种限制方式。第一,T必须是一个类(毕竟,只有引用类型需要对象池),第二,它必须有无参构造函数。
构造函数使用估测的最大值作为对象池的第一个参数。其他两个参数都是可选参数。如果有值,则第一个用来重置对象池,第二个用来初始化一个新的对象池。ObjectPool
[C#] 纯文本查看 复制代码
如果你看过本系列教程的第一篇,就会知道从内存角度来说,在poolOfListOfVector3定义两个匿名委托函数是可以的。一方面,它们并非真的闭包而是“局部定义函数”,另一方面,这不重要因为对象池有类级别的作用范围。
可以让托管类型重置自身的对象池
对象池的基础版有了它应该有的功能,但它有一个概念性的缺陷。它违反了封装原则,没有把初始化/重置对象和对象类型的定义分开,导致了代码的紧耦合,这是应该要避免的。在上面的SomeClass 例子中,没有备选方案,因为我们不能改变List
[C#] 纯文本查看 复制代码
带有整体重置功能的对象池
游戏中有些数据结构可能绝不会持续超过一个序列帧,而是在每帧结束前被释放。在这种情况下,如果很好的定义了所有对象重新存入对象池的时间点,那么这个对象池将更易用,效率也会更高。让我们先看看代码。
[C#] 纯文本查看 复制代码
改写之后的版本与最初版本基本一致。只是Store()被ResetAll()取代,这样当所有创建的对象被存入对象池时只需要调用一次。在类的内部,存储所有(甚至是正在被使用的)对象引用的Stack
|