关于GC的原理和 Unity 中如何针对 GC 进行优化的建议

例牌美女

1. 应用程序内存结构

应用程序内存空间通常划分为五个部分

1.1 静态/全局存储区

存放全局和静态变量,静态分配的,在程序执行的最开是分配,后面不会再增长

1.2 常量存储区

存储程序中的常量

1.3 代码段

存放程序执行代码的内存区域,在程序运行之前就已经确定了,通常是只读的,当多个进程运行同样的程序时,可以使用同一个代码段

1.4 栈

栈是一块连续的内存区域,栈的容量由系统规定,栈底地址在程序初始化时就确定了。栈通常用来存储局部变量、函数的参数和返回值

  • 快速存取
    栈最大的特点是快速存取,这是因为操作系统本身就支持栈这种数据结构,有专门的寄存器指向栈底,同时有专门的汇编指令进行入栈和出栈操作
  • 内存分配特点
    定义局部变量时进行分配,或函数参数/返回值自动分配和入栈。
    在栈中分配内存是连续的,不会产生碎片,且栈的分配是从高地址往低地址方向增长
  • 内存释放
    当变量超出作用域时,系统自动释放
1.5 堆

堆主要用来存放动态分配的对象,堆的大小由系统内存/虚拟内存的上限决定

  • 堆的分配特点
    通常使用 newmalloc来动态分配。堆的分配效率比较低。系统通常使用一个链表记录堆中所有空闲区域的首地址指针,当进行内存分配时,需要遍历链表,来选取一个大小足够容纳的区域进行分配,然后修改链表中的指针值;如果没有找到足够的空间,会调用系统接口来增加,因此堆的分配效率比较低,且容易生成内存碎片。另外,堆的分配是从低地址往高地址增长
  • 内存释放
    需要调用 deletefree主动释放

2. 内存管理方式

主流的内存管理方式包括三种:手动管理、引用计数和垃圾回收(Garbege Collect,GC)

2.1 手动管理

  • 管理方式
    手动调用 mallocnew 进行分配,手动调用 freedelete 进行释放回收
  • 优点
    速度快,无额外开销
  • 缺点
    较难管理,必须明确跟踪对象使用情况,容易产生分配后未回收导致内存泄漏、错误回收导致野指针、空指针等问题

2.2 引用计数

  • 管理方式
    对象使用时计数器 +1,使用完毕计数器 -1,当计数器为 0 时进行销毁
  • 优点
    半自动管理,切速度较快
  • 缺点
    存在循环引用的情况,会导致内存泄漏

2.3 垃圾回收(GC)

  • 管理方式
    自动进行垃圾回收
  • 优点
    整个过程是全自动的,用户几乎不需要参与内存的管理,并且不存在循环引用的问题
  • 缺点
    在进行垃圾回收时需要停止所有线程 Stop the World

3. .Net/Java 中 GC 的原理

3.1 堆内存划分区域

堆内存区域划分

将堆内存划分为年轻代、老年代和永久代

  • 年轻代
    年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代又被划分为 Eden 区、Survivor 区,Survivor 区分为 S1 和 S2 区,Eden 区和 S1、S2 区的大小比通常为 8:1:1,年轻代内存的代销和 Eden 区和 Survivor 区的大小比例都可以通过 JVM 参数来进行设置
  • 老年代
    老年代主要存放系统认为生命周期比较长的对象,区域大小相对会比较大,垃圾回收也相对没有那么频繁
  • 永久代
    持久代主要存放类定义、字节码和常量等很少会变更的信息

3.2 内存分配过程

当需要为一个对象分配内存时,过程大概是

  • 一般对象往 Eden 区分配,查看 Eden 区中的空间是否足够容纳对象,如果足够则在 Eden 区进行分配
  • Eden 区容纳不下该对象时,触发 MinorGC,结束后重新分配
  • 大对象直接往老年代分配,若容纳不下将触发 FullGC,结束后重新分配

3.3 GC 触发时机

以下几种情况会触发 GC:

  • 在 Eden 区分配时空间不足,触发 MinorGC
  • 大对象分配,老年代空间不足,触发 FullGC
  • 主动调用 GC.Collect 时,触发 FullGC

3.4 GC 过程和算法

3.4.1 年轻代基于复制的 GC 算法

执行在年轻代的 GC 也称之为 MinorGC,过程如下

  • 遍历 Eden 区和 S0 区,计算每个对象是否存活,存活对象全部复制到 S1 区,然后清空 Eden 和 S0 区
  • 此过程中若 S1 区空间不够存放对象,对象直接进入老年区
  • Eden 区和 S0 区的对象,每复制一次年龄 + 1,年龄超过某个阈值(默认为15,可以通过 JVM 参数设置),进入老年代
  • 清空 Eden 和 S0 区后,S0 区和 S1 区互换,下一次 Minor GC 触发时 S0 区用来接收 Eden 区和 S1 区的存活对象
