114.资源管理与GC进阶优化

114.内存优化-内存管理-资源管理与GC进阶优化


114.1 知识点

静态引用、单例与事件退订

内存泄漏的根本原因

静态字段和单例属于根引用(Root Reference)。只要它们持有对某个对象的引用,该对象就不会被垃圾回收器(GC)回收。

常见问题场景:

  1. 单例持有场景对象:单例上的成员变量引用了场景上的各种对象(如 GameObjectMonoBehaviour)。
  2. 静态容器未清理:各种管理器里的静态字典或列表引用了大量资源或对象。

如果场景切换后不去清空或释放这些引用,它们将继续持有旧场景的对象,导致这些对象无法被释放,从而造成内存泄漏。

C# 事件的泄漏问题

如果使用了事件监听机制(如 SomeManager.OnEvent += HandleMethod;),但在监听者对象(例如一个 MonoBehaviour)被销毁或移除时,没有相应地移除事件监听(SomeManager.OnEvent -= HandleMethod;),那么事件发布者(管理器)会一直持有对该对象的委托引用。这将阻止该对象被 GC 回收,造成内存泄漏。

这与闭包泄漏类似,但即便不使用闭包,事件订阅/退订机制本身若处理不当,也会造成泄漏。

建议:

  1. 强制退订:所有订阅到长生命周期单例或静态事件的监听者,都必须在自身的 OnDisableOnDestroy 方法中进行退订。
  2. 定期清理:定期检查单例中维护的列表、字典等容器,在场景切换等关键节点清理掉已经无效(如 null)的引用。

资源加载与释放

脚本层的 GC 只是内存管理的一部分。在 Unity 中,内存占用的大头往往是纹理、网格、音频、动画等资源。

托管资源加载系统的释放

资源加载方式 释放方法 说明
Resources.Load Resources.UnloadAsset(obj) 适用于临时使用的单个资源。对于全局共用、只加载一次的资源,可常驻内存。
Resources (整体) Resources.UnloadUnusedAssets() 扫描并卸载所有未被引用的资源。操作较重,可能引起卡顿,建议在场景切换、读条界面等时机调用。
Addressables / AB 包 对应的 Release 或 Unload 方法 加载后,在确定不再需要时必须显式释放,否则资源会一直占用内存。

运行时创建的资源

通过代码动态创建的 Texture2DMeshMaterial 等属于原生资源对象,同样占用大量内存。使用完毕后必须调用 Destroy 或对应的释放方法进行销毁,GC 无法自动管理它们。

Texture2D runtimeTex = new Texture2D(1024, 1024);
// ... 使用 runtimeTex ...
Destroy(runtimeTex); // 必须显式销毁

避免隐式实例化材质

访问 renderer.materialrenderer.materials 属性时,Unity 可能会从 sharedMaterial 克隆出一个新的材质实例。频繁访问会导致大量材质实例被创建,造成内存浪费和渲染效率下降。

建议:

  • 公共材质使用 sharedMaterialsharedMaterials
  • 只有当确实需要为某个渲染器单独修改材质属性时,才访问 material 属性,并且应该将获取的实例缓存下来重复使用。

大数组与缓冲区复用

大对象堆(LOH)问题

在 .NET/Mono 中,大小超过约 85KB 的数组(如 new byte[100000])会被分配在大对象堆(Large Object Heap, LOH) 上。LOH 的垃圾回收成本更高,且容易产生内存碎片。高频创建和丢弃大数组会导致:

  • 内存碎片化严重
  • GC 停顿时间变长

优化策略

  1. 复用大数组:像管理对象池一样,建立字节数组 (byte[])、浮点数数组 (float[]) 等缓冲池,避免反复分配。
  2. 使用静态缓存:对于频繁使用的临时缓冲区,可以使用静态字段缓存一个数组实例,使用时用 Array.Clear 等方法重置,而不是每次 new
  3. **利用 ArrayPool<T>**:在支持 .NET Standard 2.0/2.1 或 IL2CPP 的较新 Unity 版本中,可以使用 System.Buffers.ArrayPool<T>.Shared。它是一个全局共享、线程安全、可重复利用的数组池单例。
