Unity的GC优化原理及实践

1 | 概述

1.1 简介


内存管理一直都是一个让人比较头疼的东西,尤其是现在重度游戏越来越多,每一次卡顿、每一次内存增长对玩家来说都是一个比较差的体验。技术群里总是有人调侃,游戏开发久了人就会变成“GC怪”。事实上,在游戏开发过程中,随着功能的不停迭代,内存问题一直都不能松懈。

Unity 2018集成了正式版的 .NET 4.x 和 C#7.3 ,引入了ref return和ref locals,让值类型操作更加高效,UnsafeUtility让Unsafe编程和Native Memory操作更加方便。Unity 2019加入了增量式GC,减少了GC带来的卡顿问题。目前来说,虽然内存管理还是一个需要注意的问题,但是却比以往版本灵活易用了很多。

本文希望可以从原理出发,以逐步递进的方式讲解GC优化的问题,主要关注于逻辑代码方面,希望可以给大家带来一定的参考价值。



1.2 什么是GC


GC的全称是Garbage Collection,也就是垃圾回收,是一种自动管理堆内存的机制,管理堆内存上对象的分配和释放。

一般来说,程序比较常用的有三种内存管理方式。

第一种是手动管理,即像C/C++一样使用malloc/free或者new/delete来为对象分配释放内存。这种管理方法的优点就是速度快,没有任何额外的开销,缺点是必须追踪每一个对象的使用情况,很容易发生各种问题,比如内存泄漏、野指针和空悬指针等。

第二种方法是使用引用计数(Reference Count)。它的思想是对象创建出来后,维护一个针对该对象的计数,使用该对象的地方对该计数加1,使用完毕后再减1,当计数为0时,销毁该对象。这种方法可以看做是一种半自动的内存管理方式,优点是可以把分配和释放的开销分布在实际使用过程当中,速度也比较快,不过会存在一个循环引用的问题。引用计数是一种比较常用的内存管理方法,比如Unity中的物理引擎PhysX就是使用引用计数来管理各种对象的。

最后一种方法是本文的重点,即追踪式GC器(Tracing Garbage Collector),Unity使用的GC器是一种叫标记/清除(Mark/Sweep)的算法,它的思路是当程序需要进行垃圾回收时,从根(GC Root)出发标记所有可达对象,然后回收没有标记的对象,这是一种全自动的内存管理方法,程序员完全不用追踪对象的使用情况,也不存在循环引用无法回收的问题,而在Unity中,使用的是一种叫Boehm-Demers-Weiser的GC器,它有以下特点:

  • Stop The World:即当发生GC时,程序的所有线程都必须停止工作,等GC完成才能继续,Unity不支持多线程GC,即使是Unity 2019后使用的增量式GC,在回收时也是要停掉所有线程。
  • 不分代:.NET和Java会把托管堆分成多个代(Generation),新生代的内存空间非常小,而且一般来说,GC主要会集中在新生代上,这让每一次GC的速度也非常快,但是Unity的GC是完全不分代的,即只要发生GC,就会对整个托管堆进行GC(Full GC)。
  • 不压缩:不会对堆内存进行碎片整理,如下图:

图片来源于Unity的官方示例图


GC会造成托管堆出现很多这样的空白“间隙”,这些间隙不会合并,当申请一个新对象时,如果没有任何一个间隙大于这个新对象大小,堆内存就会增加。



1.3 影响GC性能的主要因素


影响GC速度的因素主要有两个:

  • 可达对象数量
  • 托管堆的大小

可达对象是指不会当次GC被回收的对象,减少此类开销的主要方法就是减少对象数量,参考以下实现方法:

        class Item
        {
            public int a;
            public short b;
        }
        Item[] items;

  


对于items数组,每一个元素都会产生一个对象。

而以下代码,不管a和b有多少个元素,数组都只有一个对象,这样就会减少对象数量。

        class Item
        {
            public int[] a;
            public short[] b;
        }
        Item item;

  


而优化托管堆大小主要通过以下几个方面:

  • 减少临时分配:临时分配的内存会产生碎片。
  • 减少内存泄漏:即再也用不到但是又因为存在对其引用无法回收的对象。
 

2 | 类和结构

类和结构的区别以及装箱和拆箱,基本上都是老生常谈了,不过,在开发过程中,还是会产生一个疑问:我的数据该使用类还是结构?这个问题接下来的几个部分都会有涉及到。

2.1 如何估算对象和结构体的大小


结构是值类型,它的结构体实例是存放在栈或者堆中的。在栈中我们保有的是实例的值,所以每一次赋值,都会在栈中多赋值一份实例出来。结构体在内存中所占大小,就是其字段所占大小,但是,结构体的大小并不是简单的所有字段的大小相加,而是存在一个对齐规则,在默认的对齐规则中,基本类型字段是按照自身大小对齐的,如byte是按1字节对齐,int是按4字节对齐。如下面的结构体:

    struct S
    {
        byte b1;
    }

  


这个结构体的大小是1,如果在下面添加一个字段:

    struct S
    {
        byte b1;
        int i1;
    }

  


这个结构体的大小是8,因为int是4字节对齐的,所以只能从第四个字节开始。
如果再添加一个字段:

    struct S
    {
        byte b1;
        int i1;
        byte b2;
    }

  


这个结构体的大小是12,由于struct本身也是要对齐的,所以它的对齐规则是按照其中元素最大的对齐规则决定的。如当前这个结构体是按照i1的对齐规则决定的,也就是四字节对齐,不足四字节则不齐。如果想优化其大小,调整顺序如下,结构体的大小就变成了8。

    struct S
    {
        byte b1;
        byte b2;
        int i1;
    }

 


类是引用类型,它的对象实例存放在堆中,对象实例一定是会占用堆内存的,而在栈中,我们保有的是实例的引用,对象在堆内存中大概是如下图所示:

