UnityGC优化 - 容器
4 | 容器
4.1 对象和结构体在数组中的分布
现在比较流行的DOTS经常提到一个概念,就是缓存命中率。CPU从内存直接读取的速度是非常慢的,所以会有高速缓存的存在,CPU读取数据会先从高速缓存中读取,如果缓存中没有该数据,才会从内存中取一片连续的数据放到缓存中,如果先后读取的两个数据不在同一片连续内存中,就会导致cache miss,会从内存中读取数据,读取数据的速度就会变慢。
在存放引用类型的数组中,存放的是对象值的引用,内存中大概是这样的:
数组中存的是对象的引用,数组所占内存大小是数组长度 * IntPtr.Size,而真正的数据要到引用指向的地址上读取,它们的内存是不连续的。
在存放值类型的数组中,存放的是真正的值类型的数据,内存中大概是这样的:
数据是连续存放的,数组所占内存大小是数组长度 * 单个数据内存大小。
PS:数组有一个比较方便的地方,对于下面的值类型数组。
struct S
{
byte b1;
byte b2;
int i1;
}
S[] array;
可以使用下面的读写方式:
array[0].i1 = 2;
或者:
ref item = ref array[0];
item.i1 = 2;
这是和List等容器不一样的地方。
4.2 常用容器的数据结构及增长方式
- List、Stack、Queue:这几个容器都是维护着一个一定容量的数组,当添加数据时,如果数组长度小于添加后的长度,容器容量会增长,方式大概如下:
if(Count + 1 < array.Length)
{
var newArray = new T[array.Length * 2];
Array.Copy(array, 0, newArray, array.Length);
array = newArray;
}
可以看到,容器增长是靠建立一个新的数组来实现的,新数组长度是旧数组的两倍大小(这是为了防止频繁增长),建立新数组后,会将数据 从旧数组复制到新数组中,最后旧的数组会做为内存垃圾丢弃。当然也可以通过设置Capacity或者TrimExcess来指定容器的容量,但是也会发生新建数组丢弃旧数组的过程。像List这样数据地址会变的容器不能使用ref来存取。
- HashSet、Dictionary:在 .NET 4.x 中,这两个容器维护了一个bucket数组和一个entry数组,分别存放索引和数据,当调用Add导致容器增长时,会增长到大于旧容量两倍的一个素数的大小。即使是调用TrimExcess缩小容量,也只能缩小到一个素数的大小。
以上这几类容器,最好根据要存放数据大概的大小,指定容器初始值,这样才能最大限度地防止容量的频繁改变。
- LinkedList:链表,里面有一个LinkedListNode对象,该对象的Next指向下一个LinkListNode对象,这个是完全不连续的内存。LinkListNode可以用缓存的方式来减少GC Alloc,后面会讲到。
4.3 容器操作产生的装箱
最常提到的一种,就是foreach,虽然在Unity 5.6中已经修正了foreach的bug,但是有些方法为了通用性,传入的参数是像ICollection<T>、IDictionary<K,V>等接口,但是对这些接口进行foreach操作还是会发生装箱。这个也可以重载解决。一般来说游戏中是要避免使用Linq的,但是如果想要实现像类似Linq的功能,就一定要注意这一点。
第二种是Dictionary、HashSet,如果Key是结构体,对其进行操作是会发生装箱的,解决方法是实现IEqualityComparer<T>。此外,如果Key是枚举类,在 .NET 4.x 之前也是会有装箱的,解决方法是用int代替或者实现IEqualityComparer<T>, .NET 4.x 以后修复了枚举的这个问题。
浙公网安备 33010602011771号