本系列文章主要讲解C#针对unity的内存管理,共有三部分,各部分内容如下:
• 第一篇文章讨论.NET和Mono垃圾回收的内存管理基本的原理。也涉及一些常见的内存泄漏来源。
•第二篇文章着重于使用工具发现内存泄漏。Unity Profiler在此方面是一个强大的工具,但它代价太高(unity5中免费)。因此我将讨论.NET反汇编和通用中间语言(CIL),以此展示如何使用免费工具发现内存泄漏。
•第三个篇文章讨论C#对象池。重点针对Unity/C# 开发中出现的特定需求。
垃圾回收的限制
大多数现代操作系统把动态内存分为堆栈和堆 (1,2) ,很多 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, Dictionary, LinkedList, etc.)命名空间中的集合类型都可以从GetEnumerator()函数执行返回一个结构。包括Mono 2.6.5正式版(Unity使用的版本)。
你可能知道可以使用微软的Visual Studio 开发然后编译成Unity/Mono兼容的代码。你只需把相应的集合拖进Assets文件夹。所有代码就会在Unity/Mono 运行时环境执行。然而,不同编译器编译的代码会有不同的结果,我现在才明白,foreach循环也是如此。虽然两个编译器都可以识别 GetEnumerator()返回的是结构体或类,Mono/C#在将枚举结构装箱为引用类型时有Bug(见下面的装箱部分)。
应该避免使用foreach循环吗?
•不要在Unity编译C#脚本代码时使用.
• 在使用标准通用集合迭代器时使用(例如List),而且使用VS或者.Net框架sdk编译代码。我猜测(没有验证)Mono最新版和MonoDevelop也可以。
使用外部编译器时,可以使用foreach循环迭代来枚举其他类型的集合吗?不幸的是,没有通用的答案。第二篇文章将讨论并找出哪些集合使用foreach是安全的。
[C#]纯文本查看复制代码
int result = 0;
void Update()
{
for (int i = 0; i < 100; i++){
System.Func
result += myFunc(i);
}
}
应该避免使用闭包和LINQ吗?
C#提供了匿名方法和lambda表达式(两者几乎但不完全相同)。你可以分别使用delegate关键字和=>操作符来创建,它们是非常方便的工具,如果你想使用某些库函数(例如 List.Sort())或LINQ很难不用到它们。
匿名方法和lambda表达式会引起内存泄露吗?答案是:这取决于C#编译器,有两种区别很大的方式来处理。想了解其中的区别,先看看下面的代码:
[C#]纯文本查看复制代码
int result = 0;
void Update()
{
for (int i = 0; i < 100; i++)
{
System.Func
result += myFunc(i);
}
}
如你所见,以上代码似乎每帧要创建100次委托函数myFunc ,每次调用它执行一次计算。但是Mono只在第一次调用Update()方法时分配堆内存(在我的系统上只用了52字节),在之后的帧也没有更多的堆分配操作。这是怎么了?使用代码反编译器(将会在下篇文章解释)可以看见C#编译器只是简单的把myFunc 替换为类的一个静态System.Func
现在我们在托管定义方式上做一些小小的改变:
[C#]纯文本查看复制代码
System.Func
通过把“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
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
{
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
• List
[C#]纯文本查看复制代码
public static class ListExtensions
{
public static void Reverse_NoHeapAlloc
{
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
原文链接:http://www.gamasutra.com/blogs/WendelinReich/20131109/203841/C_Memory_Management_for_Unity_Developers_part_1_of_3.php