其中vtable是类的共有数据,包含静态变量和方法表(在Mono中,结构的静态变量也存放在vtable里,它是缓存在一个叫tablecache的哈希表当中的,而IL2CPP中类和结构的静态变量存在一个单独的类里)。Monitor是线程同步用的,这两个指针分别占用一个IntPtr.Size大小(32位中是4字节,64位中是8字节),再下面是所有字段,字段是从第9个字节或17个字节开始的,字段的对齐规则与结构体的对齐规则相同,区别是Mono中对象实例会把引用类型的引用摆在最前面。一个对象实例的大小(instance_size)就是IntPtr.Size * 2+字段所占大小,结构体被装箱后在堆内存的大小也一样。

通过调整字段顺序,可以优化对象和结构体大小,特别是有容器存放多个对象或结构体的,可以减少堆内存占用。

此外,我们还可以通过StructLayoutAttribute自定义类和结构字段的对齐方式。比如下面的结构体:

    [StructLayout(LayoutKind.Sequential, Pack = 1)]
    public struct S
    {
        byte b1;
        int i1;
        byte b2;
    }

 


该结构体强制按1字节对齐,所以它的大小就是6。

    [StructLayout(LayoutKind.Explicit)]
    public struct S
    {
        [FieldOffset(0)]byte b1;
        [FieldOffset(0)]int i1;
        [FieldOffset(1)] byte b2;
    }

  


这个结构体的大小是4,它实现了类似C/C++中union的类型,b1、b2与i1共用同一段内存,b1和b2也代表了i1的前两个字节。

注意,内存对齐是会考虑硬件优化的,使用StructLayout修改对齐方式有可能会降低性能。



2.2 装箱和拆箱


装箱和拆箱的过程很多文档都会有描述,这里就不再细说了。只说几个比较容易忽视的地方。

  • 如果结构体实现了某个接口,那么结构体转换为接口会发生装箱。
  • 对值类型实例调用GetType()会发生装箱。
  • 对结构体调用ToString(),GetHashCode():在Mono中,直接调用不会发生装箱,但是在IL2CPP中却会有装箱。如果重写了这两个方法,调用时就都不会发生装箱,但是如果调用了base.ToString()或base.GetHashCode(),还是会发生装箱。
  • 某些容器类操作时发生的装箱会在下面提到。
 

 

3 | 参数与泛型

3.1 参数传递


对象和结构体另一个区别体现在参数传递上面。
正常来说,C#中所有参数传递都是值传递,方法传递的参数都是值的副本。对象和结构体的区别就是,因为栈中存放的只能是对象的引用,所以传参时,复制的是引用的值,大小是4字节(32位)或8字节(64位),也就是IntPtr.Size。如果参数是结构体,则会把整个结构体都复制一次,大小就是这个结构体的大小,如果传递的结构体很大,也会造成一定的开销。



3.2 ref,in和out


这三个关键字,可以使参数按引用传递,ref表示传进的参数可读写,in表示参数是只读的,out表示参数是只写的,类似返回值。按引用传参,特别是传递较大的结构体参数,可以减少复制带来的开销。在MSDN的优化建议中也提到,推荐所有大于IntPtr.Size的结构体,传参时都按引用传递。

此外,在 .NET 4.x 以后,ref关键词也可以修饰返回值了。如下面的代码:

    public class Test
    {
        public UnityEngine.Ray ray { get; private set; }
        ...
        void Calc()
        {
            UnityEngine.Ray ray1 = ray;
            ...
            Calc1(ray1);
        }
        void Calc1(UnityEngine.Ray ray2)
        {
            ...
        }
    }

  


Test类缓存了一个Ray类型的结构体(24个字节)给其它地方使用,而在Calc中通过赋值把ray复制了一份出来进行计算,然后又通过传参又复制了一份给Calc1进行计算。一共进行了两次复制,下面将其改成:

    public class Test
    {
        private UnityEngine.Ray _ray;
        public ref UnityEngine.Ray ray => ref _ray;
        ...
        void Calc()
        {
            ref UnityEngine.Ray ray1 = ref ray;
            ...
            Calc1(ref ray1);
        }
        void Calc1(ref UnityEngine.Ray ray2)
        {
            ...
        }
    }

  


同样缓存了一个Ray的结构体,但是公开的变量只是对于这个结构体的引用,Calc和Calc1只是复制了其引用,并没有复制该结构体。这样做有一个缺点,就是公开暴露了ray的引用,可以让其它地方随意修改缓存的_ray变量。下面改成这样:

    public class Test
    {
        private Ray _ray;
        public ref readonly Ray ray => ref _ray;
    
        void Calc()
        {
            ref readonly Ray ray1 = ref ray;
            ...
            Calc1(in ray1);
        }
        void Calc1(in Ray ray2)
        {
            ...
        }
    }

 


通过readonly标记其为只读,其它地方也要做相应的更改。

注意:ref return一定不要返回方法内局部变量的引用。

其它可以参考MSDN上的链接:编写安全有效的 C# 代码



3.3 使用泛型优化装箱


有时方法为了保证通用性,会使用object作为参数,如:

    void Func(object o)
    {
        ...
    }

  


这样如果参数传入值类型,就会产生装箱,这时为了避免装箱会使用泛型来优化,如:

    void Func<T>(T o)
    {
        ...
    }

  


但是这样的方法在IL2CPP中会有一个问题,因为IL2CPP是AOT机制的,所有泛型调用最后会为每一种类型单独生成代码,增加代码体积,同时也会增加堆内存,如:

    struct S
    {
        byte b1;
        byte b2;
        int i1;
    }
    ...
    Func(new S());

  