// 使用 ArrayPool 的示例
byte[] buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(minimumLength);
try
{
    // 使用 buffer...
}
finally
{
    System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
}

Native、非托管资源的释放

在使用 DOTS、Job System、Compute Shader 等技术时,会涉及到 NativeArray<T>ComputeBuffer 等非托管资源。这些资源的内存不在 .NET 的托管堆上,因此 GC 无法管理它们,必须手动释放。

资源类型 释放方法 后果
NativeArray<T>, NativeList<T>, NativeHashMap<,> .Dispose() 不调用 Dispose() 会导致原生内存泄漏。
ComputeBuffer, GraphicsBuffer, RenderTexture .Release(), .Dispose()Destroy() 不释放会导致显存和内存持续上涨。
GCHandle .Free() 不释放会影响 GC 的压缩和内存回收效率。

建议:

  • 将这些需要手动释放的资源封装在实现了 IDisposable 接口的类中,并在 DisposeOnDestroy 方法中统一释放。
  • 对于 Job 中使用的 NativeContainer,使用 using 语句或 try-finally 块来确保异常发生时资源也能被正确释放。

GC 模式与增量 GC 设置

增量垃圾回收(Incremental GC)

Unity 支持增量垃圾回收模式。可以在 Player Settings -> Other Settings -> Configuration -> Use incremental GC 中开启。

  • 工作原理:将传统上单帧内完成的、可能造成长时间卡顿的 GC 操作,拆分成多个小任务分摊到连续多帧中执行。
  • 优点:大幅降低单帧的 GC 峰值卡顿,使游戏体验更平滑稳定。
  • 代价:总的 CPU 消耗可能略有增加。
  • 适用场景:特别适合大型项目、移动端游戏等对帧率稳定性要求高的场景。

手动调用 GC.Collect 的原则

即使开启了增量 GC,手动触发全量垃圾回收时也需谨慎:

  1. 选择时机:只在玩家感知不到或不在意卡顿的时机调用,如场景切换、加载界面、游戏暂停时。
  2. 理解行为:在启用增量 GC 时,手动调用 GC.Collect() 也会按照增量模式执行,但仍可能引起多帧的性能波动。
  3. 避免过度:不要在频繁加载/卸载资源的循环中频繁调用 GC.Collect(),这会导致 CPU 被大量占用。正确的做法是在一段集中的资源释放操作之后,在安全的节点(如加载完成时)触发一次 GC。

诊断与工具

所有内存优化的理论都必须通过实际数据来验证和指导。

核心分析工具

  1. Unity Profiler
    • 查看 GC Alloc(每帧托管堆分配)以定位脚本产生的垃圾。
    • 查看 Total Used MemoryTexture MemoryMesh MemoryAudio Memory 等,了解内存构成。
  2. Memory Profiler Package
    • 核心功能是拍摄内存快照。
    • 通过比较两次快照之间的差异,可以精确找到新增的、未被释放的对象,是定位内存泄漏的利器。

常用检查思路

  1. 场景切换对比:在切换场景前后各拍摄一张内存快照,对比找出哪些对象或资源没有被正确释放。
  2. 运行期内存增长:让游戏运行一段时间(例如 5-10 分钟),然后拍摄快照与初始快照对比,观察内存是否持续非正常上涨,以排查疑似泄漏或资源未释放的问题。

最佳实践:养成使用 Profiler 和数据验证的习惯。不要仅凭经验或猜测断定某个 API 会产生 GC 或导致泄漏,而应通过工具确认,避免过度优化或优化方向错误。

DOTS 系统预告

Unity 的 DOTS(Data-Oriented Technology Stack,数据导向技术栈) 是一套旨在将 CPU 和内存效率压榨到极致的开发范式与工具集合。它专为高性能、大规模实体、可并行处理的游戏逻辑而设计。

