Unity零碎知识点
1.Unity协程的原理
协程不是多线程,协程还是在主线程里面(注:在Unity中非主线程是不可以访问Unity资源的)
线程、进程和协程的区别
进程有自己独立的堆和栈,即不共享堆也不共享栈,进程由操作系统调度
线程拥有自己独立的栈和共享的堆,共享堆不共享栈,线程亦由操作系统调度(标准线程是这样的)
协程和线程一样共享堆不共享栈,协程由程序员在协程的代码里面显示调度
一个应用程序一般对应一个进程,一个进程一般有一个主线程,还有若干个辅助线程,线程之间是平行的,在线程里面可以开启协程,让程序在特定的时间内运行。
协程和线程的区别是:协程避免了无意义的调度,由此可以提高性能,但也因此,程序员必须自己承担调度的责任,同时,协程也失了标准线程使用多CPU的能力。
Unity中协程执行的原理
Unity生命周期函数:
在Unity运行时,调用协程就是开启了一个IEnumerator(迭代器),协程开始执行,在执行到yield return之前和其他的正常的程序没有差别,但是当遇到yield return之后会立刻返回,并将该函数暂时挂起。在下一帧遇到FixedUpdate或者Update之后判断yield return 后边的条件是否满足,如果满足向下执行。
根据unity主线的框架运行图我们知道,协同程序主要是在update()方法之后,lateUpdate()方法之前调用。
Unity生命周期对协程的影响
通过设置MonoBehaviour脚本的enabled对协程是没有影响的,但如果gameObject.SetActive(false) 则已经启动的协程则完全停止了,即使在Inspector把gameObject 激活还是没有继续执行。也就说协程虽然是在MonoBehvaviour启动的(StartCoroutine)但是协程函数的地位完全是跟MonoBehaviour是一个层次的,不受MonoBehaviour的状态影响,但跟MonoBehaviour脚本一样受gameObject 控制,也应该是和MonoBehaviour脚本一样每帧“轮询” yield 的条件是否满足。
注:WaitForSends()受Time.timeScale影响,当Time.timeScale = 0f时,yieldreturn new WaitForSecond(X)将不会满足。
协程的主要应用
协程不是只能做一些简单的延迟,如果只是单纯的暂停几秒然后在执行就完全没有必要开启一个线程。
协程的真正作用是分步做一些比较耗时的事情,比如加载游戏里的资源。
2.C#中的虚方法、抽象方法、抽象类以及接口
多态
C# OOP(面向对象)的三大原则:封装、继承、多态。
在面向对象语言中,接口的多种不同的实现方式即为多态。引用Charlie Calverts对多态的描述——多态性是允许你将父对象设置成为一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作(摘自“Delphi4编程技术内幕”)。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。多态性在Object Pascal和C++中都是通过虚函数实现的。
用我自己的理解来说:多态就是在继承的前提下,不同对象调用相同方法却表现出不同的行为,此为多态。
关键性的一句话:多态性在C++中是通过虚函数实现的,这在C#中同样适用。但是在C#中有三种方法来体现:虚方法,抽象类,接口。
虚方法 Virtual
虚方法存在于相对于需要实现多态的子类的父类中,同时也是最基本的实现多态的方法。
具体的语法是在父类中用virtual修饰,然后在子类中使用override进行重写。
抽象方法以及抽象类 Abstract
存在于父类中的虚方法是有自己的方法体的,而且这些方法体是必要的,少了他们就无法完成逻辑,这种情况需要使用虚方法。
抽象方法必须存在于抽象类中,抽象类的具体语法是类名前加上abstract。
抽象方法没有方法体,且所有继承了抽象类的子类必须重写所有的抽象方法。
抽象类中可以包括普通方法,并且抽象类不能被实例化。
抽象类的使用场景:
1.父类方法不知道如何去实现;
2.父类没有默认实现且不需要实例化
接口 Interface
接口是指定一组函数成员而不实现他们的引用类型。所以只能类和结构来实现接口,在继承该接口的类里面要实现接口的所有方法。
接口的作用就是实现某些类的特殊功能。
1.接口声明不能包含以下成员:
数据成员、静态成员。
2.接口声明只能包含如下类型的非静态成员函数的声明:
方法、属性、事件、索引器。
3.这些函数成员的声明不能包含任何实现代码,而在每一个成员声明的主体后必须使用分号。
3.C#中值类型与引用类型的区别
在C#中值类型的变量直接存储数据,而引用类型的变量持有的是数据的引用,数据存储在数据堆中。
值类型(value type):byte,short,int,long,float,double,decimal,char,bool 和 struct 统称为值类型。值类型变量声明后,不管是否已经赋值,编译器为其分配内存。
引用类型(reference type):string 和 class 统称为引用类型。当声明一个类时,只在栈中分配一小片内存用于容纳一个地址,而此时并没有为其分配堆上的内存空间。当使用 new 创建一个类的实例时,分配堆上的空间,并把堆上空间的地址保存到栈上分配的小片空间中。
值类型的实例通常是在线程栈上分配的(静态分配),但是在某些情形下可以存储在堆中。引用类型的对象总是在进程堆中分配(动态分配)。
- 数据在哪里声明,就存储在哪里。
方法中声明:值类型数据存储在栈中,引用类型的引用存储在栈中,数据存储在堆中;方法在栈中执行,在方法内声明的变量都是在栈中存储,方法执行完毕后将这些数据清除,方法内部的值类型将直接被清除,引用类型将被清除引用,而存储在堆中的数据则等待GC自动回收。
类中:值类型数据存储在堆中,引用类型的引用和数据都存储在堆中。 - 内存区域上的区别
值类型:数据存储在栈上,超出作用域就自动清理
引用类型:数据存储在托管堆上,引用地址在线程栈上,地址指向数据存放的堆上
托管堆会由GC来自动释放 ,线程栈数据在作用域结束后会被清理。 - 拷贝策略:值类型是拷贝数据,引用类型是拷贝引用地址
如果值类型为传值参数,传值参数会在栈上新开辟一个副本,原先的值类型数据不会改变
如果引用类型是传值参数,传值参数会创建一个新的引用地址,两个引用地址会指向同一个对象实例的数据,实例数据会随着改变进行改变。(这种行为被称为副作用,一般实际项目不会这么操作,要么return返回参数,要么使用ref或者out修饰符)
【扩展Ref引用参数,Out输出参数可以利用这一副作用机制】值类型:包含了所有简单类型(整数、浮点、bool、char)、struct、enum。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Parameters
{
class Program
{
static void Main(string[] args)
{
Dowork();
}
static void Dowork()
{
int i = 0; // int 是值类型
Console.WriteLine(i); // i = 0
Pass.value(i); // 值类型使用的是 i 的副本,i不变
Console.WriteLine(i); // i = 0
WrappendInt wi = new WrappendInt(); // 创建类 WrappendInt 的另外一个实例
Console.WriteLine(wi.Number); // 0 // 被默认构造器初始化为 0
Pass.Reference(wi); // 调用方法,wi 和 param 将引用同一个对象
Console.WriteLine(wi.Number); // 42
}
}
class Pass
{
public static void value(int param)
{
param = 42; // 赋值操作使用的是值类型参数的一个副本,原始参数不受影响
}
public static void Reference(WrappendInt param) // 创建类 WrappendInt 的一个实例
{
param.Number = 42; // 此参数是引用类型的参数
}
}
class WrappendInt // 类是引用类型
{
public int Number;
}
}
/*输出结果
* 0
* 0
* 0
* 42
*/
继承自System.ValueTyoe
引用类型包含了string,object,class,interface,delegate,array
继承自System.Object
4.值类型和引用类型互相转换:拆箱和装箱
装箱:值类型====》引用类型object
1.分配内存堆
2.值类型数据拷贝到新的内存堆中
3.栈中分配一个新的引用地址指向内存堆
拆箱:引用类型object====》值类型
1.检查确保对象是给定值类型的一个装箱值
2.将该值数据复制到栈中的值类型
string是特殊的引用类型,如果传入参数是string,在方法里修改,原string数值不变。
原因是string的不变性,系统内部做了特殊处理。
5.Foreach遍历原理
任何集合类(Array)对象都有一个GetEnumerator()方法,该方法可以返回一个实现了 IEnumerator接口的对象。
这个返回的IEnumerator对象既不是集合类对象,也不是集合的元素类对象,它是一个独立的类对象。
通过这个实现了 IEnumerator接口对象A,可以遍历访问集合类对象中的每一个元素对象
对象A访问MoveNext方法,方法为真,就可以访问Current方法,读取到集合的元素。
1 | List<string> list = new List<string>() { "1", "2", "3", "4" }; |
6.反射的实现原理
定义:运行时,动态获取类型信息,动态创建对象,动态访问成员的过程。
另一种定义:审查元数据并收集元数据的信息。
元数据:编译后的最基本数据单元,就是一堆表,反射就是解析这些元数据。
反射是在运行期间获取到类、对象、方法、数据的一种手段
主要使用类库System.Reflection
反射要点:如何获取类型,根据类型来动态创建对象,反射获取方法以及动态调用方法,动态创建委托
一、动态获取类型信息
1.System.Reflection.Assembly.Load(“XXXX.dll”) 动态加载程序集
2.System.Type.GetType(“XXXX类名”); //动态获取某程序集中某类信息
3.obj.GetType(); //已知对象获取类信息 ——或者——typeof(类型) //已知类类型
二、动态创建对象实例(上一步操作后获得类对象)
System.Activator.CreateInstance(Type type);
三、动态访问成员调用方法(上一步操作后已获取实例对象)
System.Reflection.MethodInfo method = type.GetMethod(“方法名”);//获得方法
System.Reflection.MethodInfo.Invoke(object , new object[]{参数}) //调用的类实例和实例参数
核心类
System.Reflection.Assembly 描述程序集
System.Type 描述类
System.Reflection.FieldInfo 描述了类的字段
System.Reflection.ConstructorInfo 描述构造函数
System.Reflection.MethodInfo 描述类的方法
System.Reflection.PropertyInfo 描述类的属性
反射耗性能,lua是动态语言,一种小巧的脚本语言,会使用反射机制。
Unity GC垃圾回收
GC (garbage collection)简介
在游戏运行的时候,数据主要存储在内存中,当游戏的数据在不需要的时候,存储当前数据的内存就可以被回收以再次使用。内存垃圾是指当前废弃数据所占用的内存,垃圾回收(GC)是指将废弃的内存重新回收再次使用的过程。
Unity中将垃圾回收当作内存管理的一部分,如果游戏中废弃数据占用内存较大,则游戏的性能会受到极大影响,此时垃圾回收会成为游戏性能的一大障碍点。
Unity内存管理机制简介
Unity主要采用自动内存管理的机制,开发时在代码中不需要详细地告诉unity如何进行内存管理,unity内部自身会进行内存管理。这和使用C++开发需要随时管理内存相比,有一定的优势,当然带来的劣势就是需要随时关注内存的增长。
unity的自动内存管理可以理解为以下几个部分:
1.unity内部有两个内存管理池:堆内存和堆栈内存。堆栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。
2.unity中的变量只会在堆栈或者堆内存上进行内存分配,值类型变量都在堆栈上进行内存分配,其他类型的变量都在堆内存上分配。
3.只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。
4.一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中被再次使用,这样的操作就是内存回收。处于堆栈上的内存回收及其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使用状态。
5.垃圾回收主要是指堆上的内存分配和回收,unity中会定时对堆内存进行GC操作。
栈内存分配和回收机制
堆栈上的内存分配和回收十分快捷简单,因为堆栈上只会存储短暂的或者较小的变量。内存分配和回收都会以一种顺序和大小可控制的形式进行。
堆栈的运行方式就像stack: 其本质只是一个数据的集合,数据的进出都以一种固定的方式运行。正是这种简洁性和固定性使得堆栈的操作十分快捷。当数据被存储在堆栈上的时候,只需要简单地在其后进行扩展。当数据失效的时候,只需要将其从堆栈上移除。
堆内存分配和回收机制
堆内存上的内存分配和存储相对而言更加复杂,主要是堆内存上可以存储短期较小的数据,也可以存储各种类型和大小的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不同大小的内存单元来存储数据。
堆上的变量在存储的时候,
1.首先unity会先检测是否有足够的闲置内存单元用来存储数据,如果有,则分配对应大小的内存单元;
2.如果没有,就触发垃圾回收(GC)来释放不再被使用的堆内存(缓慢的操作),如果垃圾回收后有足够大小的内存单元,则进行内存分配。
3.如果还不够,则会扩展堆内存的大小(缓慢的操作),最后分配对应大小的内存单元给变量。
堆内存的分配有可能会变得十分缓慢,特别是在需要垃圾回收和堆内存需要扩展的情况下,通常需要减少这样的操作次数。
GC相关的一些信息
GC的操作过程:
当堆内存上一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存只会在GC的时候才会被回收。其操作如下
1.GC会检查堆内存上的每个存储变量;
2.对每个变量会检测其引用是否处于激活状态;
3.如果变量的引用不再处于激活状态,则会被标记为可回收;
4.被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。
何时触发GC:
主要有三个操作会触发垃圾回收:
1.在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存;
2.GC会自动的触发,不同平台运行频率不一样;
3.GC可以被强制执行。
特别是在堆内存上进行内存分配时内存单元不足够的时候,GC会被频繁触发,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。
GC操作带来的问题:
1.需要大量的时间来运行,可能会使得游戏运行缓慢。其次GC可能会在关键时候运行,例如在CPU处于游戏的性能运行关键时刻,此时任何一个额外的操作都可能会带来极大的影响,使得游戏帧率下降。
2.堆内存的碎片划。当一个内存单元从堆内存上分配出来,其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎片化的单元。也就是说堆内存总体可以使用的内存单元较大,但是单独的内存单元较小,在下次内存分配的时候不能找到合适大小的存储单元,这也会触发GC操作或者堆内存扩展操作。
堆内存碎片会造成两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加频繁地被触发。
利用profiler window 来检测堆内存分配(unity工具栏Window->Profiler打开):
在CPU usage分析窗口中,我们可以检测任何一帧cpu的内存分配情况。其中一个列是GC Alloc,通过分析其来定位是什么函数造成大量的堆内存分配操作。一旦定位该函数,我们就可以分析解决其造成问题的原因从而减少内存垃圾的产生。现在Unity5.5的版本,还提供了deep profiler的方式深度分析GC垃圾的产生。
优化方案
降低GC影响的方法
1.减少GC的运行次数;
2.减少单次GC的运行时间;
3.将GC的运行时间延迟,避免在关键时候触发,比如可以在场景加载的时候调用GC。
主要策略为:
1.对游戏进行重构,减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数从而提高GC的运行效率。
2.降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说更少的事件触发GC操作,同时也降低堆内存的碎片化。
3.我们可以试着测量GC和堆内存扩展的时间,使其按照可预测的顺序执行。当然这样操作的难度极大,但是这会大大降低GC的影响。
具体如下:
减少内存垃圾的数量
- 1.缓存
如果在代码中反复调用某些造成堆内存分配的函数但是其返回结果并没有使用,这就会造成不必要的内存垃圾,我们可以缓存这些变量来重复利用,这就是缓存。
例如下面的代码每次调用的时候就会造成堆内存分配,主要是每次都会分配一个新的数组:对比下面的代码,只会生产一个数组用来缓存数据,实现反复利用而不需要造成更多的内存垃圾:1
2
3
4void OnTriggerEnter(Collider other) {
Renderer[] allRenderers = FindObjectsOfType<Renderer>();
ExampleFunction(allRenderers);
}1
2
3
4
5
6
7
8
9Renderer[] allRenderers;
void Start() {
allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other) {
ExampleFunction(allRenderers);
} - 2.不要在频繁调用的函数中反复进行堆内存分配
在MonoBehaviour中,如果我们需要进行堆内存分配,最坏的情况就是在其反复调用的函数中进行堆内存分配,例如Update()和LateUpdate()函数这种每帧都调用的函数,这会造成大量的内存垃圾。我们可以考虑在Start()或者Awake()函数中进行内存分配,这样可以减少内存垃圾。
下面的例子中,update函数会多次触发内存垃圾的产生:通过一个简单的改变,我们可以确保每次在x改变的时候才触发函数调用,这样避免每帧都进行堆内存分配:1
2
3void Update() {
ExampleGarbageGenerationFunction(transform.position.x);
}另外的一种方法是在update中采用计时器,特别是在运行有规律但是不需要每帧都运行的代码中,例如:1
2
3
4
5
6
7
8
9float previousTransformPositionX;
void Update() {
float transformPositionX = transform.position.x;
if(transfromPositionX != previousTransformPositionX) {
ExampleGarbageGenerationFunction(transformPositionX);
previousTransformPositionX = trasnformPositionX;
}
}1
2
3
4
5
6
7
8
9float timeSinceLastCalled;
float delay = 1f;
void Update() {
timSinceLastCalled += Time.deltaTime;
if(timeSinceLastCalled > delay) {
ExampleGarbageGenerationFunction();
timeSinceLastCalled = 0f;
}
} - 3.清除链表
在堆内存上进行链表的分配的时候,如果该链表需要多次反复的分配,我们可以采用链表的clear函数来清空链表从而替代反复多次的创建分配链表。通过改进,我们可以将该链表只在第一次创建或者该链表必须重新设置的时候才进行堆内存分配,从而大大减少内存垃圾的产生:1
2
3
4void Update() {
List myList = new List();
PopulateList(myList);
}1
2
3
4
5List myList = new List();
void Update() {
myList.Clear();
PopulateList(myList);
} - 4.对象池
即便我们在代码中尽可能地减少堆内存的分配行为,但是如果游戏有大量的对象需要产生和销毁依然会造成GC。对象池技术可以通过重复使用对象来降低堆内存的分配和回收频率。对象池在游戏中广泛的使用,特别是在游戏中需要频繁的创建和销毁相同的游戏对象的时候,例如枪的子弹这种会频繁生成和销毁的对象。造成不必要的堆内存分配的因素
- 1.字符串
在c#中,字符串是引用类型变量而不是值类型变量,即使看起来它是存储字符串的值的。这就意味着字符串会造成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。
c#中的字符串是不可变更的,也就是说其内部的值在创建后是不可被变更的。每次在对字符串进行操作的时候(例如运用字符串的”+”操作),unity会新建一个字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会造成内存垃圾。
我们可以采用以下的一些方法来最小化字符串的影响:
1.减少不必要的字符串的创建,如果一个字符串的值被多次利用,我们可以创建并缓存该字符串。(实际上因为驻留机制,相同值的字符串,指针是一样的,可见最后的补充1)
2.减少不必要的字符串操作,例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个Text组件,对于不变的部分就设置为类似常量字符串即可。
3.如果我们需要实时的创建字符串,我们可以采用StringBuilderClass来代替,StringBuilder专为不需要进行内存分配而设计,从而减少字符串产生的内存垃圾。
4.移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数
- 2.Unity函数调用
在代码编程中,当我们调用不是我们自己编写的代码,无论是Unity自带的还是插件中的,我们都可能会产生内存垃圾。Unity的某些函数调用会产生内存垃圾,我们在使用的时候需要注意它的使用。
这儿没有明确的列表指出哪些函数需要注意,每个函数在不同的情况下有不同的使用,所以最好仔细地分析游戏,定位内存垃圾的产生原因以及如何解决问题。有时候缓存是一种有效的办法,有时候尽量降低函数的调用频率是一种办法,有时候用其他函数来重构代码是一种办法。现在来分析unity中常见的造成堆内存分配的函数调用。
在Unity中如果函数需要返回一个数组,则一个新的数组会被分配出来用作结果返回,这不容易被注意到,特别是如果该函数含有迭代器,下面的代码中对于每个迭代器都会产生一个新的数组:
1 | void ExampleFunction() { |
对于这样的问题,我们可以缓存一个数组的引用,这样只需要分配一个数组就可以实现相同的功能,从而减少内存垃圾的产生:
1 | void ExampleFunction() { |
此外另外的一个函数调用GameObject.name 或者 GameObject.tag也会造成预想不到的堆内存分配,这两个函数都会将结果存为新的字符串返回,这就会造成不必要的内存垃圾,对结果进行缓存是一种有效的办法,但是在Unity中都对应的有相关的函数来替代。对于比较gameObject的tag,可以采用GameObject.CompareTag()来替代。除此之外我们还可以用Input.GetTouch()和Input.touchCount()来代替Input.touches,或者用Physics.SphereCastNonAlloc()来代替Physics.SphereCastAll()。
- 3.装箱拆箱操作
装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。比如String.Format()函数需要传入字符串和对象类型参数,如果传入字符串和int类型数据,就会触发装箱操作。
- 4.协程
调用 StartCoroutine()会产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该限制该函数的调用。基于此,任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程。
yield在协程中不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如:
1 | yield return 0; |
由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:
1 | yield return null; |
另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:
1 | while(!isComplete) { |
我们可以采用缓存来避免这样的内存垃圾产生:
1 | WaitForSeconds delay = new WaiForSeconds(1f); |
如果游戏中的协程产生了内存垃圾,我们可以考虑用其他的方式来替代协程。重构代码对于游戏而言十分复杂,但是对于协程而言我们也可以注意一些常见的操作,比如如果用协程来管理时间,最好在update函数中保持对时间的记录。如果用协程来控制游戏中事件的发生顺序,最好对于不同事件之间有一定的信息通信的方式。对于协程而言没有适合各种情况的方法,只有根据具体的代码来选择最好的解决办法。
- 5.函数引用
函数的引用,无论是指向匿名函数还是显式函数,在unity中都是引用类型变量,这都会在堆内存上进行分配。匿名函数的调用完成后都会增加内存的使用和堆内存的分配。具体函数的引用和终止都取决于操作平台和编译器设置,但是如果想减少GC最好减少函数的引用。
- 6.LINQ和常量表达式
由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。
重构代码来减小GC的影响
即使我们减小了代码在堆内存上的分配操作,代码也会增加GC的工作量。最常见的增加GC工作量的方式是让其检查它不必检查的对象。struct是值类型的变量,但是如果struct中包含有引用类型的变量,那么GC就必须检测整个struct。如果这样的操作很多,那么GC的工作量就大大增加。在下面的例子中struct包含一个string,那么整个struct都必须在GC中被检查:
1 | public struct ItemData { |
我们可以将该struct拆分为多个数组的形式,从而减小GC的工作量:
1 | string[] itemNames; |
另外一种在代码中增加GC工作量的方式是保存不必要的Object引用,在进行GC操作的时候会对堆内存上的object引用进行检查,越少的引用就意味着越少的检查工作量。在下面的例子中,当前的对话框中包含一个对下一个对话框引用,这就使得GC的时候会去检查下一个对象框:
1 | public class DialogData { |
通过重构代码,我们可以返回下一个对话框实体的标记,而不是对话框实体本身,这样就没有多余的object引用,从而减少GC的工作量:
1 | public class DialogData { |
定时执行GC操作
如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换的时候),我们可以主动的调用GC操作:
1 | System.GC.Collect() |
托管堆
在开发过程中,我们总会遇到托管堆内存意外的增长的情况。在Unity中,托管堆的增长速度总是大于它收缩的速度。因此,Unity的GC(Garbage Collection)回收策略更趋向于内存片段的回收。
托管堆的工作原理
“托管堆”是由项目的脚本运行时(Scripting Runtime)——Mono或者IL2CPP内存管理器管理的 一个内存片段。所有托管代码中被创建的对象必须被分配到托管堆上(提示:严格意义上说,所有不为空的引用类型对象和所有被封装的值类型对象必须被分配到托管堆上)。
上图中,白色部分是一部分的已经分配的托管堆,有颜色的部分代表内存空间上存储的数据。当创建新的对象时,堆上就会被分配更多的空间。
Unity的GC会周期性的执行(执行的周期与平台有关)。它会遍历堆上的所有对象,并且标记那些没有被引用的对象,然后删除它们,释放内存。
Unity的GC机制,使用了Boehm GC算法(可以参考:维基百科),是非分代(non-generational)和非压缩(non-compacting)的。”非分代”是指GC执行清理操作时,必须遍历整个内存,并且随着内存的增长,它的性能就会降低。“非压缩”意味着内存中的对象不会被重新定位,去缩小对象之间的内存空隙。
上图中展示了一个内存分配的例子。当内存被释放时,内存是空的。然而,这部分未被分配的内存并没有与其他未分配的内存合并,它的两边的内存可能仍然在使用。因此,这部分未被分配的内存空间就成了内存片段中的“间隙(Gap)”(图中红色的圆圈表示了这个间隙)。因此,只有当被存储对象的大小小于或者等于被释放内存大小时,才能被存储。
当给对象分配内存时,记住对象总是占据了一块连续的内存。
这就导致了内存片段的核心问题:尽管堆中可用的空间总量可能是巨大的,但有可能很多或者所有的空间都位于已经分配对象之间的小“间隙”中。在这种情况下,尽管总共有足够大的空间来分配,但托管堆找不到足够大的连续空间来分配内存。
上图中,一个大的对象正在被分配,但是没有足够大的连续内存空间,这时,Unity内存管理器就会执行两个步骤:
第一步,启动垃圾收集器,释放足够大的空间来满足分配需要。
第二部,如果GC启动了,但任然没有足够的空间,托管堆就会扩张。堆扩张的大小是由平台决定的,但是Unity上的大多数平台都会让托管堆增长一倍。
托管堆的核心问题:
托管堆增长带来的主要问题:
- 当托管堆扩张的时候,为了尽量避免不被再次扩张,Unity没有经常释放堆上的内存页。
- 大多数平台上,Unity最终会将托管堆的空部分返回给操作系统。发生这个的时间间隔是不确定的,所以我们不能依赖这种情况。
- 被托管堆使用的地址空间永远不会返回给操作系统。
- 对于32位程序来说,托管堆增长和缩小多次,会导致地址空间不够用。如果一个程序的可用地址空间用完了,那么操作系统将会结束这个程序。但对于64位程序来说,有足够大的地址空间,不会出现地址空间用完的情况。