这样的调用,会生成这样的函数(参数是S_t082EE9B9D612912310173429A71A74D0E6044782):

    extern "C" IL2CPP_METHOD_ATTR void Test_Func_TisS_t082EE9B9D612912310173429A71A74D0E6044782_m2DFFB1A6C1B583FBB182364222DA7BCF8BAC96D2_gshared (Test_tD59136436184CD9997A7B05E8FCAF0CB36B7193E * __this, S_t082EE9B9D612912310173429A71A74D0E6044782  ___t0, const RuntimeMethod* method)

  

但是如果是:

    Func(new object());
    Func("123");

  


这两种调用只会生成这样一个函数(参数是RuntimeObject):

    extern "C" IL2CPP_METHOD_ATTR void Test_Func_TisRuntimeObject_m8B09041F4A36F01BAEE4FF189468F3AEC4392341_gshared (Test_tD59136436184CD9997A7B05E8FCAF0CB36B7193E * __this, RuntimeObject * ___t0, const RuntimeMethod* method)

这是因为IL2CPP中有一个泛型类型共享的机制,如所有引用类型,都会只生成参数是RuntimeObject的函数,还有整数和枚举,也会只生成一个参数是int32_t的函数,但是,对于其它值类型,是没有办法共享的,所以只能针对每一个类型单独生成一个函数。

所以,要权衡其中的利害,谨慎使用泛型参数。



3.4 可变参数


使用params的可变参数是一种语法糖,它和传入一个数组是等价的。
比如:

    void Func(params int[] n);

  

这个方法,如果调用

Func(1,2,3);

  

它其实等价于

Func(new int[]{1,2,3});

  

会临时产生一个数组,但是

Func();

  

这个调用等价于

Func(Array.Empty<int>);

  

这个是C#缓存的一个空数组,并不会临时产生新对象。

所以,只要可变参数不为空,就一定会产生临时的数组,大量调用会产生很多的GC Alloc。

优化方法是用一系列若干数量的参数的重载方法代替,如C#的string.Format是这样写的(来自https://github.com/Unity-Technologies/mono/blob/unity-master/mcs/class/referencesource/mscorlib/system/string.cs ):

    public static string Format (string format, object arg0);
    public static string Format (string format, object arg0, object arg1);
    public static string Format (string format, object arg0, object arg1, object arg2);
    public static string Format (string format, params object[] args);

  


把常用的1个、2个、3个参数的方法单独提出来,剩下的再用可变参数来做。



3.5 Conditional特性


在开发过程当中,一般为了调试方便,会在代码中加入很多控制台输出,比如:

    Debug.Log("123");

  


然后在真机会调用unityLogger.logEnabled或unityLogger.filterLogType对其进行开关,但是这种并不能阻止传入参数本身造成的GC Alloc,如

    Debug.Log(123);
    ...
    Debug.Log(string.Format("12{0}", 3));

  


类似这样的装箱和字符串操作都会产生GC Alloc,这里可以使用如下方法:

    #if UNITY_EDITOR
        Debug.Log(123);
    #endif
    ...
    #if UNITY_EDITOR
        Debug.Log(string.Format("12{0}", 3));
    #endif

  

该方法可以避免在真机上生成代码,可是需要用#if和#endif扩住的地方太多了,代码可读性会降低,这时可以用一个[Conditional]来完成这样的操作,如下面方法:

    [Conditional("UNITY_EDITOR")]
    public static void Print(object message)
    {
        Debug.Log(message);
    }

  

这样代码中调用:

    Print(123);
    ...
    Print(string.Format("12{0}", 3));

  


在真机上不会生成任何代码,也就不会发生GC Alloc了,同时也维护了代码的可读性。

 

 

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

 

 

5 | 对象池

UGUI中有一个通用对象池的实现(来源于 ObjectPool.cs ):

    class ObjectPool<T> where T : new()
    {
        private readonly Stack<T> m_Stack = new Stack<T>();
        private readonly UnityAction<T> m_ActionOnGet;
        private readonly UnityAction<T> m_ActionOnRelease;
    
        public int countAll { get; private set; }
        public int countActive { get { return countAll - countInactive; } }
        public int countInactive { get { return m_Stack.Count; } }
    
        public ObjectPool(UnityAction<T> actionOnGet, UnityAction<T> actionOnRelease)
        {
            m_ActionOnGet = actionOnGet;
            m_ActionOnRelease = actionOnRelease;
        }
    
        public T Get()
        {
            T element;
            if (m_Stack.Count == 0)
            {
                element = new T();
                countAll++;
            }
            else
            {
                element = m_Stack.Pop();
            }
            if (m_ActionOnGet != null)
                m_ActionOnGet(element);
            return element;
        }
    
        public void Release(T element)
        {
            if (m_Stack.Count > 0 && ReferenceEquals(m_Stack.Peek(), element))
                Debug.LogError("Internal error. Trying to destroy object that is already released to pool.");
            if (m_ActionOnRelease != null)
                m_ActionOnRelease(element);
            m_Stack.Push(element);
        }
    }

  

一般为了减少临时分配,会经常将临时生成的对象缓存起来供下一次用,但是如果每个类都单独缓存一份,也会造成内存上的浪费,而对象池则将整个项目的缓存全部都共用起来,会节省很大一部分内存。注意:每次用完都需要手动调用Release。

下面列举几个对象池的应用。



5.1 BaseMeshEffect


在UGUI中,使用这个ObjectPool的地方是在修改控件Mesh的时候,一般用的是一个特殊的对象池叫 ListPool.cs :

    static class ListPool<T>
    {
        // Object pool to avoid allocations.
        private static readonly ObjectPool<List<T>> s_ListPool = new ObjectPool<List<T>>(null, Clear);
        static void Clear(List<T> l) { l.Clear(); }
    
        public static List<T> Get()
        {
            return s_ListPool.Get();
        }
    
        public static void Release(List<T> toRelease)
        {
            s_ListPool.Release(toRelease);
        }
    }

  