三大核心支柱:

  • ECS(Entity Component System):实体组件系统,改变代码组织方式,追求极致的数据局部性。
  • Job System:作业系统,安全、高效地利用多核 CPU。
  • Burst Compiler:Burst 编译器,将 C# 代码编译成高度优化的原生代码。

DOTS 能带来的好处:

  1. 极大提升 CPU 性能和可扩展性
  2. 更高效的内存布局和更少的 GC 压力(大量使用值类型和 NativeContainer)。
  3. 更好的多线程利用率
  4. 推动数据驱动设计成为主要开发方式。

考虑使用 DOTS 的场景:

  1. 海量对象:如 RTS 游戏中的单位、城市模拟中的市民、集群式 AI。
  2. 大量重复计算:如自定义物理模拟、布料、软体、绳索物理表现。
  3. CPU 成为性能瓶颈时:当主线程压力巨大、GC 频繁,而 GPU 仍较为空闲时。

114.2 知识点代码

Lesson114_内存优化_内存管理_资源管理与GC进阶优化.cs

public class Lesson114_内存优化_内存管理_资源管理与GC进阶优化
{
    #region 知识点一 静态引用、单例与事件退订

    //1.静态字段、单例是根引用,只要它们持有引用,对象就不会被GC
    //  常见问题:
    //  1-1.单例上有成员变量引用了场景上的各种对象
    //  1-2.各种管理器里的静态字典里引用了大量资源、对象
    //  场景切换后不去清空或释放它们,则仍然会持有旧场景对象,造成内存泄漏

    //2.C#事件的退订
    //  如果使用了事件监听机制:SomeManager.OnEvent += Handle; 
    //  而没有在对象移除后去移除事件监听:SomeManager.OnEvent -= Handle;
    //  那么管理器会一直持有这个MonoBehaviour的委托引用,会导致对象不会被GC,造成内存泄漏
    //  这和闭包泄漏类似,但即便不用闭包,事件本身也会造成泄漏

    //建议:
    //  1.所有订阅到 长生命周期单例、静态事件 的监听者,都要在OnDisable、OnDestroy中退订。
    //  2.定期检查单例中维护的列表、字典,在场景切换时清理掉已经无效的引用

    #endregion

    #region 知识点二 资源加载与释放

    //脚本层的GC只是内存的一部分,Unity中大头往往是:贴图、网格、音频、动画等资源占用。
    //1.Resources、Addressables、AB包加载的资源,用完要记得释放:
    //  1-1.Resources.Load 的资源:
    //      如果只加载一次、全局共用,可以常驻
    //      如果是临时使用,卸载时可以用:Resources.UnloadAsset(obj);
    //  1-2.Resources.UnloadUnusedAssets();
    //      会扫描没有引用的资源并卸载,比较重,可能造成卡顿,一般放在:切场景、读条时调用。
    //  1-3.Addressables和AB包:
    //      加载后,在不需要使用时记得释放
    //      否则内存一直不降

    //2.运行时创建的贴图、网格、材质等:
    //  new Texture2D 、 new Mesh 、 new Material 这些都是占内存的原生资源对象
    //  用完后要显式 Destroy 销毁掉

    //3.避免隐式实例化材质
    //  renderer.material 每访问一次,都可能从 sharedMaterial 克隆出一个实例
    //  建议:
    //  公共材质用 sharedMaterial 、sharedMaterials
    //  只有确实要对这个对象单独改材质时,才访问 material 并缓存下来

    #endregion

    #region 知识点三 大数组与缓冲区复用

    //1.大数组(>85K bytes)在.NET、Mono中会进入LOH(大对象堆),GC成本更高
    //  比如:new byte[1000000] 每次都会分配一个大块内存
    //  高频new大数组会导致:
    //  内存碎片
    //  GC停顿变长

