托管堆
托管堆的运行方式及其扩展的原因
“托管堆”是一段内存,由项目脚本运行时(Mono或IL2CPP)的内存管理器自动管理。托管代码中创建的所有对象必须在托管堆上分配(2)(注意:严格地说,必须在托管堆上分配所有非空引用类型对象和所有盒装值类型对象)。
在上图中,白框表示分配给托管堆的内存量,其中的彩色框表示存储在托管堆内存空间中的数据值。当需要其他值时,将从托管堆中分配更多空间。
垃圾收集器定期运行(3)(注意:确切的时间与平台有关)。这会扫描堆上的所有对象,标记删除任何不再引用的对象。然后删除未引用的对象,释放内存。
至关重要的是,Unity的垃圾收集 - 使用Boehm GC算法 - 是非代数和非压缩的。“非代数”意味着GC在执行收集传递时必须扫描整个堆,因此其性能因堆扩展而降低。“非压缩”意味着内存中的对象不会被重新定位以便关闭对象之间的间隙
上图显示了内存碎片的示例。释放对象时,将释放其内存。但是,释放的空间也不会成为“空闲内存”一家独大池的一部分。释放对象两侧的对象可能仍在使用中。因此,释放的空间是存储器的其他部分之间的“间隙”(该间隙由图中的红色圆圈表示)。因此,新释放的空间仅可用于存储与释放的对象相同或更小的数据。
这导致了内存碎片的核心问题:虽然堆中可用的总空间量可能很大,但是该空间中的一些或全部可能在分配的对象之间存在小的“间隙”。在这种情况下,即使可能有足够的总空间来容纳某个分配,托管堆也找不到足够大的连续内存块来适应分配
但是,如果分配了大对象并且没有足够的连续空闲空间来容纳对象,则如上所述,Unity内存管理器执行两个操作。
首先,如果还没有这样做,垃圾收集器就会运行。这会尝试释放足够的空间来完成分配请求。
如果在GC运行后,仍然没有足够的连续空间来满足请求的内存量,则堆必须扩展。堆扩展的具体数量取决于平台; 但是,大多数Unity平台的大小都是托管堆的两倍。
堆的关键问题
托管堆扩展的核心问题有两个:
-
Unity在扩展时不会经常释放分配给托管堆的内存页; 它乐观地保留了扩展堆,即使它的大部分是空的。这是为了防止在进一步发生大量分配时需要重新扩展堆。
-
在大多数平台上,Unity最终将托管堆空部分使用的页面释放回操作系统。发生这种情况的间隔不能保证,不应该依赖。
-
托管堆使用的地址空间永远不会返回给操作系统。
-
对于32位程序,如果托管堆多次扩展和收缩,则可能导致地址空间耗尽。如果程序的可用内存地址空间已用尽,操作系统将终止该程序。
-
对于64位程序,地址空间足够大,对于运行时间不超过人类平均寿命的程序来说,这种情况极不可能发生。
临时分配
发现许多Unity项目在每帧都有几十或几百千字节的临时数据分配给托管堆。这通常对项目的表现极为不利。考虑以下数学:
如果程序每帧分配一个千字节(1kb)的临时内存,并且以每秒
60 帧的速度运行在,然后它必须每秒分配60千字节的临时内存。在一分钟内,这在内存中增加了3.6兆字节的垃圾。每秒调用一次垃圾收集器可能会对性能产生不利影响,但尝试在低内存设备上运行时,每分钟分配3.6兆字符会有问题。
此外,考虑装载操作。如果在繁重的资产加载操作期间生成大量临时对象,并且在操作完成之前引用这些对象,则垃圾回收器无法释放这些临时对象,并且托管堆需要扩展 - 即使很多它包含的对象将在不久后释放。
跟踪托管内存分配相对简单。在Unity的CPU Profiler
,概述中有“GC Alloc”列。此列显示特定帧中托管堆上分配的字节数(4)(注意:请注意,这与给定帧期间临时分配的字节数不同。该配置文件显示在特定帧中分配的字节数,即使在后续帧中重用了部分/全部已分配的内存)。启用“深度分析”选项后,可以跟踪发生这些分配的方法。
Unity Profiler在主线程发生时不会跟踪这些分配。因此,“GC Alloc”列不能用于度量用户创建的线程中发生的托管分配。将代码的执行从单独的线程切换到主线程以进行调试,或使用BeginThreadProfiling API 中的时间轴
显示样本,这些 Profiler。
始终使用开发构建来
分析托管分配目标设备的。
请注意,某些脚本方法在编辑器中运行时会导致分配,但在构建项目后不会生成分配。GetComponent
是最常见的例子; 此方法始终在编辑器中执行时分配,但不在已构建的项目中分配。
通常,强烈建议所有开发人员在项目处于交互状态时最小化托管堆分配。非交互操作期间的分配(例如场景
加载,问题较少。
Visual Studio 的Jetbrains Resharper插件可以帮助定位代码中的分配。
使用Unity的深层配置文件模式查找托管分配的具体原因。在深层配置文件模式下,所有方法调用都是单独记录的,可以更清晰地查看方法调用树中托管分配的位置。请注意,Deep Profile模式不仅可以在编辑器中使用,还可以使用命令行参数在Android和桌面上运行-deepprofiling
。Deep Profiler按钮在分析期间保持灰色。
集合和数组重用
使用C#的Collection类或Arrays时,请尽可能考虑重用或汇集分配的Collection或Array。Collection类公开Clear方法,该方法消除Collection的值,但不释放分配给Collection的内存。
使用C#的Collection类或Arrays时,请尽可能考虑重用或汇集分配的Collection或Array。Collection类公开Clear方法,该方法消除Collection的值,但不释放分配给Collection的内存。
void Update() {
List<float> nearestNeighbors = new List<float>();
findDistancesToNearestNeighbors(nearestNeighbors);
nearestNeighbors.Sort();
// … use the sorted list somehow …
}
在为复杂计算分配临时“帮助程序”集合时,这尤其有用。一个非常简单的示例可能是以下代码:
在此示例中,nearestNeighbors
每帧分配一次List以收集一组数据点。将此List从方法中提升到包含类中非常简单,这避免了每个帧分配新的List:
List<float> m_NearestNeighbors = new List<float>();
void Update() {
m_NearestNeighbors.Clear();
findDistancesToNearestNeighbors(NearestNeighbors);
m_NearestNeighbors.Sort();
// … use the sorted list somehow …
}
在此版本中,List的内存被保留并在多个帧中重用。仅在List需要扩展时才分配新内存。
附:数组,list,dictionary的用法
1.当一个对象的数量保持不变时和需要频繁的查找对象时不要使用List(列表)。
2.如果是动态的对象,且不需要频繁查找对象时,使用List(列表)是最佳的选择。
3.需要快速查找,并且对象的改变很小时,使用Dictionary(字典)是最佳的选择。
4.当一个对象的数量保持不变时,使用Array(数组)是最佳的选择(自己添加的)
闭包和匿名方法
使用闭包和匿名方法时需要考虑两点。
首先,C#中的所有方法引用都是引用类型,因此在堆上分配。通过将方法引用作为参数传递,可以轻松创建临时分配。无论传递的方法是匿名方法还是预定义方法,都会发生此分配。
其次,将匿名方法转换为闭包会显着增加将闭包传递给接收它的方法所需的内存量。
请考虑以下代码:
List<float> listOfNumbers = createListOfRandomNumbers();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/2))
);
此代码段使用简单的匿名方法来控制在第一行创建的数字列表的排序顺序。但是,如果程序员希望使此代码段可重用,则很容易将常量替换2
为局部范围内的变量,如下所示:
List<float> listOfNumbers = createListOfRandomNumbers();
int desiredDivisor = getDesiredDivisor();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/desiredDivisor))
);
匿名方法现在要求该方法能够访问方法范围之外的变量状态,因此已成为闭包。desiredDivisor
必须以某种方式将变量传递给闭包,以便闭包的实际代码可以使用它。
为此,C#生成一个匿名类,可以保留闭包所需的外部范围变量。将闭包传递给Sort
方法时,将实例化此类的副本,并使用desiredDivisor
整数的值初始化副本。
因为执行闭包需要实例化其生成的类的副本,并且所有类都是C#中的引用类型,所以执行闭包需要在托管堆上分配对象。
通常,最好尽可能避免C#中的闭包。应该在性能敏感的代码中最小化匿名方法和方法引用,尤其是在基于每帧执行的代码中。
IL2CPP下的匿名方法
目前,检查由IL2CPP
生成的代码显示,类型变量的简单声明和赋值会System.Function
分配一个新对象。无论变量是显式的(在方法/类中声明)还是隐式的(声明为另一个方法的参数),都是如此。
因此,在IL2CPP 脚本后端
下使用匿名方法分配管理内存。Mono 脚本后端不是这种情况。
此外,IL2CPP显示不同级别的托管内存分配,具体取决于声明方法参数的方式。正如预期的那样,闭包为每次调用分配最多的内存。
毫不直观地,预定义方法在IL2CPP脚本后端下作为参数传递时,分配的内存几乎与闭包一样多。匿名方法在堆上生成最少量的瞬态垃圾,达到一个或多个数量级。
因此,如果项目打算在IL2CPP脚本后端上发布,则有三个主要建议:
-
首选不需要传递方法作为参数的编码样式。
-
当不可避免时,更喜欢匿名方法而不是预定义方法。
-
无论脚本后端如何,都要避免关闭。
装箱
装箱是Unity项目中发现的非常规临时内存分配的最常见来源之一。只要将值类型值用作引用类型,就会发生这种情况; 这通常发生在将原始值类型变量(例如int
和float
)传递给对象类型方法时。
在这个非常简单的示例中,x中的整数被加框以便传递给object.Equals
方法,因为Equals
方法on object
需要object
传递给它。
int x = 1;
object y = new object();
y.Equals(x);
C#IDE和编译器通常不会发出有关装箱的警告,即使它会导致意外的内存分配。这是因为C#语言是在假设小型临时分配将由分代垃圾收集器和分配大小敏感的内存池有效处理的情况下开发的。
虽然Unity的分配器确实使用不同的内存池进行小型和大型分配,但Unity的垃圾收集器是分not
代的,因此无法有效地扫除由装箱生成的小的,频繁的临时分配。
在为Unity运行时编写C#代码时,应尽可能避免使用Boxing。
字典和枚举
装箱的一个常见原因是使用enum
类型作为词典的键。声明一个enum
创建一个新的值类型,在后台处理为一个整数,但在编译时强制执行类型安全规则。
默认情况下,调用会Dictionary.add(key, value)
导致调用Object.getHashCode(Object)
。此方法用于获得字典的关键适当的散列码,并在接受一个密钥的所有方法中使用:Dictionary.tryGetValue
,Dictionary.remove
,等。
该Object.getHashCode
方法是引用类型的,但enum
值始终是值类型。因此,对于枚举键字典,每个方法调用都会导致键被装箱至少一次
数组值Unity API
虚假阵列分配的一个更有害和更不明显的原因是重复访问返回数组的Unity API。返回数组的所有Unity API每次访问时都会创建一个新的数组副本。在不必要的情况下访问数组值Unity API非常不理想
for(int i = 0; i < mesh.vertices.Length; i++){}
替换:
var vertices = mesh.vertices;
for(int i = 0; i < vertices.Length; i++){}
虽然一次访问属性的CPU成本不是很高,但在紧密循环内重复访问会产生CPU性能热点。此外,重复访问不必要地扩展了托管堆。
此问题在移动设备上非常常见,因为Input.touches
API的行为与上述类似。项目包含类似于以下内容的代码是非常常见的,每次.touches
访问属性时都会发生分配。