在 Unity 开发中,内存管理从来不是可有可无的"高级话题"。一次不经意的 new 操作、一个频繁调用的 transform.position,都可能成为帧率波动的导火索。Unity 提供了多层内存管理机制,从自动化的 GC 到手动控制的 Native 容器,再到引擎底层的 C++ 分配器。更关键的是,托管内存与本机内存之间横亘着一座"桥"------跨桥操作的代价远超多数开发者的认知。
本文将系统梳理 Unity 的内存体系,并重点揭示"跨桥"这一隐形性能杀手,帮助你在实际项目中避开陷阱、写出高性能代码。
一、Unity 的三层内存架构

Unity 将内存划分为三个逻辑层级,每个层级服务于不同的目的:
| 层级 | 描述 | 管理方式 | 典型使用者 |
|---|---|---|---|
| 托管内存(Managed) | C# 脚本运行环境(Mono/IL2CPP)使用的内存,受 GC 控制 | 自动(GC) | 普通 C# 对象、List、字符串等 |
| C# 非托管内存(Unmanaged) | C# 中绕过 GC、直接操作本机内存的 API | 手动(Dispose) | NativeArray、NativeList、Job System |
| 本机内存(Native) | Unity 引擎 C++ 核心使用的内存 | 引擎内部管理 | 场景数据、资源、图形缓冲区、子系统 |
三层之间并非完全隔离------托管内存和本机内存之间存在频繁的数据交换,而这座"桥"正是性能优化的关键战场。
二、托管内存:便利与代价
2.1 托管堆与垃圾收集器
托管内存由 Mono 或 IL2CPP 虚拟机管理,所有引用类型对象(类实例、数组、委托等)都分配在托管堆上。当堆空间不足时,垃圾收集器(GC)会自动回收不再使用的对象。
GC 的工作流程:
- 标记(Mark):从所有"根引用"(静态字段、局部变量等)出发,标记所有可达对象。
- 清扫(Sweep):回收未标记对象的内存,将其加入空闲列表。
Unity 的 GC 是非压缩的,即不会移动存活对象来合并空闲空间,因此容易产生内存碎片。当连续空间不足以容纳新对象时,GC 会再次触发,甚至扩展堆------而扩展后的堆通常不会主动归还操作系统,导致内存占用虚高。
2.2 值类型与引用类型
- 值类型(struct, int, float):通常分配在栈上,方法结束即释放,开销极小。
- 引用类型(class, string, array):分配在托管堆,需 GC 管理。
注意装箱(Boxing):将值类型转为引用类型(如 object obj = 5;)会在堆上分配,是 GC Alloc 的常见来源。
2.3 增量垃圾收集(Incremental GC)
Unity 默认启用增量 GC(Project Settings > Player > Use Incremental GC)。它将一次完整的标记阶段拆分为多个小任务,分散到若干帧执行,从而大幅降低单帧的卡顿尖峰。
代价:
- 写屏障(Write Barrier)带来轻微 CPU 开销;
- 若对象引用频繁变动,可能导致标记阶段反复扫描,极端时回退到完整 GC。
启用增量 GC 后,Unity 会利用 VSync 或 Application.targetFrameRate 设定的每帧剩余时间执行增量工作,几乎不影响主逻辑。
三、C# 非托管内存:摆脱 GC 束缚
3.1 为什么需要它?
托管内存虽然方便,但 GC 的不可预测性对实时游戏是硬伤。C# 非托管内存层通过 Unity.Collections 命名空间提供了 NativeArray、NativeList 等容器,让你手动控制内存生命周期,完全不触发 GC。
这些容器通常与 C# Job System 和 Burst Compiler 搭配,实现高性能、多线程的数据处理。
3.2 分配器(Allocator)类型

