了解托管堆
许多Unity开发人员面临的另一个常见问题是托管堆的意外扩展。在Unity中,托管堆扩展比收缩更容易扩展。此外,Unity的垃圾收集策略往往会破坏内存,这可以防止大堆收缩。
托管堆的运行方式及其扩展的原因
“托管堆”是一段内存,由项目脚本运行时(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的内存。
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需要扩展时才分配新内存。
闭包和匿名方法
使用闭包和匿名方法时需要考虑两点。
首先,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 脚本后端
下使用匿名方法在Unity中为脚本编写分配管理内存。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。
识别装箱
Boxing在CPU跟踪中显示为对几种方法之一的调用,具体取决于使用的脚本后端。这些通常采用以下形式之一,其中<some class>
一些是其他类或结构的名称,并且…
是一些参数:
-
<some class>::Box(…)
-
Box(…)
-
<some class>_Box(…)
它也可以通过搜索反编译器或IL查看器的输出来定位,例如ReSharper或dotPeek反编译器中内置的IL查看器工具。IL指令是“框”。
字典和枚举
装箱的一个常见原因是使用enum
类型作为词典的键。声明一个enum
创建一个新的值类型,在后台被视为一个整数,但在编译时强制执行类型安全规则。
默认情况下,调用会Dictionary.add(key, value)
导致调用Object.getHashCode(Object)
。这种方法被用来获得用于字典的键相应的散列码,并且,接受密钥的所有方法中使用:Dictionary.tryGetValue, Dictionary.remove
,等
。
该Object.getHashCode
方法是引用类型的,但enum
值始终是值类型。因此,对于枚举键字典,每个方法调用都会导致键被装箱至少一次。
以下代码片段演示了一个演示此装箱问题的简单示例:
enum MyEnum { a, b, c };
var myDictionary = new Dictionary<MyEnum, object>();
myDictionary.Add(MyEnum.a, new object());
要解决这个问题,有必要编写一个实现IEqualityComparer
接口的自定义类,并将该类的实例指定为Dictionary的比较器(注意:此对象通常是无状态的,因此可以与不同的Dictionary实例一起重用以节省内存) 。
以下是上述代码片段的IEqualityComparer的简单示例。
public class MyEnumComparer : IEqualityComparer<MyEnum> {
public bool Equals(MyEnum x, MyEnum y) {
return x == y;
}
public int GetHashCode(MyEnum x) {
return (int)x;
}
}
可以将上述类的实例传递给 Dictionary的构造函数。
Foreach循环
在Unity的Mono C#编译器版本中,foreach
循环的使用强制Unity在每次循环终止时将值设置为一个值(注意:每次循环整个执行完成后,该值都会被设置一次。每次迭代时它不会包含一次循环,因此无论循环运行两次还是200次,内存使用量都保持不变。这是因为Unity的C#编译器生成的IL构造了一个通用值类型的枚举器,以便迭代值集合。
此Enumerator实现IDisposable
接口,在循环终止时必须调用该接口。但是,在值类型对象(例如结构和枚举器)上调用接口方法需要装箱。
检查以下非常简单的示例代码:
int accum = 0;
foreach(int x in myList) {
accum += x;
}
以上,当通过Unity的C#编译器运行时,产生以下中间语言:
.method private hidebysig instance void
ILForeach() cil managed
{
.maxstack 8
.locals init (
[0] int32 num,
[1] int32 current,
[2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_2
)
// [67 5 - 67 16]
IL_0000: ldc.i4.0
IL_0001: stloc.0 // num
// [68 5 - 68 74]
IL_0002: ldarg.0 // this
IL_0003: ldfld class [mscorlib]System.Collections.Generic.List`1<int32> test::myList
IL_0008: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0/*int32*/> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
IL_000d: stloc.2 // V_2
.try
{
IL_000e: br IL_001f
// [72 9 - 72 41]
IL_0013: ldloca.s V_2
IL_0015: call instance !0/*int32*/ valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
IL_001a: stloc.1 // current
// [73 9 - 73 23]
IL_001b: ldloc.0 // num
IL_001c: ldloc.1 // current
IL_001d: add
IL_001e: stloc.0 // num
// [70 7 - 70 36]
IL_001f: ldloca.s V_2
IL_0021: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
IL_0026: brtrue IL_0013
IL_002b: leave IL_003c
} // end of .try
finally
{
IL_0030: ldloc.2 // V_2
IL_0031: box valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
IL_0036: callvirt instance void [mscorlib]System.IDisposable::Dispose()
IL_003b: endfinally
} // end of finally
IL_003c: ret
} // end of method test::ILForeach
} // end of class test
最相关的代码是__finally { … }__
靠近底部的块。该callvirt
指令IDisposable.Dispose
在调用方法之前发现方法在内存中的位置,并要求枚举器被装箱。
通常,foreach
Unity中应避免循环。它们不仅是盒子,而且通过枚举器迭代集合的方法调用成本通常比通过for
或while
循环的手动迭代慢得多。
请注意,Unity 5.5中的C#编译器升级显着提高了Unity生成IL的能力。特别是,已经从foreach
循环中消除了装箱操作。这消除了与foreach
循环相关的内存开销。但是,由于方法调用开销,与基于阵列的等效代码相比,CPU性能差异仍然存在。
数组值Unity API
虚假阵列分配的一个更有害和更不明显的原因是重复访问返回数组的Unity API。返回数组的所有Unity API每次访问时都会创建一个新的数组副本。在不必要的情况下访问数组值Unity API非常不理想。
例如,以下代码vertices
每次循环迭代虚假地创建四个数组副本。每次.vertices
访问属性时都会发生分配。
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);
}
通过vertices
在进入循环之前捕获数组,无论循环迭代次数多少,都可以简单地将其重构为单个数组分配:
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);
}
虽然一次访问属性的CPU成本不是很高,但在紧密循环内重复访问会产生CPU性能热点。此外,重复访问不必要地扩展了托管堆。
此问题在移动设备上非常常见,因为Input.touches
API的行为与上述类似。项目包含类似于以下内容的代码是非常常见的,每次.touches
访问属性时都会发生分配。
for ( int i = 0; i < Input.touches.Length; i++ )
{
Touch touch = Input.touches[i];
// …
}
当然,通过将数组分配从循环条件中提升,可以轻松地改善这种情况:
Touch[] touches = Input.touches;
for ( int i = 0; i < touches.Length; i++ )
{
Touch touch = touches[i];
// …
}
但是,现在有许多Unity API的版本不会导致内存分配。当它们可用时,通常应该受到青睐。
int touchCount = Input.touchCount;
for ( int i = 0; i < touchCount; i++ )
{
Touch touch = Input.GetTouch(i);
// …
}
将上面的示例转换为无分配的Touch API很简单:
请注意,属性access(Input.touchCount
)仍然保持在循环条件之外,以便节省调用属性get
方法的CPU成本。
空数组重用
有些团队更喜欢返回空数组,而不是null
在数组值方法需要返回空集时。这种编码模式在许多托管语言中很常见,特别是C#和Java。
通常,当从方法返回零长度数组时,返回零长度数组的预分配单例实例比重复创建空数组(5)要高效得多(注意:当然,应该例外)在返回后调整数组大小时进行。
脚注
-
(1)这是因为,在大多数平台上,GPU内存的回读非常慢。将GPU内存中的纹理读入临时缓冲区以供CPU代码(例如
Texture.GetPixel
)使用将是非常不符合要求的。 -
(2)严格地说,必须在托管堆上分配所有非null引用类型对象和所有盒装值类型对象。
-
(3)确切的时间与平台有关。
-
(4)注意,这是不相同的给定帧期间临时分配的字节数。该配置文件显示在特定帧中分配的字节数,即使在后续帧中重用了部分/全部已分配的内存。
-
(5)当然,在返回数组后调整数组时应该例外。