当扩展BaseMeshEffect实现UI特效,经常会用到这个ListPool,如:

    public override void ModifyMesh(VertexHelper vh)
    {
                if (!IsActive())
                    return;
    
        var verts = ListPool<UIVertex>.Get();
        vh.GetUIVertexStream(verts);
        ...
    
        vh.Clear();
        vh.AddUIVertexTriangleStream(verts);
        ListPool<UIVertex>.Release(verts);
    }

  

需要注意一点的是,UIVertex结构体的大小是76字节,GetUIVertexStream是按三角形顶点来输出的,如果对Text类型使用这种方法修改Mesh,字数很多的时候,会把缓存的List撑到很大,比如内置的Outline,这样不止会撑大堆内存,在像Image这种一般只有两个三角形六个顶点的对象,使用较大List缓存也会有不必要的Clear开销。如果自定义的BashMeshEffect想要修改Text,有两种方法:

  • 设置两个ListPool,图片和文字分开。
  • 使用以下方法来代替:
        vh.PopulateUIVertex(ref vert, index);
        vh.SetUIVertex(vert, index);
        vh.AddVert(vert);

  


用这几种方法可以使用UGUI内部的ListPool(VertexHelper维护的是List<int>, List<Vertex3>等一系列缓存),可以减少代码中使用自定义ListPool带来的开销。



5.2 LinkedList


前面说过,LinkedList是一串LinkedListNode对象的组合,如果频繁的添加、删除,会生成很多无用的LinkedListNode对象,下面是用对象池缓存LinkedListNode的一个实现:

public class PooledLinkedList<T> : LinkedList<T>
{
    static Stack<LinkedListNode<T>> s_pool = new Stack<LinkedListNode<T>>(10);
    public PooledLinkedList()
    {
    }
    private LinkedListNode<T> Create(T t)
    {
        LinkedListNode<T> node = null;
        if (s_pool.Count > 0)
        {
            node = s_pool.Pop();
            node.Value = t;
        }
        else
        {
            node = new LinkedListNode<T>(t);
        }
        return node;
    }
    public new void AddLast(T t)
    {
        base.AddLast(Create(t));
    }
    public new void AddFirst(T t)
    {
        base.AddFirst(Create(t));
    }
    public new void AddAfter(LinkedListNode<T> n, T t)
    {
        base.AddAfter(n, Create(t));
    }
    public new void AddBefore(LinkedListNode<T> n, T t)
    {
        base.AddBefore(n, Create(t));
    }
    public new void Remove(LinkedListNode<T> n)
    {
        int count = Count;
        base.Remove(n);
        if (count != Count)
        {
            s_pool.Push(n);
        }
    }
    public new void RemoveFirst()
    {
        if (Count <= 0)
            return;
        s_pool.Push(this.First);
        base.RemoveFirst();
    }
    public new void RemoveLast()
    {
        if (Count <= 0)
            return;
        s_pool.Push(this.Last);
        base.RemoveLast();
    }
    public new void Clear()
    {
        if (Count <= 0)
            return;
        for (var cur = First; cur != null; cur = cur.Next)
        {
            s_pool.Push(cur);
        }
        base.Clear();
    }
}

  

 

 

6 | 字符串

C#的string是不可变的,在通常情况下,一旦生成,就无法被改变(使用Unsafe除外),这样会导致两个问题:

  • 每一次拼接或修改字符串都会生成一个新字符串,一般旧的字符串就会作为垃圾存在。
  • 内存中会出现多份内容一样的字符串资源,如两次用相同方法拼接的字符串。这样会造成内存浪费。这里有一个例外,就是“abc”+“def”的形式,会被编译器直接编译成“abcdef”,当然基本上也没有人会这么写。

针对第二个问题,在C#中有一个stringpool可以解决,就是一个内置的字符串池,是一个哈希表的数据结构,一般代码中用直接写的字符串,如“abcdef”,会存在于stringpool中,而拼接的字符串,可以使用string.Intern(str); 把字符串放在stringpool中并指向在stringpool中的实例。

看看下面几个测试用例(Assert.AreSame的意思是两个对象引用的地址相同):

    a: Assert.AreSame("12", "12".ToString());
    
    b: Assert.AreSame("12", 12.ToString());
    
    c: Assert.AreSame("12", string.Intern(12.ToString()));
    
    d: Assert.AreSame("12", "1" + "2");
    
    e: Assert.AreSame("12", "1".ToString() + "2");

  


  • a:“12”是在stringpool中的,字符串的 ToString() 方法并不能生成新的字符串,所以这个会通过。
  • b:12.ToString() ,会生成一个新的 “12” 实例,所以这个会失败。
  • c:string.Intern 会返回指向stringpool中的地址,新生成的实例会被抛弃,所以这个会通过。
  • d:这个看似是字符串拼接,但是一般编译器会将其优化成 “12” ,所以这个会通过。
  • e:虽然 “1”.ToString() 返回的是 “1” ,在stringpool的实例中看似和d相同,可是这个不会被Mono或IL2CPP编译器优化掉,拼接后生成了一个新字符串,所以这个会失败。



6.1 自定义string内存池


内置的内存池有两个缺点:

  • 无法清空,比如我在这次战斗中经常会生成的字符串,在下一场战斗中不会经常生成,如人名等,使用内置的stringpool会造成内存泄漏。
  • 每次调用string.Intern会将生成的字符串抛弃,如果频繁使用会产生很多的垃圾。

首先是要分清哪些字符串是需要在某一时刻清空的,哪些时候是可以常驻内存的,如:

  • 整数型:比如倒计时”12s“之类的。可以使用单独的一个池常驻内存,防止每次都生成新的字符串,如:
    public struct Key
      {
          public string format;
          public int number;
      }
      public class KeyComparer : IEqualityComparer<Key>
      {
          ...
      }
      Dictionary<Key, string> intToStringPool;

  

