IL2CPP 托管堆内存碎片与原生堆交互机制深度解析
栏目:Unity知识速递 | 期号:NO.008 | 日期:2026-10-29
核心知识点
在 IL2CPP 构建中,内存分布在三个独立区域:托管堆(Managed Heap)、C# 非托管内存(C# Unmanaged Memory)和原生堆(Native Heap)。多数开发者只关注托管堆的 GC 分配,但真正决定移动端 OOM 风险的,是托管堆碎片如何触发堆扩张、以及托管引用如何"拖住"原生对象不释放。
理解三层内存的交互机制,是做中大型项目内存优化的第一道门槛。本期从 IL2CPP 底层实现出发,讲清三个核心问题:托管堆为什么只增不减、碎片如何导致堆扩张、以及为什么 UnloadUnusedAssets 有时不生效。
技术详解
一、三层内存架构:谁管谁的事
IL2CPP 运行时将内存分为三层,每层有独立的分配器和生命周期管理:
- 托管堆(Managed Heap):由 GC 自动管理,存放所有引用类型对象和装箱的值类型。GC 只扫描这一层,分配走 GC.Alloc。
- C# 非托管内存(C# Unmanaged):通过 Unity.Collections(NativeArray 等)和 UnsafeUtility 分配,走引擎原生分配器,不受 GC 管辖,需手动 Dispose。
- 原生堆(Native Heap):引擎 C++ 层的内存,存放纹理、Mesh、Shader 等所有资源的 CPU 端数据,以及引擎各子系统的内部状态。
关键认知:三层内存之间不是隔离的,而是通过"托管壳对象(Managed Shell Object)"互相引用。一个 C# 的 Texture2D 对象在托管堆只占几十字节,但它引用的原生纹理资源可能占几十 MB。GC 回收了托管壳,原生资源才有可能被释放。
二、托管堆碎片:为什么堆只增不减
托管堆的内存分配以 Segment(堆段)为单位,每个 Segment 包含多个 Page。IL2CPP 的 GC 采用标记-清除(Mark-Sweep)算法,回收后释放的内存会变成"空洞"------这就是碎片。
当新分配一个大对象时,如果当前堆段里找不到足够大的连续空闲块,GC 会先尝试回收。如果回收后仍然不够,堆就会扩张。扩张规则是:大多数平台上每次翻倍增长;如果请求的大小超过翻倍量,则做一次"超周期扩张"来容纳它。
生产级踩坑点:托管堆扩张后,即使 GC 回收了大量对象,虚拟内存也不会立即还给操作系统。Unity 会在"某些时机"尝试归还空闲页,但这个时机不可控也不可靠。结果就是:一次峰值分配可能让托管堆永久膨胀,物理内存占用居高不下。
三、实战场景:数组分配的隐性成本
来看一个商业项目中常见的场景------每帧访问 mesh.vertices。很多人不知道,Unity 所有返回数组的 API,每次调用都会创建一个全新的数组副本。
// ❌ 每帧分配 4 次数组,堆碎片快速累积
void Update()
{
for (int i = 0; i < mesh.vertices.Length; i++)
{
float x = mesh.vertices[i].x;
float y = mesh.vertices[i].y;
float z = mesh.vertices[i].z;
ProcessVertex(x, y, z);
}
}
上面的代码每帧访问了 4 次 mesh.vertices,每次都分配一个新数组。在 60fps 下,每秒产生 240 次数组分配,托管堆碎片会以惊人速度累积。
// ✅ 零分配版本:用 List 缓存 + GetVertices 填充
List<Vector3> m_vertexCache = new List<Vector3>();
void Update()
{
mesh.GetVertices(m_vertexCache);
for (int i = 0; i < m_vertexCache.Count; i++)
{
var v = m_vertexCache[i];
ProcessVertex(v.x, v.y, v.z);
}
}
类似的 API 还有很多:Physics.RaycastAll、Input.touches、Animator.parameters 等。它们都有对应的 NonAlloc / GetXxx 零分配版本,生产项目中必须优先使用。
四、托管引用拖住原生资源的释放
调用 Resources.UnloadUnusedAssets() 时,Unity 会扫描所有没有引用的原生对象并释放。但这里有一个极易踩坑的点:只要 C# 层还持有任何引用,原生资源就不会被释放。
// ⚠️ 静态字段持有引用,导致原生资源永不释放
public static class TextureCache
{
private static Texture2D s_whiteTexture;
public static Texture2D WhiteTexture
{
get
{
if (s_whiteTexture == null)
s_whiteTexture = Resources.Load<Texture2D>("white");
return s_whiteTexture;
}
}
}
上面的代码看起来很正常,但 s_whiteTexture 是静态字段,它的生命周期等于整个应用程序的生命周期。即使场景切换、即使调用了 UnloadUnusedAssets,这张纹理的原生内存也永远不会被释放。静态字段和静态事件订阅,是托管层内存泄漏最常见的来源。
实战价值
掌握这套机制,你可以解决以下生产级问题:
- 定位内存膨胀:Profiler 里看到 Mono 内存只升不降,先查是不是大对象分配导致堆段扩张,再查是不是静态引用拖住了原生资源。
- 降低 GC 频率:把热点路径上的数组 API 全部替换为 NonAlloc 版本,可以显著减少 GC.Alloc,降低碎片累积速度。
- 控制内存峰值:大型数组改用 NativeArray 等非托管容器,既避免托管堆碎片,又能配合 Job System 和 Burst 提速。
- 排查资源泄漏:UnloadUnusedAssets 后内存不降,用 Memory Profiler 的 Managed Shell Objects 视图查谁还持有引用------90% 的情况是静态字段或事件订阅。
互动思考
-
你的项目里,Mono 内存的峰值和稳态分别是多少?峰值是由什么操作触发的?
-
如果你发现 UnloadUnusedAssets 后原生内存没有下降,你会按什么顺序排查?
-
大规模数据(10w+ 元素)用 NativeArray 比普通 T\[\] 好在哪里?除了 GC 还有什么收益?
#Unity #知识速递 #游戏开发 #C# #IL2CPP #内存优化
信息来源:Unity 官方文档(docs.unity.com)· Unity Technologies · 2026年10月
标签:Unity、知识速递、游戏开发、C#、IL2CPP、内存优化