本系列文章主要讲解C#针对unity的内存管理,共有三部分,各部分内容如下:
•  第一篇文章讨论.NET和Mono垃圾回收的内存管理基本的原理。也涉及一些常见的内存泄漏来源。
•  第二篇文章着重于使用工具发现内存泄漏。Unity Profiler在此方面是一个强大的工具,但它代价太高(unity5中免费)。因此我将讨论.NET反汇编和通用中间语言(CIL),以此展示如何使用免费工具发现内存泄漏。
•  第三个篇文章讨论C#对象池。重点针对Unity/C# 开发中出现的特定需求。
 
 
 
垃圾回收的限制
 
 
大多数现代操作系统把动态内存分为堆栈和堆 (12) ,很多 CPU体系结构(包括PC 和 Mac以及智能手机/平板电脑) 在其指令集支持这种划分。C# 通过区分值类型(简单的内置类型以及用户定义的enum或struct类型)和引用类型(类、 接口和委托)。值类型在堆栈上分配,引用类型在堆上分配。堆栈在开启新线程时被设置为一个固定值。它通常很小— —例如,在Windows上的.NET线程默认堆栈大小为1Mb。这种内存用于加载线程的主要功能和局部变量,以及一些频繁加载和释放的函数(与局部变量一起)被主线程调用。其中一些可能会被映射到CPU的缓存中以加快访问速度。只要函数调用深度不是过高或局部变量使用内存不多,就不必担心堆栈溢出。堆栈的这种用法很符合结构化编程的概念。
 
 
如果对象太大,无法放在堆栈中,或者如果它们的生命周期比创建它们的函数还久那么就需要使用堆。堆是除堆栈外的内存的一部分,(通常)按照自己的需求向OS申请。堆栈内存管理很简单(只需要使用一个指针记住内存块开始的位置),与之相比堆碎片在分配对象时出现,释放时回收。可以把堆看作瑞士奶酪,你必须记住所有的小孔(瑞士奶酪一般有很多小孔,记住这么多小孔是多么复杂)!内存管理自动化主要任务是辅助你跟踪所有的孔,几乎所有现代编程语言都支持。更难的工作是内存自动释放,尤其是决定在什么时候该释放对象。这样就不用去自己管理内存了。
 
 
后面的工作称为垃圾回收(GC)。不用自己告诉运行时环境何时释放对象内存,运行时自己会跟踪所有对象的引用,从而决定释放时机。GC按照一定的时间间隔工作,当对象不会被任何代码访问到时,这个对象会被销毁,占用的内存会被释放,GC会回收此对象。至今仍有很多学者在研究GC,这就是为什么自从.NET框架1.0开始到现在GC的架构已经有了巨大的改变和提升。虽然Unity不使用.NET,但是毕竟.Net开源,Mono也算是.Net近亲,而Mono也一直落后于它的商业对手。此外,Unity默认的Mono版本不是最新的2.11/3.0而是2.6版本(准确的说是2.6.5,我使用Windows版Unity4.2.2和Unity4.3)。
 
 
Mono 2.6之后的版本的一个重大修订是关于GC,新版本使用的是generational GC,而2.6版本使用的是不太负责的Boehm garbage collector。现代generational GC表现很好,可以应用于实时程序,例如游戏。而Boehm风格GC是通过相对不太常见的穷举法每隔一段时间搜索堆上的垃圾(不会被引用的对象)。因此,它有种趋势,每隔一段时间产生帧率性能下降,从而影响用户体验。Unity文档推荐每当游戏进入帧率降低的下一阶段最好自己主动调用System.GC.Collect()(例如,加载新场景、显示菜单)。然而,对于很多游戏都很少出现这样的时候,这意味着GC在你想调用之前已经执行内存回收了。这种情况下,你只能咬紧牙关自己管理内存。这也是本文和接下来两篇文章要探讨的问题
 
 
自己制作内存管理器
 
 
我们首先应该清楚什么是在Unity/.Net世界“自己管理内存”。你自己去跟踪分配了多少内存的能力是有限的。你需要选择自定义的数据结构是类(通常在堆上分配)还是结构体(通常在堆栈分配,除非是类的成员变量)。如果你想要更多魔力,你需要使用C#的“unsafe”关键字。但是unsafe代码是无法校验的代码,意味着它不能在unity web player和其它可能的目标平台中运行,基于这些种种原因,最好不用“unsafe”。由于上述那些堆栈的限制,而且因为C#数组只是System.Array(是个类)的语法封装,你不能也不应该避免使用自动堆分配功能。你需要避免的只是不必要的堆分配。我们将在后面的文章中介绍这方面的知识。
 
 
当对象被释放之后你所能做的相当有限。事实上,唯一可以释放分配堆对象的只是GC,GC的工作对开发者是不可见的。你能影响的只是堆中对象最后的引用失效的时间,因为GC只会在对象不再被引用时去访问它们。这种限制有很强的实际意义,因为周期性的垃圾回收(你无法避免)在没有对象需要释放时效率非常快。
 
 
[C#] 纯文本查看 复制代码

foreach (SomeType s in someList)

    s.DoSomething();

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

using (SomeType.Enumerator enumerator = this.someList.GetEnumerator())

{

    while(enumerator.MoveNext()){

        SomeType s = (SomeType) enumerator.Current;

        s.DoSomething();

    }

}

常见的非必须堆分配原因
 
 
我们应该避免使用“foreach”循环吗?
一般建议是避免使用foreach循环,尽量使用for或者while循环,我在Unity论坛遇到很多人提到这个建议。这背后的原因咋一看似乎是合理的,foreach只是语法封装,因为编译器处理代码的流程大体是下面这样:
 
[C#] 纯文本查看 复制代码

foreach (SomeType s in someList)

    s.DoSomething();

转换为如下:
 
[C#] 纯文本查看 复制代码

using (SomeType.Enumerator enumerator = this.someList.GetEnumerator())

{

    while (enumerator.MoveNext())

    {

        SomeType s = (SomeType)enumerator.Current;

        s.DoSomething();

    }

}

换句话说,每次使用foreach都会创建一个enumerator对象,一个System.Collections.IEnumerator接口实例。但这创建在堆栈还是堆上呢?这是一个很好的问题。因为,都有可能。最重要的是,几乎所有System.Collections.Generic(List<T>, Dictionary<K,V>, LinkedList<T>, etc.)命名空间中的集合类型都可以从GetEnumerator()函数执行返回一个结构。包括Mono 2.6.5正式版(Unity使用的版本)。
 
 
你可能知道可以使用微软的Visual Studio 开发然后编译成Unity/Mono兼容的代码。你只需把相应的集合拖进Assets文件夹。所有代码就会在Unity/Mono 运行时环境执行。然而,不同编译器编译的代码会有不同的结果,我现在才明白,foreach循环也是如此。虽然两个编译器都可以识别 GetEnumerator()返回的是结构体或类,Mono/C#在将枚举结构装箱为引用类型时有Bug(见下面的装箱部分)。
 
 
应该避免使用foreach循环吗?
 
 
•  不要在Unity编译C#脚本代码时使用.
•  在使用标准通用集合迭代器时使用(例如List<T>),而且使用VS或者.Net框架sdk编译代码。我猜测(没有验证)Mono最新版和MonoDevelop也可以。
使用外部编译器时,可以使用foreach循环迭代来枚举其他类型的集合吗?不幸的是,没有通用的答案。第二篇文章将讨论并找出哪些集合使用foreach是安全的。
 
[C#] 纯文本查看 复制代码

int result = 0;

void Update()

{

    for (int i = 0; i < 100; i++){

        System.Func<int, int> myFunc = (p) => p * p;

        result += myFunc(i);

    }

}

应该避免使用闭包和LINQ吗?
 
 
C#提供了匿名方法和lambda表达式(两者几乎但不完全相同)。你可以分别使用delegate关键字和=>操作符来创建,它们是非常方便的工具,如果你想使用某些库函数(例如 List<T>.Sort())或LINQ很难不用到它们。
 
匿名方法和lambda表达式会引起内存泄露吗?答案是:这取决于C#编译器,有两种区别很大的方式来处理。想了解其中的区别,先看看下面的代码:
 
[C#] 纯文本查看 复制代码

int result = 0;

void Update()

{

    for (int i = 0; i < 100; i++)

    {

        System.Func<int, int> myFunc = (p)=> p * p;

        result += myFunc(i);

    }

}

如你所见,以上代码似乎每帧要创建100次委托函数myFunc ,每次调用它执行一次计算。但是Mono只在第一次调用Update()方法时分配堆内存(在我的系统上只用了52字节),在之后的帧也没有更多的堆分配操作。这是怎么了?使用代码反编译器(将会在下篇文章解释)可以看见C#编译器只是简单的把myFunc 替换为类的一个静态System.Func<int, int> 类型字段,包括 Update()函数也是。这个字段的名字很奇怪但是也有一些意义:f__am$cache1(不同系统上可能会有差别),也就是说,托管方法只分配一次,然后就被缓存了。
 
 
现在我们在托管定义方式上做一些小小的改变:
 
[C#] 纯文本查看 复制代码
System.Func<int, int> myFunc = (p) => p * i++;
通过把“p”替换为“i++”,我们已经局部定义方法转变成一个真的闭包。闭包是函数编程的一大支柱。它把数据和函数联系到一起,更准确的说是非局部变量在函数之外定义。在myFunc中,p是一个局部变量但i是一个非局部变量,属于Update()方法。C#编译器现在不得不将myFunc转换成可访问、甚至是可修改,包含非局部变量的方法。它通过声明一个全新的类表示myFunc创建的引用来实现这一功能。For循环每次执行都要分配一个类的实例,瞬间产生大量的内存泄露(在我的电脑上每帧26KB)。
 
 
当然,闭包和其他语言特性在C# 3.0被引入的主要原因是LINQ。如果闭包可以引起内存泄露,在游戏中使用LINQ还安全码?我不是能回答这个问题的最佳人选,因为,我总是像躲避瘟疫一样避免使用LINQ。LINQ明显在不支持即时编译的操作系统上无法工作,例如iOS。但是从内存角度来说,LINQ弊大于利。下面是一个难以置信的基础表达式:
 
[C#] 纯文本查看 复制代码

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;

    ...

}

在我的系统上每帧分配了68字节(Enumerable.OrderByDescending() 占用了28字节, Enumerable.Where() 40字节)。罪魁祸首不是闭包而是IEnumerable的扩展方法:LINQ创建了中间数组来实现最终结果,而且也没有一个系统在之后回收它们。我不是LINQ专家,我并不知道是否可以在实时环境中安全的使用其组件。
 
 
协程
 
 
如果你通过 StartCoroutine()运行一个协程,则会隐式分配Unity Coroutine (在我系统上占用21字节)类和Enumerator(占用16字节)。重要的是当协程调用yiled和恢复时不会分配。所以,为了避免内存泄露,在游戏运行时尽量少用StartCoroutine()。
 
[C#] 纯文本查看 复制代码

void Update()

{

    string string1 = "Two";

    string string2 = "One" + string1 + "Three";

}

字符串
 
 
C#和Unity内存问题必须会提到字符串。从内存角度,字符串很奇怪,因为,他们是堆分配且不变的。当你连接两个字符串时(无论是变量还是常量):
 
[C#] 纯文本查看 复制代码

void Update()

{

    string string1 = "Two";

    string string2 = "One" + string1 + "Three";

}

运行时不得不分配至少一个新的字符串对象存储新的结果。String.Concat()有效地通过调用FastAllocateString()分配新对象,但是必定会产生多余的堆分配(上面例子在我的系统上占用40字节)。如果你需要在运行时修改或者连接字符串,最好使用 System.Text.StringBuilder。
 
 
装箱
 
 
有时,数据必须在堆栈和堆之间传输。例如:
 
[C#] 纯文本查看 复制代码
 
string result = string.Format("{0} = {1}", 5, 5.0f);
 
你正在调用下面的签名方法:
 
[C#] 纯文本查看 复制代码
 
public static string Format(string format, params Object[] args )
 
 
换句话说,当调用Format()时整数“5”和浮点数“5.0f”必须被强制转成System.Object。但是,对象是引用类型,而其它两个是值类型。因此C#不得不在堆上分配内存,将值复制到堆上,把新的int和float对象的引用传递给Format()。这个流程就叫做装箱,反之对应的过程叫开箱。
 
 
这种行为也许不是 String.Format()的问题,因为你知道它会在堆上分配内存。
 
但是,装箱很少以预期情况出现。一个臭名昭著的例子是,你使用“==”运算符判断自定义值类型(例如,一个表示复数的结构体)。所有避免装箱例子见这里
 
[C#] 纯文本查看 复制代码

public static class ListExtensions

{

    public static void Reverse_NoHeapAlloc<T>(this List<T> list)

    {

        int count = list.Count;


  
        for (int i = 0; i < count / 2; i++)

        {


            T tmp = list[/color][/font][font=微软雅黑][color=#000000][i];[/i]

            list[/color][/font][font=微软雅黑][color=#000000][i] = [/i]list[count - i - 1];

            list[count - i - 1] = tmp;

        }

    }

}

库方法
 
 
最后讲一下各种库方法也会隐式分配内存。捕捉它们最好的方法是分析。最近碰到的有趣例子是:
•     我之前已经写过的 foreach-循环,大多数标准泛型集合不会导致堆分配。Dictionary <K、 V>也是。然而,有点神秘地,Dictionary <K、 V>.KeyCollection和Dictionary <K、 V>.ValueCollection是类,不是结构,这意味着"foreach(K key in myDict.Keys)......"会分配16个字节。
•     List <T>.Reverse()使用标准替换数组反转算法。如果你和我一样认为它不分配堆内存,那就错了,至少Mono 2.6版本会。可以使用下面的扩展方法,虽然它可能不如.NET/Mono优化的好,但至少可以避免堆内存分配。用法与List<T>.Reverse()相同:
 
[C#] 纯文本查看 复制代码

public static class ListExtensions

{

    public static void Reverse_NoHeapAlloc<T>(this List<T> list)

    {

        int count = list.Count;

  
        for (int i = 0; i < count / 2; i++)

        {

            T tmp = list[/color][/font][font=微软雅黑][color=#000000];

            list[/color][/font][font=微软雅黑][color=#000000] = list[count - i - 1];

            list[count - i - 1] = tmp;

        }

    }

}

还有其他一些内存陷阱可以探讨。然而,授人以鱼不如授人以渔。这是下篇文章将要讨论的问题!
 
 
 
 
 
原文作者:Wendelin Reich
 
 
 
感谢蛮牛译员“Jingli”对本文翻译所做的贡献~~~

本文由蛮牛译馆倾情奉献,除 合作社区 及 合作媒体 外,禁止转载。
 
关注蛮牛教育,更新中...