这种第一次生成字符串时可以调用string.Intern再放入字典中,保证全局唯一。

  • 常用名称拼接,一般两到三个字符串拼在一起:如 “gun_” + “ak47” 这种形式,可以常驻内存,例子和上面的差不多。
  • xx击杀了yy,这种形式:只在当前战斗有效,如果想缓存,需要单独一个pool,战斗结束后需要清空。

 

 

7 | 匿名方法和协程

7.1 匿名方法优化


匿名方法一般有下面几种形式:

    void Func()
    {
        int a = 1;
        Call(()=>a = 2);
        ...
    }

  

以上这种形式,调用局部变量,在每次运行时,都会生成一个新的对象,有很高的GC Alloc。

    int a = 1;
    void Func()
    {
        Call(()=> a = 2);
    }

  

调用了类的字段,同样也会在每次运行时生成一个新的对象。

    void Func1()
    {
        ...
    }
    void Func()
    {
        Call(Func);
    }

  

用上面方法做参数,也会生成新的对象。

    int Func(int a)
    {
        return Call(a, (_a)=> {_a = 2; return _a;});
    }

  

不使用任何外部变量,在Mono中不会产生GC Alloc。

    static int a = 1;
    void Func()
    {
        Call(()=> a = 2);
    }

  

上面这种,调用静态变量,在Mono中是不会产生GC Alloc的。

注意,在IL2CPP中,以上所有形式都会产生GC Alloc。

所以,在任何地方都要避免使用匿名方法,如果需要使用,可以缓存一下再用,如:

    int a = 1;
    Action action;
    void Func()
    {
        if(action == null)
        {
            action = () => a = 2;
        }
        Call(action);
    }

  


这样,无论是Mono,还是IL2CPP,都不会产生额外的GC Alloc。



7.2 协程的优化


协程是用IEnumerator实现的,每帧对其执行MoveNext,MoveNext执行类似下面的代码(实际流程会复杂很多):

    bool MoveNext(IEnumerator coroutine)
    {
     var child = coroutine.Current as IEnumerator;
        if (child != null && MoveNext(child))
            return true;
        return coroutine.MoveNext();
    }

  


参考下面的代码:

    IEnumerator Coroutine1()
    {
        ...
    }
    IEnumerator Coroutine()
    {
        yield return new WaitForSeconds(5);
        ...
        yield return Coroutine1();
    }

  


在Coroutine里,会先用WaitForSeconds等待5秒,然后执行其他代码,然后执行另一个Coroutine1方法,它的问题是:

  • yield return new WaitForSeconds每次都会创建一个对象。
  • IEnumerator方法会在编译时生成一个类,每次 yield return 这个方法,也都会创建一个对象。

针对第一种情况,可以将WaitForSeconds缓存:

    IEnumerator Coroutine1()
    {
        ...
    }
    
    WaitForSeconds seconds = new WaitForSeconds(5);
    IEnumerator Coroutine()
    {
        yield return seconds;
        ...
        yield return Coroutine1();
    }

  


针对第二种情况,缓存是不能使用的,因为Coroutine1生成的类中没有实现Reset方法,所以需要手动实现一个类:

    class Coroutine1 : IEnumerator
    {
        public Coroutine1()
        {
            ...
        }
        public bool MoveNext()
        {
            ...
        }
        public void Reset()
        {
            ...
        }
        public object Current
        {
            get
            {
                ...
            }
        }
    }

  


上面这个类,即使缓存了一个对象,也需要每次都手动调用Reset方法将其重置,Coroutine方法变成下面这样:

    WaitForSeconds seconds = new WaitForSeconds(5);
    Coroutine1 coroutine1 = new Coroutine1();
    IEnumerator Coroutine()
    {
        while (true)
        {
            yield return wait;
            ...
            c.Reset();
            yield return coroutine1;
        }
    }

  


这样就不会产生额外的GC Alloc了。

 

 

8 | Unity API

8.1 object.name和object.tag


需要避免使用,但是object.CompareTag没有GC Alloc。



8.2 所有返回是数组的API都会有GC Alloc


不过,大部分API也都会提供一个可以传入List参数的方法,比如比较常见的:

        Text[] texts = GetComponentsInChildren<Text>();
        ...

  

每次调用都会生成一个新的数组对象,可以配合ListPool对其进行优化:

        List<Text> texts = ListPool<Text>.Get();
        GetComponentsInChildren(texts);
        ...
        ListPool<Text>.Release(texts);

  


再比如:

        var materials = renderer.sharedMaterials;
    ...

  


这种很容易被当成字段来使用,但其实每次调用也会生成新的Material[]数组,可以改成下面的代码:

        List<Material> materials = ListPool<Material>.Get();
        renderer.GetSharedMaterials(materials);
        ...
        ListPool<Material>.Release(materials);

  

还有导航网格,即使缓存了NavMeshPath,navMeshPath.corners也是一个会生成一个数组,使用GetCornersNonAlloc并传入一个足够大小的数组会解决这个问题。如:

    public static Vector3[] cachedPath{get;} = new Vector3[256];
    public static int pathCount{get;private set;}
    private static NavMeshPath navMeshPath;
    
    public static void CalculatePath(Vector3 startPos, Vector3 endPos)
    {
        navMeshPath.ClearCorners();
        NavMesh.CalculatePath(startPos, endPos, NavMesh.AllAreas, navMeshPath);
        pathCount = navMeshPath.GetCornersNonAlloc(cachedPath);
        ...
    }

  

最后,Physics.RayCastAll也有相应的Physics.RayCastNonAlloc方法,这里就不再细说了。



8.3 UGUI


