unity 下的资源加载内存管理

 学习两个方面的知识  看的这里 http://www.nikest.com/web/jswd/2015/0316/145497_6.html

① .NET 和 Mono 的垃圾回收中的内存管理,和内存泄漏的常见来源。

② 进行内存泄漏发现党的UnityProfiler 和 .NET反汇编和公共中间语言CIL

一、 

   大多数现在操作系统划分动态内存为栈和堆。许多CPU架构包括PC、Mac、智能手机、平板电脑在他们的指令集支持这个区分。C#通过区分值类型和引用类型来支持它。值类型大部分时候都在栈上,引用类型分配在堆上。垃圾回收暂时先留在这里。先记录常见的C#常见的内存溢出。

 不必要的堆分配常见的原因

  foreach循环,foreach结构在编译后,它在后台创建一个enumerator,IEnumerator接口的实例。 它是创建在堆上还是在栈上呢? 这两种都是有可能的。 最主要的是System.Collections.Generic 命名空间里几乎所有的集合类型(List<T>,Dictionary<K,V>,LinkedList<T> 等) 都会根据GetEnumerator() 的实现 来返回一个struct。这也包括Mono2.6.5的所有集合版本(unity所使用的).

  MatthewHanlon指出微软现在的C#编译器和Unity正在使用编译你的脚本的老的Mono/c#编译器之间一个不幸的差异。你也许知道你可以使用MicrosoftVisualStudio来开发甚至编译Unity/Mono兼容的代码。你只需要将相应的程序集放到Assets目录下。所有代码就会在Unity/Mono运行时环境中执行。但是,执行结果还是会根据谁编译了代码不一样。Foreach循环就是这样一个例子,这是我才发现的。尽管两个编译器都会识别一个集合的GetEnumerator()返回struct还是class,但是Mono/C#有一个会把struct-enumerator装箱从而创建一个引用类型的BUG。

所以你觉得你该避免使用foreach循环吗?

  • 不要在Unity替你编译的时候使用
  • 在用最新的编译器的时候可以使用用来遍历standardgenericcollections(ListTetc.)VisualStudio或者免费的.NETFrameworkSDK都可以,而且我猜测最新版的Mono和MonoDevelop也可以。

当你在用外部编译器的时候用foreach循环来遍历其他类型的集合会怎么样?很不幸,没有统一的答案。用在第二篇帖子里提到的技术自己去发现哪些集合是可以安全使用foreach的。

你应该避免闭包和LINQ吗?

你可能知道C#提供匿名函数和lambda表达式(这两个几乎差不多但是不太一样)。你能分别用delegate关键字和=操作符创建他们。他们通常都是很有用的工具,并且你在使用特定的库函数的时候很难避免(例如ListT.Sort())或者LINQ。

匿名方法和lambda会造成内存泄露吗?答案是:看情况。C#编译器实际上有两种完全不一样的方法来处理他们。来看下面小段代码来理解他们的差异:

 int result = 0;   
 void Update(){   
 for (int i = 0; i  100 ; i++) 
{
    System.Funcint, int myFunc = (p) = p * p;
    result += myFunc(i); 
 }
}

正如你所看到的,这段代码似乎每帧创建了myFunc委托100次,每次都会用它执行一个计算。但是Mono仅仅在Update()函数第一次调用的时候分配内存(我的系统上是52字节),并且在后续的帧里不会再做任何堆的分配。怎么回事?使用代码反射器(我会在下一篇帖子里解释)就会发现C#编译器只是简单的把myFunc替换为System.Funcint,int类的一个静态域。

我们来对这个委托的定义做一点点改变:

  System.Funcint, int myFunc = (p) = p * i++;

通过把p替换成i++,我们把可以称为本地定义的函数变成了一个真正的闭包。闭包是函数式编程的核心。它们把函数和数据绑定在一起-更准确的说,是和在函数外定义的非本地变量绑定。在myFunc这个例子里,p是一个本地变量但是i不是,它属于Update()函数的作用域。C#编译器现在得把myFunc转换成可以访问甚至改变非本地变量的函数。它通过声明(后台)一个新类来代表myFunc创造时的引用环境来达到这个目的。这个类的对象会在我们每次经历for循环的时候创建,这样我们就突然有了一个巨大的内存泄露(在我的电脑上2.6kb每帧)。

当然,在C#3.0引入闭包和其他一些语言特性的主要原因是LINQ。如果闭包会导致内存泄露,那在游戏里使用LINQ是安全的吗?也许我不适合问这个问题,因为我总是像躲瘟疫一样避免使用LINQ。LINQ的一部分显然不会在不支持实时编译(jit)的系统上工作,比如IOS。但是从内存角度考虑,LINQ也不是好的选择。一个像这样基础到难以置信的表达式:

int[] array = { 1, 2, 3, 6, 7, 8 };
 void Update(){   
  IEnumerableint elements = from element in array                    
 orderby element descending                   
  where element  2     
select element; ...}

在我的系统上每帧需分配68字节(Enumerable.OrderByDescending()分配28,Enumerable.Where()40)!这里的元凶甚至不是闭包而是IEnumerable的扩展方法:LINQ必须得创建中间数组以得到最终结果,并且之后没有适当的系统来回收。虽然这么说,但我也不是LINQ方面的专家,我也不知道是否部分可以再实际中可以使用。

协程

如果你通过StartCoroutine()来启动一个协程,你就隐式创建了一个UnityCoroutine类(21字节)和一个Enumerator类(16字节)的实例。重要的是,当协程yield和resume的时候不会再分配内存,所以你只需要在游戏运行的时候限制StartCoroutine()的调用就能避免内存泄露。

字符串

对C#和Unity内存问题的概论不提及字符串是不完整的。从内存角度考虑,字符串是奇怪的,因为它们既是堆分配的又是不可变的。当你这样连接两个字符串的时候:

1 void Update(){   
2  string string1 = "Two";   
3  string string2 = "One" + string1 + "Three";
4 }

运行时必须至少分配一个新的string类型来装结果。在String.Concat()里这会通过一个叫FastAllocateString()的外部函数高效的执行,但是没有办法绕过堆分配(在我的系统里上述例子占用40字节)。如果你需要动态改变或者连接字符串,使用System.Text.StringBuilder

装箱

有时候,数据必须在堆栈和堆之间移动。例如当你格式化这样的一个字符串:

string result = string.Format("{0} = {1}", 5, 5.0f);

你是在调用这样的函数:

public static string Format(     string format, 3 params Object[] args)

  

换句话说,当调用Format()的时候整数5和浮点数5.0f必须被转换成System.Object但是Object是一个引用类型而另外两个是值类型。C#因此必须在堆上分配内存,将值拷贝到堆上去,然后处理Format()到新创建的int和float对象的引用。这个过程就叫装箱,和它的逆过程拆箱。

String.Format()来说这个行为也许不是一个问题,因为你怎样都希望它分配堆内存(为新的字符串)。但是装箱也会在意想不到的地方发生。最著名的一个例子是发生在当你想要为你自己的值类型实现等于操作符==的时候(例如,代表复数的结构)。阅读关于如果避免隐式装箱的例子点这里here。

库函数

为了结束这篇帖子,我想说许多库函数也包含隐式内存分配。发现它们最好的方法就是通过分析。最近遇到的两个有趣的例子是:

  • 之前我提到foreach循环通过大部分的标准泛集合类型并不会导致堆分配。这对DictionaryK,V也成立。然而,神奇的是,DictionaryK,V集合和DictionaryK,V.Value集合是类类型,而不是结构。意味着(KkeyinmyDict.Keys)..."需要占用16字节。真恶心!
  • ListT.Reverse()使用标准的原地数组翻转算法。如果你像我一样,你会认为这意味着不会分配堆内存。又错了,至少在Mono2.6里。有一个扩展方法你能使用,但是不像.NET/Mono版本那样优化过,但是避免了堆分配。和使用ListT.Reverse()一样使用它:
    public static class ListExtensions{    
    public static void Reverse_NoHeapAllocT(this ListT 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;        
    }    
    }}        

    对程序进行内存情况分析

  • 让我们仔细看看找到项目中非必须堆分配的两种方法。第一种方法非常简单,使用工具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查看在哪里发生堆内存分配。

    让我们看看一些示例代码,将下面脚本被附加到某个游戏对象上。

    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的一部分)。让我们从一个非常简单的方法开始,将两个整数相加。

    int AddTwoInts(int first, int second) {

    int result = first + second;

    return result;

    }

    如果你愿意,可以拷贝上面这段代码保存到MemoryAllocatingScript.cs文件。确定使用Unity来编译,然后在ILSpy中打开编译后的库文件Assembly-Csharp.dll(一般在Unity工程目录的LibraryScriptAssemblies中)。在程序集中选择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 <值类型标记>:装箱(传递数据)专用指令,在第一部分已经介绍过。

    我们来看一个使用了上面三种分配类型的方法,代码如下:

    • 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()

    正如我们怀疑的那样,使用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的版本号。

原文地址:https://www.cnblogs.com/bambomtan/p/5020127.html