浅析GC-垃圾回收

前置知识

CLR:公共语言运行时

CLR(Common Language Runtime):CLR的核心功能包括内存管理,程序集加载,类型安全,异常处理和线程同步,而且还负责对代码实施严格的类型安全检查,保证代码的准确性,这些功能都可以提供给面向CLR的所有语言

CLR并不关心是使用何种语言进行编程开发,只要编译器是面向CLR而进行编译的即可,这个中间的结果,就是IL(Intermediate Language), 最终面向CLR编译得到的结果是:IL语句以及托管数据(元数据)组成的托管模块

在这里插入图片描述

托管资源与非托管资源

托管就是.net framework 负责帮你管理内存及资源释放,不需要自己控制。

对于引用类型,栈上保存着一个地址而已,当栈释放后, 即使对象已经没有用了,但堆上分配的内存还在,只能等GC收集时才能真正释放。但对于值类型,GC会自动释放他们占用的内存,不需要GC来回收释放

  • 托管资源指的是.NET可以自动进行回收的资源,主要是指托管堆上分配的内存资源。托管资源的回收工作是不需要人工干预的,由.NET运行时在适当的时候调用垃圾回收器进行回收。例如程序中分配的对象,作用域内的变量等
  • 非托管资源指的是.NET不知道如何回收的资源,最常见的一类非托管资源如文件、窗口、网络连接、数据库连接等。这类资源,垃圾回收器在清理的时候会调用Object.Finalize()方法。默认情况下,方法是空的,对于非托管对象,需要在此方法中编写回收非托管资源的代码,以便垃圾回收器正确回收资源

托管代码和非托管代码

托管代码:由公共语言运行库环境(而不是直接由操作系统)执行的代码。托管代码应用程序可以获得公共语言运行库服务,例如自动垃圾回收、运行库类型检查和安全支持等。

非托管代码:在公共语言运行库环境的外部,由操作系统直接执行的代码。非托管代码必须提供自己的垃圾回收、类型检查、安全支持等服务。

内存管理机制

Unity内存管理

Unity自动内存管理机制

  1. unity内部有两个内存管理池:堆内存和栈内存。栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。
  2. unity中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在堆栈内存上,要么处于堆内存上。
  3. 只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。
  4. 一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中被再次使用,这样的操作就是内存回收。处于栈上的内存回收极其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使用状态。
  5. 垃圾回收主要是指堆上的内存分配和回收,unity中会定时对堆内存进行GC操作。

利用profiler window 来检测堆内存分配

在CPU usage分析窗口中,我们可以检测任何一帧cpu的内存分配情况。其中一个选项是GC Alloc,通过分析其来定位是什么函数造成大量的堆内存分配操作

如何垃圾回收

当满足以下条件之一时CLR将发生垃圾回收:

  1. 系统具有低的物理内存。
  2. 由托管堆上已分配的对象使用的内存超出了可接受的阈值(涉及到代的概念)。随着进程的运行,此阈值会不断地进行调整。
  3. 强制调用 GC.Collect 方法。
  4. CLR正在卸载应用程序域(AppDomain
  5. CLR正在关闭。

具体流程:

GC是一种分代式垃圾回收器,使用引用计数算法,该算法只关心引用类型变量

根对象:相当于是当前存活对象的合集,GC去搜索能被该集合直接或者是间接指向的对象

  • GC的准备阶段:
    在这个阶段,CLR会暂停进程中的所有线程,这是为了防止线程在CLR检查根期间访问堆。
  • GC的标记阶段:
    当GC开始运行时,它会假设托管堆上的所有对象都是垃圾。然后GC开始遍历根对象并让所有与之有引用关系的对象构成一个对象图,假如一个根对象指向null,GC会忽略这个根并继续检查下个根。

下图是回收之前的托管堆模型,根直接引用了对象A,C,D,F。标记对象D时,垃圾回收器发现这个对象含有一个引用对象H的字段,所以H也会被标记,整个过程一直持续到所有根检查完毕。NextObjPtr对象始终保持指向最后一个对象放入托管堆的地址

如果GC发现一个对象已经在图中就会换一个路径继续遍历。这样做有两个目的:一是提高性能,二是避免无限循环。

在这里插入图片描述

  • GC的碎片整理阶段
    托管堆上所有不在这个图上的对象就是要做回收的垃圾对象了。同时,CLR会使堆中非垃圾对象覆盖占用连续的内存空间(还伴随着对根返回新的内存地址的行为),这样一方面恢复了引用的“局部化”,也解决了本机堆的空间碎片化问题。

在这里插入图片描述

  • GC恢复阶段
    恢复暂停的所有线程,使这些线程可以继续访问对象。

值得注意的是,将引用赋值为null并不意味着强制GC立即启动并把对象从堆上移除,唯一完成的事情是显式取消了引用和之前 引用所指向对象之间的连接

代数概念(Generation)

代的分代式垃圾回收器,而代就是一种为了降低GC对性能影响的机制,垃圾回收有两个基本原理:

  1. 对象越新,生命周期越短,反之也成立
  2. 回收托管堆的一部分,速度快于回收整个堆

托管堆中的每个对象都可以被分为0、1、2三个代,表示他们经历了几次GC仍没有被回收

  • 低一代的GC触发,移动到高一代后,未必会触发高一代的GC,只有高一代的内存不足时才会触发高一代的GC
  • 不同代的自动GC频率是可以设置的

在这里插入图片描述

第 0 代满的时候触发GC,GC后第 0 代对象不包括任何对象,并且第一代对象也已经被压缩整理到连续的地址空间中[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z0mNfsCF-1668434575983)(浅析GC-垃圾回收/image-20221114200703105.png)]