UGUI的GC优化其它文章说的比较详细了,这里说一个比较容易忽视的一点,就是当Prefab中有大量空的Text,初始化的时候会有一个很严重的GC Alloc,这是因为在初始化时,会先初始化TextGenerator,如果Text为空,则会先按50个字来初始化,即50个字的UIVertex和50个字的UICharInfo,这种可以不让它为空,或者填一个空格进去来阻止。

 

 
 

9 | Protobuf 3的优化

Protobuf是比较常用的消息协议,它利用变长整数和默认值的方法压缩消息,提高了消息传输的效率,又用生成代码提高序列化/反序列化速度。但是在使用过程中,还是存在比较大的坑,如果使用不当,会造成很大的GC压力。

这里说一下如何用对象池来优化Protobuf 3的GC。



9.1 字节流的缓存


首先,当反序列化字节流的时候,一般不要使用下面的代码:

    MemoryStream stream;
    ...
    message.MergeFrom(stream);

  

传入参数如果是stream,会新建一个CodedInputStream,并新建一个byte[]数组,相当于把字节流又复制了一份,如果改成这样:

    byte[] cachedBytes;
    ...
    message.MergeFrom(cachedBytes);

  

这样会省掉复制字节流的开销。

也可以用对象池缓存CodedInputStream,类似如下代码:

    public class NetMsgInStream
    {
        public CodedInputStream codedStream { get; private set; }
        public MemoryStream memoryStream { get; } = new MemoryStream();
        public NetMsgInStream()
        {
            this.codedStream = new CodedInputStream(memoryStream);
        }
    
        public static ObjectPool<NetMsgInStream> pool { get; }
            = new ObjectPool<NetMsgInStream>(actionOnRelease: (s) => s.memoryStream.SetLength(0));
    }

    var stream = NetMsgInStream.pool.Get();
    message.MergeFrom(stream.codedStream);
    NetMsgInStream.pool.Release(stream);

  

事实上,Protobuf的所有扩展方法都不建议滥用。



9.2 消息类的缓存


频繁生成的消息类,如战斗时的移动同步消息,如果每次接受到消息都新建一个对象,一场战斗下来会有很大的GC Alloc。

所以,最好也要用对象池来缓存。

但是,仅缓存是不够的。如前文所说,Protobuf为了节省传输压力,对默认值进行了优化,所以,并不是所有字段都会在反序列化的时候赋值,这时如果服务器生成的消息某个字段是默认值,就不会放入字节流里传给前端。

如果消息在放回缓冲池时没有把所有字段恢复到默认值,下一次反序列化重用到这个对象时,字段有可能还是上一次的值。所以,在使用protoc生成消息类时,最好也为消息生成一个Clear方法,清空所有字段。

plugin在Protobuf 3中已经不能用了,所以比较方便的做法是直接修改protoc,在csharp_message.cc的MessageGenerator::Generate中添加:

    printer->Print("public void Clear()\n{\n");
    for (int i = 0; i < descriptor_->field_count(); i++)
    {
        const FieldDescriptor* fieldDescriptor = descriptor_->field(i);
        if (fieldDescriptor->is_repeated() || fieldDescriptor->type() == FieldDescriptor::Type::TYPE_MESSAGE || fieldDescriptor->type() == FieldDescriptor::Type::TYPE_BYTES)
        {
            printer->Print(" if($field_name$_ != null)$field_name$_.Clear();\n", "field_name", fieldDescriptor->name());
        }
        else if (fieldDescriptor->type() == FieldDescriptor::Type::TYPE_ENUM)
        {
            printer->Print(
                " $field_name$_ = $field_type$.$default_value$;\n", "field_type", GetClassName(fieldDescriptor->enum_type()), "field_name", fieldDescriptor->name(), "default_value", fieldDescriptor->default_value_enum()->name());
        }
        else
        {
            printer->Print(
                " $field_name$_ = $default_value$;\n", "field_name", fieldDescriptor->name(),"default_value", fieldDescriptor->GetDefaultValue());
        }
      }

  

例如一个位置同步消息MoveSync的简化版的结构大概如下图,包含在某一帧的所有玩家的位置信息(MoveInfo):

    message MoveInfo
    {
        int64 id = 1;
        Vector3Msg position = 3;
    }
    message SCP_MoveSync {
      float timeStamp = 1;
      repeated MoveInfo moveInfo = 2;
    }

  

MoveInfo生成的Clear方法:

    public void Clear()
    {
        id_ = 0;
        if(position_ != null) position_.Clear();
    }

  


MoveSync生成的Clear方法:

    public void Clear()
    {
        timeStamp_ = 0;
        if(moveInfo_ != null)moveInfo_.Clear();
    }

  

这样缓存后,还是存在一个问题。如下图:

这是项目最初在UWA上的Mono测试的截图,我们缓存了MoveSync消息,但是可以看到,消息线程中还是有43%的GC Alloc都是位置同步消息,整个测试期间产生了10.46MB的GC Alloc。

这是因为,MoveSync中的MoveInfo是RepeatedField<MoveInfo>这种形式,即里面是一个消息容器,每次反序列化时,不能调用外部逻辑层的对象池,所以还是会创建新的message,截图中,10MB中有6MB创建MoveInfo对象,3MB是创建MoveInfo中的其它消息类(如: Vector3Msg)。

解决方法是在Protobuf的C#代码中留出一个接口,可以设置外部的对象池,当反序列化MoveSync时,Protobuf可以使用对象池中的MoveInfo,这里可以在MessageParser.cs中添加:

    public static ObjectPool<T> pool;
    
    internal new T CreateTemplate()
    {
        if (pool != null)
            return pool.Get();
        else
            return factory();
    }

  