使用 Native 容器时,必须指定分配器类型,以声明内存的预期生命周期:
| 分配器 | 生命周期 | 特点 |
|---|---|---|
| Temp | 单帧内,线程本地 | 栈式分配,每帧自动重置;容量有限(主线程 16MB,工作线程 256KB),超出则回退 |
| TempJob | 最多 4 帧 | 线性分配,适合作业间传递数据;超时未释放则编辑器警告 |
| Persistent | 手动释放,无限制 | 分配较慢,无回退,必须显式 Dispose |
⚠️ 使用 Persistent 时,务必在不再需要时调用 Dispose(),否则会造成内存泄漏(DisposeSentinel
会在句柄被 GC 时警告,但不会自动释放)。
四、本机内存:引擎的"大后方"
本机内存是 Unity C++ 引擎运行的核心内存池,存储着场景对象、资源数据、图形 API 缓冲区、音频数据等。开发者一般无法直接访问,但其分配行为会通过性能分析器可见。
4.1 Unity 的多种分配器
Unity 内部使用五种本机分配器,分别针对不同用途:
| 分配器 | 算法 | 主要用途 |
|---|---|---|
| 动态堆 | TLSF(Two-Level Segregated Fit) | 主分配、Gfx、类型树、文件缓存 |
| 存储桶 | 固定大小无锁 | 小型共享分配 |
| 双线程 | 按大小和线程 ID 重定向 | 通用分配 |
| TLS 堆栈 | LIFO 栈 | 临时分配(对应 C# 的 Temp) |
| 线程安全线性 | 循环 FIFO | 作业间数据传递(对应 TempJob) |
Unity 通过内存标签(Labels)、区域(Areas)和根(Roots)来追踪本机内存。例如,每个 UnityEngine.Object 派生类在 Object 区域下拥有一个根,当调用 Resources.UnloadUnusedAssets 时,无引用的对象根会被整体清理。
五、跨桥(Native-Managed Bridge):隐形的性能杀手

5.1 什么是"跨桥"?
在 Unity 中,托管内存(C# 对象)与本机内存(引擎对象) 之间存在频繁的数据交换。这种跨越两个内存域的操作,被称为"跨桥"(Crossing the Bridge)。
具体来说:
- 本机部分:场景中的 GameObject、Transform、Mesh、Texture 等,其核心数据存储在本机内存中。
- 托管部分:我们在 C# 中持有的引用(如 Transform变量),实际上是一个托管包装器(Wrapper),它指向本机对象,但自身只占很少的内存。
当我们通过托管引用访问本机数据(比如读取 transform.position)时,Unity 必须从本机内存复制数据到托管内存(或将数据从托管复制到本机),这个过程就是"跨桥"。
5.2 跨桥的代价
- 额外的 CPU 开销:跨域调用涉及上下文切换和参数封送(Marshalling),比纯 C#方法调用慢得多。有资料显示,这类操作可能使调用开销增加约 15% 。
- 潜在的 GC 分配:为了复制数据,有时会在托管堆上产生临时对象,增加 GC 压力。
更重要的是,开发者往往不知道哪些操作会触发跨桥,从而在循环或 Update 中无意间制造性能热点。
5.3 常见的跨桥操作与替代方案
| 触发跨桥的写法 | 推荐的替代写法 | 说明 |
|---|---|---|
| if (gameObject == null) | if (ReferenceEquals(gameObject, null)) | 只比较引用,不触发跨桥 |
| gameObject.tag == "Player" | gameObject.CompareTag("Player") CompareTag | 完全在本机侧完成,速度快一倍 |
| transform.position频繁访问 | 缓存为局部变量 Vector3 pos = transform.position; 复用 | 减少重复跨桥 |
| transform.rotation / .localScale 等 | 同上 | 缓存或使用 transform.SetPositionAndRotation 批量设置 |
| GetComponent()每帧调用 | 在 Awake/Start 中缓存引用 | 避免每次调用都跨桥查找 |
| 直接修改 mesh.vertices 数组 | 使用 Mesh.SetVertices 传入 NativeArray | 减少托管-本机复制次数 |
5.4 跨桥与 Native 容器
当我们使用 Unity.Collections 的 NativeArray 时,实际上是在 C# 层直接操作本机内存------这相当于主动建立一座更高效的桥,允许批量数据传递,减少了逐次跨桥的损耗。配合 Job System,还能实现多线程并行处理,且不触发 GC。
💡 一个良好的实践:对于频繁更新的数据(如每帧刷新的顶点位置),尽量使用 NativeArray 并配合 Mesh.SetVertices批量更新,而不是逐元素赋值。
六、常见内存陷阱与最佳实践
6.1 避免不必要的 GC 分配
- 在 Update 中避免 new 对象、字符串拼接、LINQ 操作。
- 使用对象池复用引用类型。
- 优先使用 struct 而非 class。
- 用 StringBuilder 替代频繁的字符串 +。
6.2 谨慎使用 Resources.UnloadUnusedAssets
- 该方法会触发完整 GC 并遍历所有资源,开销极大。
- 仅在场景切换等合适的时机调用,切忌频繁使用。
6.3 警惕静态字段和事件导致的内存泄漏
- 静态字段引用的对象不会被 GC,即使它们"看起来"不再需要。
- 使用弱引用(WeakReference)或及时注销事件监听。
6.4 非托管容器务必 Dispose
- 使用 using 语句或 try-finally 确保 NativeArray 等被释放。
- 在 Editor 中注意 TempJob 超时警告,及时处理。
6.5 活用性能分析工具
- Profiler CPU Usage:查看 GC.Alloc 样本,开启调用栈定位分配源。
- Profiler Memory:宏观内存趋势。
- Memory Profiler 包:离线深度分析,按标签、区域、根查看内存分布。
- Deep Profile 模式可详细捕获每帧分配,但会大幅影响性能,仅用于开发调试。