3.4.2 老年代标记-清除的 GC 算法

执行于老年代的 GC,也成为 Major GC,其中一种算法是标记-清除算法,过程如下

  • 第一趟遍历对象列表,标记所有未存活对象
  • 第二趟遍历对象列表,清除所有未存活对象
    标记-清除算法存在问题:
    清除的对象很可能不在连续空间,容易产生内存碎片,随着时间推移,连续的内存区域越来越少,一次稍微大一点的分配就可能触发 GC,导致 GC 会越来越频繁
3.4.3 老年代标记-整理的 GC 算法

另外一种算法改进了标记-清除算法的问题,称为标记-整理算法,过程如下

  • 第一趟遍历对象,标记所有未存活对象
  • 第二趟遍历对象,进行整理,将所有存活对象复制到连续区域:使用 memmove 移动内存空间,同时修改引用该对象的指针值
    标记-整理的问题是效率稍微低一些
3.4.3 GC时如何判断对象是否存活
  • 可达性算法:如果对象 A 到对象 B 存在引用链路,说明 A 为 B 的可达对象
  • 判断对象 A 是否存活:遍历程序的 根对象列表,若 A 为任何根对象的可达对象,则A为存活对象
3.4.4 哪些对象是根对象

可以作为根对象来进行可达性判定的对象包括:

  • 栈中的局部变量
  • 类静态变量
  • 全局变量和常量
3.4.5 Minor GC,Major GC 和 FullGC

Minor GC 是发生在年轻代中的 GC,触发较为频繁,Major GC 是发生在老年代中的 GC,相对不频繁,触发 FullGC 时会先执行 Minor GC,然后执行 Major GC

4. Unity 中的 GC

4.1 Unity 中GC的特性

  • Stop the World
    Unity 不支持多线程 GC,要停止所有线程,GC才能继续执行。即便 Unity 2019 引入了增量式GC,将 GC 操作分散到不同帧当中,仍然是需要停止所有线程的
  • 不分代
    Unity 中的托管堆内存未分代,只要触发 GC,就是 FullGC
  • 不整理
    Unity 中 GC 算法是基于标记-清除算法,不会和并对象空间,容易造成内存碎片,且 GC 频率会越来越高

4.2 Unity 中关于 GC 优化的建议

4.2.1 减少对象的大小

合理安排类或结构体的字段声明顺序,以优化其对象的内存布局进而减少对象大小,结构体可以使用 StructLayout 属性

关于结构体,结构体本身是值类型。若结构体中不包含引用类型时,针对结构体的 new 操作不会造成 GCAlloc,但若结构体中包含引用类型字段,如 string 或数组等,那么在对结构体执行 new 操作时会产生 GC Alloc

4.2.2 降低内存分配的频次

也就是尽量减少 GCAlloc

  • 减少引用类临时对象的分配,传递结构类型的参数时,如果对象尺寸超过 IntPtr.Size 时,采用引用传递方式,参数加关键字 ref,类类型的对象本身已经是引用传值了,不会生成临时对象
  • 使用泛型优化装箱,例如 void Func(object o) 方法在传值类型参数时会进行装箱,使用 void Func(T o) 则不会产生装箱,但泛型在 IL2cpp 时会生成多中类型对应的代码
  • 可变参数的方法,先定义常用参数个数的方法,再定义可变参数方法,确保绝大多数调用是固定个数的方法。如 string.Format方法是将1个、2个和3个参数的方法单独提出,另外再实现一个可变参数的方法
  • 缓存某些 Get 类方法或属性的结果,例如不要使用 GameObject.nameGameObject.tag,但GameObject.CompareTag() 方法不会产生 GCAlloc
  • 尽量减少装箱和拆箱
  • 不要在 Update 等频次较高的方法中分配堆内存
  • 使用对象池
  • 事先申请足量的容器尺寸,避免申请尺寸不足时添加元素造成的复制和重新申请操作
  • 字符串本身是引用类型,字符串连接时会申请新的空间,所以尽量避免字符串拼接
  • 协程 yield return 0 应该使用 yield return null 来替代,避免装箱
  • 协程 yield return new WaitForSeconds 应该先将 new WaitForSeconds 缓存下来

5. 查看 Android 应用的内存情况

4.2.3 在适当的时机主动调用 GC.Collect

例如在场景切换显示加载界面时调用,用户无感知

4.2.4 关于调试日志的字符串参数

正式版本中使用 unityLogger.logEnabled = false 仅仅只是不打日志,但字符串已经被分配内存,GC Alloc 还是产生了。解决方法是使用 Conditional 特性来处理日志输出,在正式版本中不要生成打印相关的代码,也不会有字符串的生成,减少了 GCAlloc

你可能感兴趣的:(关于GC的原理和 Unity 中如何针对 GC 进行优化的建议)