做完上面的工作,就可以针对MoveSync和MoveInfo使用对象池了,对象池类似这样:

    static ObjectPool<MoveInfo> moveInfoPool = new ObjectPool<MoveInfo>(
        null,
        msg=>msg.Clear();
    )
    static ObjectPool<MoveSync> moveSyncPool = new ObjectPool<MoveSync>(
        null,
        msg=>
        {
            foreach(var item in msg.moveInfo)
            {
                moveInfoPool.Release(msg.moveInfo);
            }
            msg.Clear();
        }
    )

  

当MoveSync返回对象池的时候,需要遍历将所有MoveInfo返回到对象池。

然后,将对象池添加到Protobuf中:

    MessageParser<MoveInfo>.pool = moveInfoPool;

  


这样就可以使用对象池来创建MoveSync实例了。

优化后的MoveSync:

可以看到,优化后只剩下RepeatedField扩容造成的开销了,这里可以通过设置初始容量来优化。

关于Protobuf的修改全都放在这里了。

 

 

10 | Unsafe和非托管内存

Unsafe适用于对于性能有较高要求或者需要做某些特殊操作的时候,它把类似C++的指针操作暴露出来,让开发时具有很大的灵活性。Unity 2018以后又提供了UnsafeUtility工具类,让指针操作更加便捷。



10.1 Unsafe优化字符串操作的GC


比较简单的也比较常用的是ToLower:

    public static void ToLower(string str)
    {
        fixed (char* c = str)
        {
            int length = str.Length;
            for (int i = 0; i < length; ++i)
            {
                c[i] = char.ToLower(c[i]);
            }
        }
    }

  

这样直接修改了原字符串,将其所有字符全都改成小写。

还有一个比较常用的操作是split,通过分隔符生成一个字符串数组,虽然数组中每个字符串要缓存起来比较麻烦,但是数组本身是可以缓存出来的:

    public static int Split(string str, char split, string[] toFill)
    {
        if (str.Length == 0)
        {
            toFill[0] = string.Empty;
            return 1;
        }
        var length = str.Length;
        int ret = 0;
        fixed (char* p = str)
        {
            var start = 0;
            for (int i = 0; i < length; ++i)
            {
                if (p[i] == split)
                {
                    toFill[ret++] = (new string(p, start, i - start));
                    start = i + 1;
                    if (i == length - 1)
                        toFill[ret++] = string.Empty;
                }
            }
            if (start < length)
            {
                toFill[ret++] = (new string(p, start, length - start));
            }
        }
        return ret;
    }

  

方法传入了一个缓存的string[],然后根据split遍历分割字符串。但如果是根据split遍历操作每一个字符串,也可以缓存一个单独的长度比较大的字符串,每次分割后的字符都复制进这个缓存里,当然,这就需要动态修改字符串的长度了,字符串的长度修改方法如下:

    public static void SetLength(this string str, int length)
    {
        fixed (char* s = str)
        {
            int* ptr = (int*)s;
            ptr[-1] = length;
            s[length] = '\0';
        }
    }

  

字符串最后一个字符后面的字符必须是‘\0’,这个和C++是一样的,字符串的首字母地址之前的一个int代表了字符串的长度。

可以设置字符串长度以后,也就可以实现Substring了:

    public static void Substring(string str, int start, int length = 0)
    {
        if (length <= 0)
        {
            length = str.Length - start;
        }
        if (length > str.Length - start)
        {
            throw new IndexOutOfRangeException($"{length} > {str.Length} - {start}");
        }
        fixed (char* c = str)
        {
            UnsafeUtility.MemMove(c, c + start, sizeof(char) * length);
        }
        SetLength(str, length);
    }

  

此外还可以实现字符串拼接、Path.Combine、string.Format等方法。

使用Unsafe操作字符串可以不必生成新的字符串,从而减少GC Alloc,不过需要注意几点:

  • 指针操作没有越界检查,如果修改字符串的长度,要确保长度小于等于字符串的原始长度。
  • 谨慎修改intern字符串的内容。
  • 修改字符串内容会使字符串的hashcode发生改变,如果修改的字符串是某个字典的Key,需要将其从字典中移除,修改后再放进去。



10.2 使用Unsafe优化反射


反射中一个比较常用的东西是fieldInfo.SetValue,或fieldInfo.GetValue,如果字段是值类型的,就会有一次装箱或拆箱,使用Unsafe可以通过字段在内存中的偏移量来赋值,如下给一个int字段赋值:

    var offset = (int)Marshal.OffsetOf<T>(fieldName) + IntPtr.Size * 2;
    var address = (byte*)UnsafeUtility.PinGCObjectAndGetAddress(obj, out var gcHandle);
    *(int*)(address + offset) = value;
    UnsafeUtility.ReleaseGCObject(gcHandle);

  

首先取得字段的偏移量,因为是对象,所以偏移量要加IntPtr.Size * 2(也可以直接使用UnsafeUtility.GetFieldOffset(fieldInfo),不用修改偏移量,但是有GetFieldInfo的开销),然后,PinGCObjectAndGetAddress将对象的地址固定住,通过偏移量来赋值,最后释放对象句柄。

取字段偏移量是一个非常耗时的操作,最好可以提前缓存这个偏移量,再调用时速度就会变得非常快了。
下面是三种方式的对比(设置对象中一个整数的值,10000次迭代):

其中fieldInfo.SetValue用到的fieldInfo是提前缓存了的,可以看出来,Marshal.OffsetOf的开销是非常大的(UnsafeUtility.GetFieldOffset开销也很大),而如果缓存了offset,速度会比fieldInfo.SetValue提升十倍。



10.3 非托管堆


相对于托管堆,非托管堆有一个好处,就是可以手动申请和释放,此外,Unity的DOTS大量使用Native容器也是为了能保证尽量使用连续内存。UnsafeUtility提供了方便的接口手动管理非托管内存,下面是一个使用非托管堆的UnsafeList示例。

