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 以后修复了枚举的这个问题。

 

 

posted on 2020-03-18 11:37  深秋大街道  阅读(293)  评论(0)    收藏  举报

导航