Unity 内存优化之理解托管堆和本机堆

    技术2022-07-10  162

    https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity4-1.html

    https://www.dazhuanlan.com/2019/09/24/5d88efd226ceb/,C#托管堆

    https://www.jianshu.com/p/cf3ab3bac1ab Unity 内存管理和profiler详解,内存三种分类

    https://gameinstitute.qq.com/community/detail/125199 Unity GC优化学习(一):认识堆(heap)&栈(stack)

    什么是托管堆

    托管堆是指托管给运行环境如Mono进行内存管理的托管内存,所以内存的优化是离不开托管堆相关的问题。

    托管堆最常见的一个问题就是托管堆的意外扩展,在unity中,托管堆的扩展比收缩的要容易得多,而且unity的垃圾回收策略会让托管堆内存碎片化,也会阻止大堆的收缩。

    所以一共两个重要问题

    意外的扩展,也就是不必要的堆内存开销

    GC后的内存碎片化导致堆内存无法被充分利用

    接下里就了解一下托管堆的内存机制,明确为什么会出现这两个问题

    托管堆如何运行、为何扩展、为何碎片化

    托管堆是被内存管理器进行自动管理的,必须将所有在托管代码中创建的对象分配到托管堆上,严格来说,所有非空引用类型的对象和所有装箱的值类型的对象都必须分配在托管堆上。

    如下所示,C#中数组、字符串等都是对象,也就都属于引用类型。

    垃圾收集器会定时运行,确切的时间是取决于平台的(当然开发者也可以手动调用GC),采用的算法是Boehm GC算法,主要思想是标记并清除,它会遍历堆上所有的对象,并标记删除所有不再引用的对象,然后释放内存。GC算法是分代的,它必须扫描整个堆,所以随着堆内存的扩展而降低;

    开始GC时,会挂起进程中的所有线程,防止线程在检查期间访问对象并更改对象状态。

    内存分配

    当调用IL指令new一个对象时,就会为相应的资源类型分配内存,所有对象从托管堆分配,Mono会分配一个地址空间区域作为托管堆,然后维护一个指针NextObjPtr,该指针指向下一个对象在堆中共的分配位置,默认初始设置为地址空间区域的基地址

    内存碎片问题

    GC算法是非紧凑的,也就是内存中的对象不会重定位以缩小对象之间的间隙,一个对象从创建到销毁整个生命周期中,它在内存中的位置是不会变化的。所以,释放后的内存不会被聚集在一起,他们的物理地址不会改变,所以有可能在频繁的内存释放后出现大量的内存间隙,新释放的空间只能用于存储和释放相同或更小的数据。

    并且,分配对象时,对象必须在内存中占据连续的内存块。

    因此这两个规则就导致了内存碎片的核心问题:尽管堆中的可用空间总量可能很大,但是有一部分或全部空间可能位于分配对象之间的“内存间隙”中,这种情况下,即使可能有足够的总空间来容纳某个分配,但是托管堆无法找到足够大的连续内存块来进行分配。

    如果没有连续可用的空间容纳该对象,那么内存管理器就会进行两个操作:

    首先运行垃圾收集器,尝试释放足够的空间来满足分配请求

    在GC运行之后,如果仍然没有足够的连续空间来容纳请求的内存量,则必须扩展堆,一般对扩展都是两倍。

    堆的关键问题

    扩展时,Unity并不经常释放分配给托管堆的内存页面,它乐观地保留了扩展堆,即使其中很大一部分为空。这是为了防止在发生更大的分配时需要重新扩展堆。

    在大多数平台上,Unity最终会将托管堆的空白部分使用的页面释放返还给操作系统,但是不能保证这种情况的间隔,因此不能依赖这种间隔。

    对于32位程序,如果托管堆多次扩展和收缩,可能导致地址空间耗尽,可能引发操作系统终止该程序,也就是造成crash。

    对于64位,地址空间足够大,极不可能发生上述情况。

    临时分配

    许多项目中都在将数十或数百KB的临时数据分配到每个帧的托管堆中运行,这是非常耗内存的:如果程序每帧分配一千字节1KB的临时内存,并且以帧率60运行,那么每秒就会分配60KB的临时内存,也就是0.06M,那么一分钟就是3.6M的内存垃圾。而且,每秒调用一次GC是会严重损害性能的,所以这种分配是有问题的,是需要避免的。

    关于资源加载操作:如果在资源加载期间产生了大量临时对象,并且在操作完成之间引用了这些对象,那么垃圾收集器无法释放这些对象,托管堆就需要扩展。

    使用Profiler中的GC Alloc就可以跟踪特定帧中在托管堆上分配的字节数,注意:当这些发配发生在主线程之外时,Unity Profiler不会跟踪这些分配。

    基本内存存储

    运用一些规范性的语法技巧和策略可以减少托管堆的分配

    集合和数组的重用

    使用C#的Collection类或数组(都是引用类型)时,请尽可能考虑重用或者池化已分配的Collection或Array,Collection提供了Clear方法,它可以消除Collection内的值,但是不释放分配给Collection的内存。

    因此,通常把集合变量从一个方法中提取到包含类中很重要,它可以避免每帧分配新的集合。

    闭包和匿名方法

    使用闭包和匿名方法有两点需要考虑

    C#中所有的方法引用都是引用类型,如委托,因此都是在堆上分配内存的。通过将方法引用作为参数传递,可以轻松创建临时分配。不管传递的方法是匿名方法还是预定义方法,都会进行此分配。

    将匿名方法转换为闭包将大大增加接收闭包的方法所需的内存量。

    考虑一下两段代码:

    // Code Snippet 1 List<float> listOfNumbers = createListOfRandomNumbers(); listOfNumbers.Sort( (x, y) => (int)x.CompareTo((int)(y/2)) ); // Code Snippet 2 List<float> listOfNumbers = createListOfRandomNumbers(); int desiredDivisor = getDesiredDivisor(); listOfNumbers.Sort( (x, y) => (int)x.CompareTo((int)(y/desiredDivisor)) );

    后者因为包含了局部范围外的变量,形成了闭包。C#为此生成一个匿名类,该类可以保留闭包所需的外部作用域变量,将闭包传递给Sort方法时,将实例化此类的副本,并用局部变量的值初始化副本。因为执行闭包需要实例化其生成类的副本,并且所有类都是C#的引用类型,所以执行闭包需要在托管堆上分配对象,因此,最好避免C#中的闭包。

    装箱和拆箱

    装箱Unity项目中最常见的意外内存分配来源之一,在C#中就会经常提到装箱和拆箱导致很多性能问题。每当值类型的值用作引用类型时,就会发生装箱,比如将值类型传递给对象类型的方法时,通常会出现这种情况。

    C#的IDE和编译器不会给出装箱的警告,即使这种操作会导致意外的内存分配,C#语言的开发假设是,垃圾收集器和对分配大小敏感的内存池会有效地处理小的临时分配。但是Unity中无法有效清除由装箱生成的频繁临时小分配,所以在unity中编写C#代码时,应尽可能避免装箱。

    字典和枚举

    装箱的一个常见的原因是使用enum类型作为字典的键,生命enum会创建一个新的值类型,这个值在底层被视为整数,但是会在编译时强制执行类型安全性规则。默认情况下,调用Dictionary.add<key, value>导致调用Object.getHashCode(Object),此方法用于获得字典的散列码,因此,对于使用枚举键的字典,每次方法调用都会导致键至少装箱一次。

    要解决此问题,就需要编写一个实现了IEqualityComparer接口的自定义类,并将该类的实例分配为Dictionary的比较器,从而避免GetHashCode的装箱操作

    public class MyEnumComparer : IEqualityComparer<MyEnum> { public bool Equals(MyEnum x, MyEnum y) { return x == y; } public int GetHashCode(MyEnum x) { return (int)x; } }

    Foreach循环

    在unity版本的Mono C#编译器中,使用foreach循环会强制在Unity每次循环终止时对一个值进行装箱,注意:是每次整个循环完成执行后,该值都会被装箱一次,因为无论是循环1次还是1000次,内存使用率都保持不变。

    此枚举器实现IDisposable接口,在循环终止时必须调用该接口,但是在值类型上调用接口方法需要将他们装箱。

    在Unity5.5的C#编译器中,提高了Unity生成IL的能力,装箱操作已经从foreach循环中去除,但是由于方法调用开销,所以与等效的基于数组的代码相比,CPU性能仍有差异。

    扩展时,Unity并不经常释放分配给托管堆的内存页面,它乐观地保留了扩展堆,即使其中很大一部分为空。这是为了防止在发生更大的分配时需要重新扩展堆。

    在大多数平台上,Unity最终会将托管堆的空白部分使用的页面释放返还给操作系统,但是不能保证这种情况的间隔,因此不能依赖这种间隔。

    对于32位程序,如果托管堆多次扩展和收缩,可能导致地址空间耗尽,可能引发操作系统终止该程序,也就是造成crash。

    对于64位,地址空间足够大,极不可能发生上述情况。

    C#中所有的方法引用都是引用类型,如委托,因此都是在堆上分配内存的。通过将方法引用作为参数传递,可以轻松创建临时分配。不管传递的方法是匿名方法还是预定义方法,都会进行此分配。

    将匿名方法转换为闭包将大大增加接收闭包的方法所需的内存量。

    数组值的Unity API

    一个很有害但是更不可见的来源是重复访问数组的Unity API,每次访问数组时,所有返回数组的API都会创建数组的新副本,如下所示,每次循环会创造数组的四个副本,从计算长度到访问x,y,z三个属性值。

    for(int i = 0; i < mesh.vertices.Length; i++) { float x, y, z; x = mesh.vertices[i].x; y = mesh.vertices[i].y; z = mesh.vertices[i].z; // ... DoSomething(x, y, z); }

    正确的做法是在循环之前捕捉数组,

    var vertices = mesh.vertices; for(int i = 0; i < vertices.Length; i++) { float x, y, z; x = vertices[i].x; y = vertices[i].y; z = vertices[i].z; // ... DoSomething(x, y, z); }

     空数组重用

    从方法返回零长度数组时,返回零长度数组的预分配单例实例比重复创建空数组要高效得多。

     

    Processed: 0.012, SQL: 9