114.内存优化-内存管理-资源管理与GC进阶优化
114.1 知识点
静态引用、单例与事件退订
内存泄漏的根本原因
静态字段和单例属于根引用(Root Reference)。只要它们持有对某个对象的引用,该对象就不会被垃圾回收器(GC)回收。
常见问题场景:
- 单例持有场景对象:单例上的成员变量引用了场景上的各种对象(如
GameObject、MonoBehaviour)。 - 静态容器未清理:各种管理器里的静态字典或列表引用了大量资源或对象。
如果场景切换后不去清空或释放这些引用,它们将继续持有旧场景的对象,导致这些对象无法被释放,从而造成内存泄漏。
C# 事件的泄漏问题
如果使用了事件监听机制(如 SomeManager.OnEvent += HandleMethod;),但在监听者对象(例如一个 MonoBehaviour)被销毁或移除时,没有相应地移除事件监听(SomeManager.OnEvent -= HandleMethod;),那么事件发布者(管理器)会一直持有对该对象的委托引用。这将阻止该对象被 GC 回收,造成内存泄漏。
这与闭包泄漏类似,但即便不使用闭包,事件订阅/退订机制本身若处理不当,也会造成泄漏。
建议:
- 强制退订:所有订阅到长生命周期单例或静态事件的监听者,都必须在自身的
OnDisable或OnDestroy方法中进行退订。 - 定期清理:定期检查单例中维护的列表、字典等容器,在场景切换等关键节点清理掉已经无效(如
null)的引用。
资源加载与释放
脚本层的 GC 只是内存管理的一部分。在 Unity 中,内存占用的大头往往是纹理、网格、音频、动画等资源。
托管资源加载系统的释放
| 资源加载方式 | 释放方法 | 说明 |
|---|---|---|
Resources.Load |
Resources.UnloadAsset(obj) |
适用于临时使用的单个资源。对于全局共用、只加载一次的资源,可常驻内存。 |
Resources (整体) |
Resources.UnloadUnusedAssets() |
扫描并卸载所有未被引用的资源。操作较重,可能引起卡顿,建议在场景切换、读条界面等时机调用。 |
| Addressables / AB 包 | 对应的 Release 或 Unload 方法 | 加载后,在确定不再需要时必须显式释放,否则资源会一直占用内存。 |
运行时创建的资源
通过代码动态创建的 Texture2D、Mesh、Material 等属于原生资源对象,同样占用大量内存。使用完毕后必须调用 Destroy 或对应的释放方法进行销毁,GC 无法自动管理它们。
Texture2D runtimeTex = new Texture2D(1024, 1024);
// ... 使用 runtimeTex ...
Destroy(runtimeTex); // 必须显式销毁
避免隐式实例化材质
访问 renderer.material 或 renderer.materials 属性时,Unity 可能会从 sharedMaterial 克隆出一个新的材质实例。频繁访问会导致大量材质实例被创建,造成内存浪费和渲染效率下降。
建议:
- 公共材质使用
sharedMaterial或sharedMaterials。 - 只有当确实需要为某个渲染器单独修改材质属性时,才访问
material属性,并且应该将获取的实例缓存下来重复使用。
大数组与缓冲区复用
大对象堆(LOH)问题
在 .NET/Mono 中,大小超过约 85KB 的数组(如 new byte[100000])会被分配在大对象堆(Large Object Heap, LOH) 上。LOH 的垃圾回收成本更高,且容易产生内存碎片。高频创建和丢弃大数组会导致:
- 内存碎片化严重
- GC 停顿时间变长
优化策略
- 复用大数组:像管理对象池一样,建立字节数组 (
byte[])、浮点数数组 (float[]) 等缓冲池,避免反复分配。 - 使用静态缓存:对于频繁使用的临时缓冲区,可以使用静态字段缓存一个数组实例,使用时用
Array.Clear等方法重置,而不是每次new。 - **利用
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接口的类中,并在Dispose或OnDestroy方法中统一释放。 - 对于 Job 中使用的
NativeContainer,使用using语句或try-finally块来确保异常发生时资源也能被正确释放。
GC 模式与增量 GC 设置
增量垃圾回收(Incremental GC)
Unity 支持增量垃圾回收模式。可以在 Player Settings -> Other Settings -> Configuration -> Use incremental GC 中开启。
- 工作原理:将传统上单帧内完成的、可能造成长时间卡顿的 GC 操作,拆分成多个小任务分摊到连续多帧中执行。
- 优点:大幅降低单帧的 GC 峰值卡顿,使游戏体验更平滑稳定。
- 代价:总的 CPU 消耗可能略有增加。
- 适用场景:特别适合大型项目、移动端游戏等对帧率稳定性要求高的场景。
手动调用 GC.Collect 的原则
即使开启了增量 GC,手动触发全量垃圾回收时也需谨慎:
- 选择时机:只在玩家感知不到或不在意卡顿的时机调用,如场景切换、加载界面、游戏暂停时。
- 理解行为:在启用增量 GC 时,手动调用
GC.Collect()也会按照增量模式执行,但仍可能引起多帧的性能波动。 - 避免过度:不要在频繁加载/卸载资源的循环中频繁调用
GC.Collect(),这会导致 CPU 被大量占用。正确的做法是在一段集中的资源释放操作之后,在安全的节点(如加载完成时)触发一次 GC。
诊断与工具
所有内存优化的理论都必须通过实际数据来验证和指导。
核心分析工具
- Unity Profiler:
- 查看 GC Alloc(每帧托管堆分配)以定位脚本产生的垃圾。
- 查看 Total Used Memory、Texture Memory、Mesh Memory、Audio Memory 等,了解内存构成。
- Memory Profiler Package:
- 核心功能是拍摄内存快照。
- 通过比较两次快照之间的差异,可以精确找到新增的、未被释放的对象,是定位内存泄漏的利器。
常用检查思路
- 场景切换对比:在切换场景前后各拍摄一张内存快照,对比找出哪些对象或资源没有被正确释放。
- 运行期内存增长:让游戏运行一段时间(例如 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 能带来的好处:
- 极大提升 CPU 性能和可扩展性。
- 更高效的内存布局和更少的 GC 压力(大量使用值类型和
NativeContainer)。 - 更好的多线程利用率。
- 推动数据驱动设计成为主要开发方式。
考虑使用 DOTS 的场景:
- 海量对象:如 RTS 游戏中的单位、城市模拟中的市民、集群式 AI。
- 大量重复计算:如自定义物理模拟、布料、软体、绳索物理表现。
- 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