【资源优化】场景切换导致内存泄露

本文探讨了游戏中频繁场景切换导致的内存问题,特别是AssetBundle的加载和内存驻留。通过分析发现依赖于共享资源的问题,使用IDisposable接口确保资源释放,同时强调了处理静态变量和GC滞后性的重要性,以优化内存管理和防止内存泄漏。
摘要由CSDN通过智能技术生成

1.问题描述

image.png
玩家在传送点来回切换世界场景时,随着切换次数频率增加,加载进度条会有轻微延长表现。上层估计在加载新场景前后,内存有驻留资源堆积现象。这些Bundle资源本应该被卸载掉,但实际上并没有。
测试工具截图

image.png
可以看到
Cached和Bundle 数量都在涨
其中shared_shader.data 每切场景(迭代一轮)它的值就在稳定上升。

2.问题分析

因为不同的bundle类型可能都会依赖于shared_shader.data 【注意到它是一个被共享使用的shader资源】
所以在AssetBundleCache下找到主包的依赖WebGL.mainifest文件
image.png

image.png
可以看到哪些文件一级依赖于shared_shader.data
然后我做了统计,发现主要是三类文件,effect/mod 还有scene文件
image.png

场景切换

因为是MMO开放世界类型,地图比较大,里面涉及太多的model和贴图纹理,如果直接用一个scene去加载,那么切换场景前后的耗时是不能被接受的,于是工程采用场景分块异步加载,单屏模型采用多级Lod 和Mipmap 优化掉远景。表现上是这样的
image.png
经过逐级调试,我找到了切换场景的资源加载支持的源文件AssetGroupLoader.cs
AssetGroupLoader 继承了IDisposable 接口

IDisposable接口

.NET中的资源分为托管资源(由CLR分配的资源)和非托管资源(不受CLR管理分配和释放掉的资源)
因为托管资源一级被.NET的垃圾回收机制完成了,而非托管资源需要我们显式地去释放。
一个标准的释放非托管资源的类需要去实现Disposable的接口

public class MyClass:IDisposable
{
    /// <summary>执行与释放或重置非托管资源关联的应用程序定义的任务。</summary>
    public void Dispose()
    {
    }
}

//在实例化的时候using这个类
using(var mc = new MyClass())
{
    
}

所以我们需要做以下几件事

  1. 实现Dispose方法;
  2. 提取一个受保护的Dispose虚方法,在该方法中实现具体的释放资源的逻辑;
  3. 添加析构函数;
  4. 添加一个私有的bool类型的字段,作为释放资源的标记

终结器(C++中的析构函数)

~AssetGroupLoader()
{
        Clear();
}

private void Clear()
{
        _disposed = true;
        foreach (var pair in _assetDict)
        {
            if (pair.Value != null && pair.Value.assetRef != null)
            {
                pair.Value.assetRef.Release();
            }
        }
        _assetDict.Clear();
        StopTimeOutClean();
}

这个析构方法更规范的说法叫做终结器,它的意义在于,如果我们忘记了显式调用Dispose方法,垃圾回收器在扫描内存的时候,会作为释放资源的一种补救措施。

两次垃圾回收

在new新对象的时候,CLR会为对象创建一块内存空间。一旦对象不再被引用,就会被垃圾回收器回收掉,对于没有实现IDisposable接口来说,垃圾回收时将直接回收掉这片空间,而对于实现了IDisposable接口来说,由于析构函数的存在,在创建对象之初,CLR会将该对象的一个指针放到终结器列表中,在GC回收内存之前,会首先将终结器列表的指针放到freachable队列中,同时CLR还会分配专门的内存空间来读取freachable队列,并且调用对象的终结器,只有在这个时候,对象才会被真正标识为垃圾,在第二次垃圾回收的时候,回收掉这个对象所占用的空间。
那么,实现了IDisposable接口的对象在回收时要经过两次GC才能被真正的释放掉,因为GC要先安排CLR调用终结器,基于这个特点,如果我们显式调用了Dispose方法,那么GC就不会再进行第二次垃圾回收了,当然,如果忘记了Dispose,也避免了忘记调用Dispose方法造成的内存泄漏。

静态字段和非静态字段的回收次序

在CLR托管的应用程序中,有一个“根”的概念,类型的静态字段、方法参数以及局部变量都可以被作为“根”存在(值类型不能作为“根”,只有引用类型才能作为“根”)。

var mc1 = new MyClass() { Name = "mc1" };
var mc2 = new MyClass() { Name = "mc2" };

上面的代码中,mc1和mc2在代码运行过程中分别会在内存中创建一个“根”。在垃圾回收的过程中,GC会沿着线程栈扫描“根”(栈的特点先进后出,也就是mc2在mc1之后进栈,但mc2比mc1先出栈),检查完毕后还会检查所有引用类型的静态字段的集合,当检查到方法内存在“根”时,如果发现没有任何一个地方引用这个局部变量的时候,不管你是否已经显式的置为null这都意味着“根”已经被停止,然后GC就会发现该根的引用为空,就会被标记为可被释放,这也代表着mc1和mc2的内存空间可以被释放.

public class MyClass
{
    public string Name { get; set; }
    public static MyClass2 MyClass2 { get; set; } = new MyClass2();
    ~MyClass()
    {
        MyClass2 = null;
        MessageBox.Show(Name + "被销毁了");
    }
}
public class MyClass2
{
    ~MyClass2()
    {
        MessageBox.Show("MyClass2被释放");
    }
}

但是,在另一种情况下,就完全有必要将对象赋值为null,那就是静态字段或属性,但这斌不意味着将对象赋值为null就是将它的静态字段赋值为null。上面的代码运行我们会发现,当mc被回收时,它的静态属性并没有被GC回收,而我们将MyClass终结器中的MyClass2=null的注释取消,再运行,当我们两次点击按钮7的时候,属性MyClass2才被真正的释放,因为第一次GC的时候只是在终结器里面将MyClass属性置为null,在第二次GC的时候才当作垃圾回收了,之所以静态变量不被释放(即使赋值为null也不会被编译器优化),是因为类型的静态字段一旦被创建,就被作为“根”存在,基本上不参与GC,所以GC始终不会认为它是个垃圾,而非静态字段则不会有这样的问题。

3.问题解决

处理静态属性

那么问题就来了。场景中的临时缓存部分是静态字段保存,比如说1002位置格的NPC信息,副本中的进度等等
我发现原来工程中在不持有该变量的接口实现过程中都没有
image.png
只是做了refCount 计数器的加减 和 缓存池列表的增减。但static 变量始终没有置空。
那么试想,如果这个静态变量是作为全局的缓存变量,一旦项目运行,那么它所占的内存空间只增不减,最终顶爆机器内存,所以,对于静态变量的使用应该更谨慎一些。
除此之外,我发现进入副本战斗之后,bundle和Assets的指标剧增,于是我对特效和模型的bundle资源在加载过程中统一做了额外的缓存,在持有计数器为0的时候,显示调用Dispose() 过程去释放。

处理GC的滞后性

还有一个原因是因为GC“代”的回收策略会有滞后性,
自动垃圾回收器只会在如下情况触发:

  1. 系统物理内存较低
  2. 托管堆分配的对象超出第i代的空间,会触发一次GC回收掉标记为垃圾的对象,然后旧对象进入第i+1代
  3. 强制调用GC.Collect()

所以,为了确保对象回收的及时性,我检查了所有非托管资源调用地方。
使用using语句包裹代码区,保证在执行结束后及时释放。

文章参考:
https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/unsafe-code
深入理解C#中的IDisposable接口https://zhuanlan.zhihu.com/p/244894004


  • 21
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值