超过第 0 代预算时再次触发GC,假如第 1 代占用内存远少于预算,GC将只检查第 0 代对象,即便此时原来的第 1 代对象中也出现了垃圾对象

在这里插入图片描述

非托管对象资源回收

大多数类型只要分配了内存就能够正常工作,但有的类型除了内存还需要本机资源,比如说常用的FileStream,便需要打开一个文件(本机资源)并保存文件句柄,或者是数据库连接信息,那么我们就需要显式释放非托管对象,因为GC仅能跟踪托管堆上的内存资源

可终结对象(Finalize)

Finalize方法(C#中是析构函数),允许对象在判定为垃圾之后,在对象内存在回收之前执行一些代码。当一个对象被判定不可达后,对象将终结它自己,并释放包装着的本机资源,之后,GC再从托管堆中回收对象。

Finalize虽然看似手动清除非托管资源,其实还是由垃圾回收器维护,它的最大作用是确保非托管资源一定被释放

Finalize方法的执行时间无法控制,所以原则上并不提倡使用终结器机制

可处置对象(IDisposable)

为了更快更具操作性进行释放,而非让垃圾回收器(即不可预知)来进行,可以使用Dispose,即实现IDispose接口

结构和类类型都可以实现IDispose(与重写Finalize不同,Finalize只适用于类类型),因为不是垃圾回收器来调用Dispose方法,而是对象本身释放非托管资源,这也意味着如果没有调用Dispose()方法,非托管资源永远得不到释放

同样的,Dispose方法也不会将托管对象从托管堆中删除,我们要记住在正常情况下,只有在GC之后,托管堆中的内存才能得以释放。习惯用法是将Dispose方法放入try finally的finally块中,以确保代码的顺利执行

如何减少GC副作用

主要有三种方法降低影响:

  • 减少GC的运行次数;
  • 减少单次GC的运行时间;
  • 将GC的运行时间延迟,避免在关键时候触发,比如可以在场景加载的时候调用GC

分别对应着三个策略:

  • 对游戏进行重构,减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数从而提高GC的运行效率。

  • 降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说更少的事件触发GC操作,同时也降低堆内存的碎片化。

  • 我们可以试着按照可预测的顺序执行。当然这样操作的难度极大

缓存变量

如果在代码中反复调用某些造成堆内存分配的函数但是其返回结果并没有使用,这就会造成不必要的内存垃圾,我们可以缓存这些变量来重复利用

// 下面的代码每次调用的时候就会新分配一个数组,造成堆内存分配
void OnTriggerEnter(Collider other)
{
     Renderer[] allRenderers = FindObjectsOfType<Renderer>();
     ExampleFunction(allRenderers);      
}
// --------修改为---------
private Renderer[] allRenderers;
void Start()
{
   allRenderers = FindObjectsOfType<Renderer>();
}
 
void OnTriggerEnter(Collider other)
{
    ExampleFunction(allRenderers);
}

如果是链表等数据结构,记得需要先调用 Clear()函数

同时也要避免在 Update等函数中反复进行堆内存分配

对象池方法

如果游戏有大量的对象需要产生和销毁依然会造成GC。对象池技术可以通过重复使用对象来降低堆内存的分配和回收频率。对象池在游戏中广泛的使用,特别是在游戏中需要频繁的创建和销毁相同的游戏对象的时候,例如枪的子弹

更进一步,可以不将整个游戏物体 SetActive(false)而是只取消掉他的关键组件

字符串调用

在c#中,字符串是引用类型变量而不是值类型变量。

c#中的字符串在创建后是不可变更的。每次在对字符串进行操作的时候(例如运“加”操作),会新建一个字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会造成内存垃圾

在Text文本组件中,我们可以分离常量字符串和需要修改的字符串(尤其是表示UI数字的Text),去除 + 操作符,实施创建的字符串还可以使用 StringBuilder

Unity函数调用

迭代器

// 下面的代码中对于每个迭代器都会产生一个新的数组
void ExampleFunction()
{
    for(int i=0; i < myMesh.normals.Length;i++)
    {
        Vector3 normal = myMesh.normals[i];
    }
}
// ----修改为---------
void ExampleFunction()
{
    Vector3[] meshNormals = myMesh.normals;
    for(int i=0; i < meshNormals.Length;i++)
    {
        Vector3 normal = meshNormals[i];
    }
}

协程

调用 StartCoroutine()会产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该限制该函数的调用

yield在协程中不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,比如应该将 yield return 0 改为 yield return null,否则会引发装箱

也尽量少返回一个新创建的变量

while(!isComplete)
{
    yield return new WaitForSeconds(1f);
}

// 我们可以采用缓存来避免这样的内存垃圾产生:

WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
    yield return delay;
}

其他

  • LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试
  • struct是值类型的变量,但是如果struct中包含有引用类型的变量,那么GC就必须检测整个struct。

猜你喜欢

转载自blog.csdn.net/jkkk_/article/details/127856675