Unity性能优化之内存管理的优化(内存管理性能增强)一
关于Unity的代码编译当修改了C#代码,并且从喜欢的IDE切换到Unity之后,代码会自动编译,但是C#代码没有直接转化为机器码,相反,代码转换为中间语言CIL,他是本地代码上的一种抽象,这正是.NET支持多种语言的方式——每种语言都使用不同的编译器,但是他们都会转换成CIL,因为不管选择的语言是什么,最终的输出都是一样的;在运行的时候,CIL通过Mono的虚拟机VM运行,VM是一种基础架构元素
关于Unity的代码编译
当修改了C#代码,并且从喜欢的IDE切换到Unity之后,代码会自动编译,但是C#代码没有直接转化为机器码,相反,代码转换为中间语言CIL,他是本地代码上的一种抽象,这正是.NET支持多种语言的方式——每种语言都使用不同的编译器,但是他们都会转换成CIL,因为不管选择的语言是什么,最终的输出都是一样的;
在运行的时候,CIL通过Mono的虚拟机VM运行,VM是一种基础架构元素,允许相同的代码运行在不同的平台,而不需要修改代码本身,Mono虚拟机时.NET公共语言运行时(CLR)的一个实现,例如,在IOS上运行,则游戏运行与基于IOS的虚拟机上,如果运行在Linux上,就使用更适用于Linux的另一个虚拟机,这也是为什么Unity允许编写一次代码,但是却可以在多个平台上工作的方式;
在CLR中,中间CIL代码实际上会根据需要编译为本地代码,这种及时的本地编译有两种方式:AOT(Ahread Of Time)、JIT(Just In Time)两种编译器完成;
AOT编译是代码编译的典型行为,它发生于构建流程完成之前,在一些情况下在程序初始化之前,但是不管是在什么情况下,代码都是已经提前编译,没有后续运行时由于动态编译产生的消耗;
JIT编译在运行时的独立线程中完成,通常,该动态编译导致代码在第一次调用的时候,运行的稍微慢一点,因为代码必须在执行之前完成编译,但是只要JIT编译完成,那么只要是执行的相同代码,那么就不需要再重新编译;
这两种本地编译的方式通常意味着,JIT编译对性能的优势比简单直接的解视CIL代码墙,然而由于JIT编译器必须要快速编译代码,他不能使用很多静态AOT编译其可以使用的优化技术;
IL2CPP
IL2CPP是一个脚本后端,用于将Mono的CIL(中间语言)输出直接转换为本地C++代码,由于应用程序现在运行本地代码,因此这将带来性能的提升,同时也可以使Unity更好的控制运行时行为,IL2CPP提供了自己的AOT和VM,允许对GC等子系统和编译过程的改进;
需要注意的是,IL2CPP再IOS和WebGL项目中自动启用,在其他支持的项目类型中需要通过Edit->Project Settings ->players =>Configure->Scripting Backend 开启
分析内存
分析内存消耗
一:通过Profiler中的Memory Area 中观察,Unity(本地内存分配(上)、该内存域预留了多少内存(下))Mono(为托管堆分配了多少内存(上)、为托管堆预留了多少内存(下)),如下
二:Profiler.GetRuntimeMemorySize()方法获取特定对象本地内存分配
三:也可以在运行的时候分别使用Profiler.GetmonoUsedSize() 和Profiler.GetMonoHeapSize()方法确定当前使用和预留的空间
分析内存效率
最直观就是观察GC行为,如果,GC的工作越多,那么所产生的浪费就越多,从而程序的性能,可能就越差;
使用Profier中的CPU page 和Memory Area搭配以观察GC的工作量以及时间,但是这种方式是具有不确定性的,原因:当前的峰值,可能是上一帧分配的内存过多而当前帧,没有进行分配导致,或可所清理的内存在很长一段时间之前就分配好了,但是程序却执行了很长时间,不管什么情况,都具有很强的不确定性;
内存管理性能增强
垃圾回收策略
最小化垃圾回收问题的一种策略就是在合适的时间手动触发垃圾回收,当确定玩家不会注意到这种行为时就可以偷偷的触发垃圾回收,可以通过System.GC.Collect来手动触发;
一般可以在下面的几种情况下触发垃圾回收,如加载场景,当游戏暂停,或者打开菜单界面的瞬间,切换场景,任何玩家不关心的或者不注意的瞬间都可以,甚至可以在运行的时候,使用Profiler.GetmonoUsedSize和Profiler.GetMonoHeapSize()方法来决定是否需要调用垃圾回收
另一种情况,如果一个类,例如Unity5中有一些不同的对象类,NetWorkConnect、www等 实现了IDisposable接口类,这样就可以通过实现IDisposable接口的Dispose函数,来确保在需要的时候释放内存缓冲区,当然,在正常情况下,我们所写的工具类,或者其他类也是可以使用这个接口的
其他的资源对象则提供了某种类型的方法以清除任何未使用的资源数据,例如Resources.UnloadUnUsedAssets(),实际的资源都存储在本地域里面,因此该方法不涉及到立即回收技术,但是思想基本一样,它将遍历特定类型的数据,所有资源,检查他们是否不再被引用,如果资源没有被引用,则释放他们,但是,这是一个异步处理,并不能保证什么时候被释放,该方法在加载场景结束之后由内部自动调用,但是这依然不能立刻就释放内存, 首选的方法是,Resources.UnloadAsset(),一次卸载一个指定资源,这种方法因为不需要迭代资源,因此会更快点;
实际上,最好的垃圾回收机制就是避免垃圾回收,尽量把所分配的内存都控制到自己的手上,这样就不需要担心垃圾回收机制所产生的性能开销了;
值类型和引用类型
并非在Mono中分配的所有内存都通过堆进行分配,.NETFramework有值类型和引用类型的概念,当GC进行标记-清除算法时,之后引用类型才需要被GC标记,这是因为引用类型的复杂度、大小、和使用方式,他们会在内存中存在一段时间。大的数据集、和从类实例化的任何类型的对象都是引用类型,这也包括数组、委托、所有的类(Monoehaviour、或者Gameobject、自定义的类等等)
需要注意的是,引用类型通常在堆上进行分配,值类型一般在栈上分配,但是,如果值类型包含在引用类型中,那么会被分配到堆上
如上,这个a就是分配在栈上,一旦这个方法结束,a就会在栈上释放,这样的话,基本是一个无消耗的操作。
如上,如果是在类中进行定义的整型,那么他就会分配在堆上,与他的容器一起;
因此在类方法中创建临时值类型变量,和在类中创建值类型变量,所分配的地方是不一样的,前一种保存在栈上,后一种保存在堆上
如上DoSomething使用成员变量ray保存dataobj的引用,那么在TestFunction结束的时候是不能释放dataobj的,因为其引用的数量由2变为1,由于不是0,因此GC依然会在标记-清除期间标记它,这时需要在对象无法访问之前设置ray为null。
需要注意的是,值类型必须要有一个值,并且值不能是null,如果栈分配的类型被赋予为引用类型,那么数据就会简单的复制,即使对于值类型的数组也是如此
如上,初始化的时候,会把10个整数分配到堆上,并设置值为0,当调用storeNumber的时候,只是简单的将num的值赋值到数组的初始元素中,而不是保存的引用;
因此,引用功能的西为变化最终决定了谋个对象是引用类型还是值类型,应该在有机会时尝试使用值类型,这样他们会分配到栈上而不是在堆上;
按值传递和按引用传递
从技术上说,不管是引用传递还是值传递,都会复制些东西,而这两种方式的重要差异就是引用类型只是将其指向内存中的位置进行传递,它仅消耗4或8字节的大小,而不管他真正指的是什么,但是值传递会包含存储在具体对象中的完整数据位,在很多情况下,这意味着,与使用引用类型,让GC处理它相比,过多的将巨大的值类型(如结构体)作为参数传递会更加昂贵;
数据也是可以使用ref关键字按引用传递;
结构体是值类型
struct类型是C#中一个有趣的特殊情况,struct对象可以包括私有的、受保护的、公共的字段,包含方法,可以在运行时实例化,与class类型一样,但是两者有基本差异,struct是值类型,class是引用类型,因此,这将导致两者之间的重大差异,即struct不支持继承,特们的属性不能使用自定义的默认值(成员数据的默认值始终未0或者控制,因为他是值类型),而他们的默认构造函数不能被覆盖,与类相比,这极大的限制了他们的使用,因此简单的将所有类替换为结构体并不像听起来那样简单;
数组和引用类型
数组的目的是包含大的数据集,很难将其视为值类型,因为栈上可能没有足够大的空间去保存它, 因此数组被视为引用类型,这样完整的数据集可以通过一个引用传递(如果是值类型,将需要在每次传递时复制整个数组)
如上,1 由于创建的是数组,是引用类型,因此会导致堆分配,2由于textstruct是结构体,值类型,因此不会导致堆分配
字符串时不可变的引用类型
字符串本质上是字符数组,因此他们是引用类型,遵循与其他引用类型相同的所有规则,因为我们经常会扩大、连接、或者合并字符串,以创建其他字符串,这可能导致我们堆字符串的工作方式做出错误的假设,我们可能会假设,由于字符串是如此常见的,无所不在的对象,对他们执行操作即快速又低消耗,但是实际上,这是错误的,字符串并不快速,只是比较方便而已;
需要知道的是,字符串是不可变的,这意味着他们不能再分配内存之后变化, 所以当改变字符串的时候,实际上是再堆上分配了一个全新的字符串以替代它,原来的内容会复制到新的字符数组中,并且根据需要修改对应的字符,而原本的字符串对象引用现在指向新的字符串对象,在此情况下,旧的字符串对象不再被引用,不会在-标记-清除过程中标记,最终被GC清除,因此,懒惰的字符串编程将导致很多不必要的堆分配和垃圾回收;如下代码
如果错误的假定字符串的工作方式与其他引用类型想通过,就会认为输出的日志为world,实际上是错误的,由于引用类型通过值传递,dosomthing作用域中的raycast变量开始的时候引用的是textstr相同的地址,这样就有了两个引用,但是当其发生重新赋值的时候,由于字符串是不可变的,因此,这个时候,raycast所代表的就是新分配的world的位置,因此,textstr并没有发生改变,而raycast在重新赋值之后所指也不是原本的那个位置,在调用dosomthing之后,raycast则会被回收;
总结一下:
如果通过值传递值类型,就只能修改的是其数据的副本,对原本的数据不会产生影响
如果通过引用传递值类型,就可以修改传入的原始数据
如果通过值传递引用类型,就可以修改原始引用的对象
如果通过引用传递引用对象,就可以修改原始引用的对象或数据集;
因此如果我们发现函数在调用时似乎在内存中生成很多GC分配,那么可能是由于在传递的时候没有对这两种参数类型进行恰当的传递造成的;
字符串连接
连接字符串是指将某个字符串追加到另一个字符串的后面以形成更大的字符串的行为,显然这样的而操作会导致过量的堆分配,基于字符串的内存浪费中最大的问题是使用+和+=操作符连接字符串,这样会导致分配链效应,如下
string outputstr = Res.attacker()+Res.total()+Res.damagetype()....
输出是:Dwarf dealt 15 Slashing damage to orc
该函数充满了一些字符串字面量(程序初始化阶段分配的硬编码字符串),例如dealt、damage,他们对于编译其而言是简单的构造,编译其可以提前为他们分配内存,然而,因为在合并字符串中使用了其他本地变量,因此这些字符串不能在初始化阶段进行构造,每次在 调用方法的时候,都会重新生成完整的字符串,同时也会进行新的堆分配,每次都会为新的字符串分配堆内存,然后结果再去分配,因此这样滥用字符串会导致浪费大量的内存,来生成不必要的字符串,生成字符串更好的办法应该使用stringbuilder类或者字符串类的各种用于字符串格式话的方法;
- StringBuilder:传统观点认为,如果最开始就知道结果字符串的最终大小,那么可以提前分配一个适当的缓冲区,这正是StringBuilder的目标,它分配一块空间,可以将未来的字符串对象复制到其中, 并且在超过当前大小的时候分配额外的空间,当然应该尽可能的预判需要的最大大小,并提前分配足够大的缓冲区,以避免扩展缓冲区,当使用StringBuiler的时候,可以通过调用ToSting的方法取出结果字符串对象,这依然会为已经完成的字符串进行内存分配,但是至少只分配一个大的字符串,而不像+、+=那样分配很多个较小的字符串
- 字符串格式化:如果不知道结果字符串最终的大小,那么使用StringBuilder不可能会生成大小合适的缓冲区,要么太大,浪费空间,要么太小需要扩展缓冲区,此时最好尝试一下各种字符串不同格式话方法的一种,字符串类有3个生成字符串的方法,string.format、string.join、string.concat每个方法的操作都有所不同,但最终输出是一样的;
实际上在给定的情况下,很难说那种字符串生成方法更有利,因此最简单的就是都尝试一下,如果某种方式的性能不佳就换一种
更多推荐
所有评论(0)