    //2.优化方式:
    //  2-1.复用大数组:把byte[] 、 float[]做成缓冲池(类似对象池)。
    //  2-2.对频繁使用的临时buffer,使用静态缓存 + Clear,而不是反复new
    //  2-3.NET标准库中有 ArrayPool<T>.Shared(Unity较新版本/IL2CPP可用时可考虑)
    //      它是.NET 里全局共享、线程安全、可重复利用的数组仓库(池子)的单例实例

    #endregion

    #region 知识点四 Native、非托管资源的释放

    //在DOTS、Jobs、ComputeShader等场景中,很多内存不在托管堆上,GC管不着,必须手动释放
    //常见例子:
    //1.NativeArray<T> 、 NativeList<T> 、 NativeHashMap<,>
    //  用完必须调用 Dispose(),否则会产生原生内存泄漏
    //2.ComputeBuffer 、 GraphicsBuffer 、 RenderTexture
    //  用完必须 Release() 、 Destroy(),否则显存、内存持续上涨
    //3.GCHandle.Alloc 固定托管对象
    //  记得 Free(),否则影响GC压缩与内存回收

    //建议:
    // 把这些“需要手动释放”的资源,封装成可管理的对象,在 OnDestroy、Dispose 中统一释放
    // 对于Job、Naitve容器,可以用using或try、finally保证异常时也会Dispose

    #endregion

    #region 知识点五 GC模式与增量GC设置

    //1.Unity支持增量GC(Incremental GC)
    //  可以在
    //  Player Settings -> Other Settings -> Configuration(配置) -> Use incremental GC
    //  中开启
    //  将一次性长时间的GC拆成多帧执行,降低单帧卡顿的峰值
    //  虽然总CPU时间略增,但体验更稳定,适合大型项目、移动端
    //2.脚本里手动GC.Collect的使用原则:
    //  只在玩家不在意卡顿的时候调用(切场景/读条/暂停)
    //  在启用增量GC时,手动Collect也会按照增量模式慢慢做完,仍要注意时机
    //3.避免与内存优化互相打架
    //  比如在频繁加载、卸载资源时,不要频繁手动GC.Collect,否则CPU会被榨干。
    //  正确做法是:一段集中释放资源后,在安全节点触发一次GC,而不是每次小释放都GC

    #endregion

    #region 知识点六 诊断与工具

    //1.所有理论上的内存优化最后都应该回到数据上验证:
    //  Unity Profiler:查看GC Alloc、Total Used Memory、Texture、Mesh、Audio占用
    //  Memory Profiler Package:拍快照比较两次快照之间的差异,定位泄漏对象
    //2.常用检查思路:
    //  切场景前后各拍一张快照,看哪些对象、资源没有被释放
    //  跑一段时间(比如5分钟)后拍快照,看内存是否持续上涨(疑似泄漏或未释放资源)
    //3.把用Profiler确认是否GC、是否泄漏当成规范:
    //  不要只凭经验这个API可能有GC,而是去Profiler里确定,避免过度优化或优化错方向

    #endregion

    #region 知识点七 Dots系统预告

    //Unity中的DOTS(数据导向的技术栈:Data-Oriented Tech Stack)系统
    //是一套专门为了把 CPU、内存效率压榨到极致的开发方式 + 工具集合
    //是用来做高性能、大规模对象、可并行游戏逻辑的
    //它的三大核心是:ECS + Job System + Burst
    //它可以帮助我们
    //1.极大提升CPU性能和可拓展性
    //2.更高效的内存布局和更少 GC 压力
    //3.更好的多线程利用
    //4.让数据驱动设计成为主要开发方式
    //当我们有以下需求时,可以考虑使用它来进行开发
    //1.海量对象
    //  RTS、城市模拟、集群式AI等等
    //2.大量重复计算
    //  自定义物理、布料实现、软体、绳子物理表现等
    //3.CPU是性能瓶颈时
    //  比如主线程压力非常大、GC非常频繁,但是GPU还非常轻松的情况下

    #endregion
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