可以使用非托管堆的类型必须是Blittable,也就是必须是结构体,而且里面的字段只包含基本值类型和Blittable结构体。所以,声明可以写成:

    public unsafe struct UnsafeList<T> where T : unmanaged
    {
        static int alignment = UnsafeUtility.AlignOf<T>();
        static int elementSize = UnsafeUtility.SizeOf<T>();
        const int MIN_SIZE = 4;
    
        ArrayInfo* array;
        ...
    }

  

unmanaged约束可以看作是Blittable,但是有个问题就是不包含泛型结构体,如果不需要泛型结构体可以忽略,或者使用struct约束,但是struct约束就不能用指针T * 来存取数据了,需要换一种方式。这里还是使用unmanaged约束。

几个静态变量缓存了申请内存所需要的信息,数据信息存在ArrayInfo的指针中:

    unsafe struct ArrayInfo
    {
        public int count;
        public int capacity;
        public void* ptr;
    }

  

信息包含长度、容量,真正的数据保存在ptr中。

首先是构造函数:

    public UnsafeList(int capacity)
    {
        capacity = Mathf.Max(MIN_SIZE, capacity);
        array = (ArrayInfo*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<ArrayInfo>(), UnsafeUtility.AlignOf<ArrayInfo>(), Allocator.Persistent);
        array->capacity = capacity;
        array->count = 0;
        array->ptr = UnsafeUtility.Malloc(elementSize * capacity, alignment, Allocator.Persistent);
    }

  

UnsafeUtility提供了多种Allocator,生命周期和性能都不相同,具体可以参见官方文档。 然后如果不使用这个List,需要手动将其释放:

    public void Dispose()
    {
        UnsafeUtility.Free(array->ptr, Unity.Collections.Allocator.Persistent);
        UnsafeUtility.Free(array, Unity.Collections.Allocator.Persistent);
    }

  

当容量不够时,可以像List一样扩容:

    void EnsureCapacity(int newCapacity)
    {
        if (newCapacity > array->capacity)
        {
            newCapacity = Mathf.Max(newCapacity, array->count * 2);
            var newPtr = UnsafeUtility.Malloc(elementSize * newCapacity, alignment, Allocator.Persistent);
            UnsafeUtility.MemCpy(newPtr, array->ptr, elementSize * array->count);
            UnsafeUtility.Free(array->ptr, Allocator.Persistent);
            array->ptr = newPtr;
            array->capacity = newCapacity;
        }
    }

  

申请新内存,将旧的数据复制到新的内存中,再释放旧内存。有了这个就可以添加数据了:

    public void Add(T t)
    {
        EnsureCapacity(array->count + 1);
        *((T*)array->ptr + array->count) = t;
        ++array->count;
    }
    public void Insert(int index, T t)
    {
        EnsureCapacity(array->count + 1);
        if (0 <= index && index <= array->count)
        {
            UnsafeUtility.MemMove((T*)array->ptr + index + 1, (T*)array->ptr + index, (array->count - index) * elementSize);
            *((T*)array->ptr + index) = t;
            ++array->count;
        }
        else
        {
            throw new IndexOutOfRangeException();
        }
    }

  

如果复制的内存区域重叠,不管是向前还是向后,最好都使用memmove,内部会决定要不要考虑重叠区域。AddRange和Remove也是类似的实现方法。

在List中,Clear方法因为要考虑元素是引用类型的情况,为了能让GC正常回收List中的对象,必须把所有数据全都归零,但是这里因为不存在这种情况,所以Clear方法很简单:

    public void Clear()
    {
        array->length = 0;
    }

  


最后是读写,因为索引器的set方法不支持ref参数,所以可以直接用指针:

    public T* this[int index]
    {
        get
        {
            if (0 <= index && index < array->count)
            {
                return ((T*)array->ptr + index);
            }
            throw new IndexOutOfRangeException();
        }
        set
        {
            if (0 <= index && index < array->count)
            {
                *((T*)array->ptr + index) = *value;
            }
            throw new IndexOutOfRangeException();
        }
    }

  

然后也可以提供一个单独的ref return方法:

    public ref T Get(int index)
    {
        return ref *this[index];
    }

  

这样一个使用非托管堆的容器就诞生了。



10.4 stackalloc、Span<T>和Memory<T>


stackalloc关键词,可以申请栈内存:

    void Calculate()
    {
        Vector3* s = stackalloc Vector3[10];
        ...
    }

  


如果计算只需要一组占用比较小的临时数据,使用stackalloc是一个很好的选择,因为它的申请速度非常快,而且不需要手动管理,作用域一结束就会自动释放。

Span<T>Memory<T>这两个类型需要额外的DLL支持。它们可以管理存放在托管堆,非托管堆和栈内存的数据,因为提供了Slice方法分割内存,还提供了各种Copy方法可以在各种类型内存中互相拷贝,比直接用指针来方便一些,Span<T>Memory<T>的区别是,Span<T>是ref类型的,不能用作字段,也不能跨越yield和await使用。一般Span<T>Memory<T>的执行效率比直接使用指针要低。有兴趣可以看一下 KCP的一个实现 。



10.5 总结


本文包含了一些在我们项目实际开发过程当中用到过的和优化方法,内容概括起来有三点:

  1. 使用结构代替类
  2. 缓存对象
  3. 使用非托管堆

虽然在游戏运行过程当中完全没有GC是非常难的,但是至少在一场战斗过程中,最好可以确保不会出现一次GC。此外,对于低端设备,1GB内存以下的设备要尽量保证堆内存大小控制在一定范围内,这也是非常重要的。希望本文可以对大家进行内存优化方面的工作有一定的帮助。

《深度剖析Persistent Manager.Remapper内存占用》
《移动游戏加载性能和内存管理全解析》
《Unity引擎加载模块和内存管理的量化分析及优化方法》

 

 
posted @ 2020-04-10 20:11  sliker  阅读(2491)  评论(0编辑  收藏  举报