1、C#和Mono
垃圾回收如何工作以及何时被触发,我们首先需要了解底层的一些知识。
1)Unity主要采用C#作为脚本语言来开发的,这和使用C++开发需要随时管理内存相比,有一定的优势,当然带来的劣势就是需要随时关注内存的增长,否则容易有内存性能问题。C#语言最终会编译成CIL代码,而只要任何一个平台实现了CLR,通用语言运行平台,简称Common Language Runtime,那么这个平台就可以解析和运行CIL,对这里来说的话也就是可以运行C#语言。Mono就是CLR的一种实现,并且实现了可跨平台运行。C#本身实现了GC机制,管理C#的对象。Unity使用的垃圾收集(GC)机制是由Mono实现的,回收原理和C#的GC差不多,和C#的GC一样,也提供了System.GC.Collect()方法。下面讨论的是Mono的GC。
2、Mono的GC机制
GC机制使用的是标记法来控制内存的使用情况,与之经常一起提起的另外一种办法是计数法。unity的自动内存管理可以理解为以下几个部分:
- 1)unity内部有两个内存管理池:堆内存和堆栈内存。堆栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。
- 2)unity中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在堆栈内存上,要么处于堆内存上。
- 3)只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。
- 4)一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中被再次使用,这样的操作就是内存回收。处于堆栈上的内存回收及其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使用状态。
- 垃圾回收主要是指堆上的内存分配和回收,unity中会定时对堆内存进行GC操作。
在了解了GC的过程后,再回顾一下上一篇文章中的内容。
在第一篇优化文章中,有提到如果按照分配方式来划分的话,可以分为Native Memory和Managed Memory。从这个角度,如何使用这两种内存以及释放,已经有提及,Native Memory相关内存的优化方法以及释放方法也已经提及。这个文章主要就是优化Managed Memory的,这部分的内存主要管理者实际是GC机制,而这部分内存,从内存的存储类型来看的话,又分为堆栈内存和堆内存。下面详细了解堆内存和堆栈内存的分配和回收机制的差别。
3.1堆栈内存分配和回收机制
堆栈上的内存分配和回收十分快捷简单,因为堆栈上只会存储短暂的或者较小的变量。内存分配和回收都会以一种顺序和大小可控制的形式进行。
堆栈的运行方式就像[stack]: 其本质只是一个数据的集合,数据的进出都以一种固定的方式运行。正是这种简洁性和固定性使得堆栈的操作十分快捷。当数据被存储在堆栈上的时候,只需要简单地在其后进行扩展。当数据失效的时候,只需要将其从堆栈上移除。
3.2堆内存分配
堆内存上的内存分配和存储相对而言更加复杂,主要是堆内存上可以存储短期较小的数据,也可以存储各种类型和大小的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不同大小的内存单元来存储数据。
堆上的变量在存储的时候,主要分为以下几步:
- 1)首先,unity检测是否有足够的闲置内存单元用来存储数据,如果有,则分配对应大小的内存单元;
- 2)如果没有足够的存储单元,unity会触发垃圾回收来释放不再被使用的堆内存。这步操作是一步缓慢的操作,如果垃圾回收后有足够大小的内存单元,则进行内存分配。
- 3)如果垃圾回收后并没有足够的内存单元,则unity会扩展堆内存的大小,这步操作会很缓慢,然后分配对应大小的内存单元给变量。
堆内存的分配有可能会变得十分缓慢,特别是在需要垃圾回收和堆内存需要扩展的情况下,通常需要减少这样的操作次数。
4.垃圾回收时的操作
当堆内存上一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存只会在GC的时候才会被回收。
每次运行GC的时候,主要进行下面的操作:
1)GC会检查堆内存上的每个存储变量;
2)对每个变量会检测其引用是否处于激活状态;
3)如果变量的引用不再处于激活状态,则会被标记为可回收;
4)被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。
5. 何时会触发垃圾回收
主要有三个操作会触发垃圾回收:
1) 在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存;
2) GC会自动的触发,不同平台运行频率不一样;
3) GC可以被强制执行。
特别是在堆内存上进行内存分配时内存单元不足够的时候,GC会被频繁触发,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。
GC操作带来的问题
在了解GC在unity内存管理中的作用后,我们需要考虑其带来的问题。最明显的问题是GC操作会需要大量的时间来运行,如果堆内存上有大量的变量或者引用需要检查,则检查的操作会十分缓慢,这就会使得游戏运行缓慢。其次GC可能会在关键时候运行,例如在CPU处于游戏的性能运行关键时刻,此时任何一个额外的操作都可能会带来极大的影响,使得游戏帧率下降,另外一个GC带来的问题是堆内存的碎片化。
6.降低GC的影响的方法
大体上来说,我们可以通过三种方法来降低GC的影响:
1)减少GC的运行次数;
2)减少单次GC的运行时间;
3)将GC的运行时间延迟,避免在关键时候触发,比如可以在场景加载的时候调用GC
7.代码层面减少内存垃圾数量
1、需要反复分配内存的遍历,做成外部的全局变量,实现反复利用而不需要造成更多的内存垃圾。
2、
void Update()
{
List myList = new List();
PopulateList(myList);
}
改成:
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
3、字符串变更较多使用StringBuilde替代。
4、减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。
5、尽量避免装箱操作。
装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。比如String.Format()函数需要传入字符串和对象类型参数,如果传入字符串和int类型数据,就会触发装箱操作。如下面代码所示:
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format("Price:{0} gold",cost);
}
6、协程
yield在协程中不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如:
1
yield return 0;
由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:
1
yield return null;
另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:
while(!isComplete)
{
yield return new WaitForSeconds(1f);
}
我们可以采用缓存来避免这样的内存垃圾产生:
WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
yield return delay;
}