首页 / 资讯中心 / 文章详情

Unity内存管理深度解析:从托管堆到资源内存的优化实战

Unity内存管理深度解析:从托管堆到资源内存的优化实战 ★ FEATURED ARTICLE
1. 为什么Unity内存管理值得你花时间搞透做Unity项目这些年我见过太多团队在项目后期被内存问题拖垮。美术资源越堆越多场景越做越大手机跑着跑着就闪退编辑器里Profiler一开GC Alloc那一栏红得刺眼。更让人头疼的是有些问题在高端机上完全看不出来一到中低端设备就原形毕露。说到底Unity的内存管理不像传统C那样需要手动malloc和free但它也绝不是“不用管”的。它有一套自己的规则托管堆、原生内存、资源内存各管各的你不理解这套规则就只能在出问题的时候瞎猜。这篇文章我想把Unity内存管理这件事从头到尾讲清楚。不管你是刚入行的新人还是做了几年但一直没系统梳理过这块的开发者我都希望能让你看完之后有一个完整的认知框架。我会从内存的基本分类讲起然后深入到托管堆和GC的运作机制再讲到资源内存的管理策略最后给出实际项目中的优化手段和排查方法。中间会穿插大量我在实际项目中踩过的坑和总结出来的经验这些内容你在官方文档里不一定找得到但都是真金白银换来的。先明确一个核心认知Unity的内存大致可以分成三块——托管内存、原生内存和资源内存。托管内存由Mono或IL2CPP的运行时管理受GC控制原生内存是引擎底层和第三方库申请的不受GC管辖资源内存则是你加载的Texture、Mesh、AudioClip这些资源占用的空间。这三块互有关联但管理方式完全不同。很多人一提到内存优化就只盯着GC其实资源内存往往才是大头。搞清楚这个分类是做好内存管理的第一步。2. Unity内存的核心分类与底层逻辑2.1 托管内存与GC的运行机制托管内存是C#脚本运行时申请的内存由Mono或IL2CPP的垃圾回收器负责回收。你在代码里new一个对象、创建一个字符串、装箱一个值类型这些都会在托管堆上分配内存。托管堆又分为小对象堆和大对象堆一般来说超过85KB的对象会进入大对象堆。小对象堆的分配和回收效率高大对象堆的回收代价大且容易产生碎片。GC的工作方式是标记-清除或者标记-压缩。Mono的GC是分代式的分为第0代、第1代和第2代。新分配的对象在第0代第0代满了就触发一次GC存活的对象晋升到第1代以此类推。第2代GC也就是Full GC会扫描整个堆代价最高。IL2CPP使用的是Boehm GC不分代每次GC都是全堆扫描这也是为什么IL2CPP下GC卡顿往往更明显的原因。这里有一个关键点很多人忽略GC的触发时机不完全由你控制。你没法手动调用GC.Collect()来精确管理虽然可以调用但不建议频繁使用GC会在堆内存不足时自动触发。这意味着如果你的代码每帧都在产生大量垃圾GC就会频繁触发造成帧率波动。我见过一个项目每帧在Update里拼接字符串做UI显示结果GC每几秒就触发一次帧率像心电图一样跳。注意GC.Alloc这个指标在Profiler里代表的是每帧分配的托管内存量不是GC触发的次数。优化目标是把这个值降到尽可能低理想情况是每帧0B。2.2 原生内存的构成与排查手段原生内存是Unity引擎底层、图形API、音频系统、物理系统以及第三方插件申请的内存。这部分内存不受GC管理你也没法直接控制它的分配和释放。常见的原生内存包括图形驱动分配的显存、物理引擎的碰撞体数据、音频解码缓冲区、以及各种插件的内部数据结构。原生内存的问题在于它不透明。你在Profiler里看到的Native内存只是一个总数具体分配到哪了需要更细粒度的工具来查。Unity的Memory Profiler可以抓取快照看到每一块原生内存的归属。我一般会在怀疑有原生内存泄漏的时候连续抓几次快照做对比看哪一块在持续增长。一个典型的原生内存泄漏场景是你加载了一个AssetBundle用完之后调用了Unload(false)但AssetBundle内部的某些资源还被引用着导致整个Bundle的原生内存无法释放。这种问题在编辑器里往往看不出来因为编辑器的资源加载走的是另一套路径。只有真机测试才能暴露。2.3 资源内存真正的大头资源内存是你加载的Texture、Mesh、AudioClip、AnimationClip、Material等资源占用的内存。这部分内存的大小取决于资源的导入设置和加载方式。一张4096x4096的RGBA32纹理未压缩的情况下占用64MB内存。你项目里有十张这样的纹理就是640MB。手机上显存和内存是共享的这么大的占用直接就把设备压垮了。资源内存的管理核心在于两点什么时候加载和什么时候卸载。Unity提供了Resources、AssetBundle、Addressables等多种加载方式每种方式对内存的影响都不一样。Resources文件夹里的资源会在游戏启动时全部加载到内存中不管你有没有用到。这是最粗暴的方式也是内存占用最高的方式。AssetBundle可以按需加载和卸载但需要你自己管理引用计数和依赖关系。Addressables是在AssetBundle之上封装的一套更高级的系统提供了引用计数和自动卸载的能力。我个人的经验是新项目一律用Addressables老项目如果已经在用AssetBundle且运行稳定不必强行迁移。Resources文件夹只用来放极少量必须常驻内存的资源比如全局配置、字体等。3. 托管堆优化的实操要点3.1 哪些操作在偷偷分配内存要优化托管堆首先得知道内存是怎么被分配的。很多看似无害的代码实际上每帧都在产生垃圾。我整理了一份常见的内存分配场景清单你可以对照自己的代码排查字符串拼接每次用拼接字符串都会创建新的字符串对象。在Update里做这件事就是灾难。装箱把值类型赋值给object类型或者把值类型放进非泛型集合里都会触发装箱。闭包和lambdalambda表达式捕获外部变量时会创建闭包对象。数组和List的创建每次new一个数组或List都会在堆上分配。foreach遍历非泛型集合某些情况下foreach会产生装箱。协程的yield return new WaitForSeconds每次都会创建一个新的WaitForSeconds对象。LINQ查询LINQ的每个操作符都会创建中间对象。GetComponents系列方法这些方法每次调用都会返回一个新数组。我见过最夸张的一个案例是一个项目在Update里用GetComponentsInChildren遍历子物体做状态同步每帧分配几KB的托管内存GC每隔十几秒就触发一次每次触发造成几十毫秒的卡顿。后来改成在Start里缓存组件引用GC Alloc直接降到0。3.2 用对象池替代频繁创建销毁对象池是减少托管内存分配最有效的手段之一。核心思想是不再频繁地new和销毁对象而是预先创建一批对象放在池子里用的时候取用完还回去。这样就把分配和回收的成本摊薄到了初始化阶段。Unity自己也有对象池在UnityEngine.Pool命名空间下提供了ObjectPool、ListPool、DictionaryPool等。我一般会自己封装一个泛型的对象池因为Unity自带的池在某些场景下不够灵活。下面是一个简化的实现思路public class ObjectPoolT where T : class, new() { private StackT pool new StackT(); private FuncT createFunc; private ActionT resetAction; public ObjectPool(FuncT createFunc, ActionT resetAction, int prewarmCount 0) { this.createFunc createFunc; this.resetAction resetAction; for (int i 0; i prewarmCount; i) { pool.Push(createFunc()); } } public T Get() { T item pool.Count 0 ? pool.Pop() : createFunc(); return item; } public void Return(T item) { resetAction?.Invoke(item); pool.Push(item); } }这个池子的关键在于resetAction它负责把对象恢复到初始状态。比如一个子弹对象归还时需要重置位置、速度、生命周期等属性。如果忘了重置下次取出来就会带着上次的状态产生难以排查的bug。提示对象池不是万能的。如果对象的创建频率很低比如几秒才创建一个那用不用池子差别不大。池子主要用在每帧或高频创建的场景比如子弹、特效、飘字、伤害数字等。3.3 结构体与类的选择策略在C#里结构体是值类型分配在栈上或者作为类的字段时内联在堆对象里类是引用类型分配在堆上。这意味着使用结构体可以减少托管堆的分配。但结构体也不是随便用的它有几个限制不能继承、默认不能为null、赋值时是拷贝语义。我的一般原则是如果一个数据结构很小小于16字节、生命周期短、不需要多态就优先用结构体。比如Vector3、Color、RaycastHit这些Unity内置的类型都是结构体。你自己定义的数据结构比如一个伤害信息、一个配置项如果满足上述条件也可以考虑用结构体。但要注意结构体的拷贝开销。如果一个结构体很大比如包含十几个字段那每次传参和赋值都会产生拷贝反而影响性能。一般来说结构体的大小控制在16到32字节以内比较合适。超过这个范围就该考虑用类了。还有一个坑是结构体作为接口的实现或者放在List里时可能会被装箱。比如你把一个结构体放进List3.4 字符串处理的避坑指南字符串是托管内存分配的重灾区。C#的字符串是不可变的任何修改操作都会创建新的字符串对象。在游戏循环里做字符串操作几乎必然导致GC压力。我总结了几个字符串处理的实用技巧避免在Update里拼接字符串。如果UI需要显示变化的数值用TextMeshPro的SetText方法配合格式化参数或者用StringBuilder缓存。用StringBuilder替代频繁的字符串拼接。StringBuilder内部维护一个字符数组拼接时不会创建新对象。但要注意StringBuilder本身也要缓存复用不要每次用都new一个。用字符串比较时避免不必要的分配。比如string.Equals(a, b)比a b在某些情况下更高效因为后者可能会调用string.Concat。用HashCode替代字符串做字典键。如果你的字典键是字符串考虑在初始化时把字符串转成int类型的哈希值用int做键。避免使用string.Format。string.Format内部会装箱参数并创建新字符串。可以用C#的字符串插值替代但插值在底层也是string.Format所以最好的方式还是用StringBuilder或者TextMeshPro的格式化功能。我实测过一个案例一个伤害飘字系统原本每帧用string.Format拼接伤害数值GC Alloc在高峰期达到每帧2KB。改成用TextMeshPro的SetText配合预缓存的StringBuilder之后GC Alloc降到0。4. 资源内存的管理与优化4.1 纹理资源的导入设置与内存占用纹理通常是资源内存中占用最大的一块。一张纹理占用的内存大小可以用这个公式估算宽度 × 高度 × 每像素字节数 × Mipmap层级数。每像素字节数取决于纹理格式RGBA32是4字节RGB24是3字节RGBA16是2字节等等。纹理格式的选择直接决定了内存占用和画质。我的经验是UI纹理用RGBA32或者RGBA16关闭Mipmap因为UI通常不需要缩放。3D模型纹理用压缩格式比如ASTC、ETC2、PVRTC。压缩格式的内存占用是未压缩的1/4到1/8但画质会有损失。法线贴图用压缩格式但要注意压缩算法对法线的精度影响。光照贴图用RGBM或者Half格式根据项目需求选择。Mipmap是一个容易被忽略的点。开启Mipmap后纹理内存会增加约33%但渲染性能会提升因为远处物体使用低级别的Mipmap减少了纹理采样带宽。对于3D场景中的纹理我一般建议开启Mipmap对于UI纹理关闭。还有一个重要的设置是Read/Write Enabled。开启后纹理会在内存中保留一份CPU可访问的拷贝内存占用翻倍。除非你需要在代码里读取纹理像素否则一律关闭。4.2 AssetBundle的加载与卸载策略AssetBundle是Unity资源管理的核心机制。它的内存管理有几个关键点加载方式AssetBundle.LoadFromFile是最高效的方式它直接从磁盘加载不需要经过托管堆。LoadFromMemory和LoadFromStream会占用额外的托管内存一般不推荐。卸载方式AssetBundle.Unload(false)只卸载AssetBundle对象本身不卸载已经加载的资源。Unload(true)会卸载AssetBundle和所有已加载的资源但如果还有引用指向这些资源就会导致资源丢失。我一般用Unload(false)然后手动管理资源的卸载。引用计数AssetBundle本身不提供引用计数需要你自己维护。我通常会在加载资源时记录引用在释放时减少引用引用为0时才真正卸载。依赖关系AssetBundle之间有依赖关系加载一个Bundle时需要先加载它依赖的所有Bundle。如果依赖的Bundle被卸载了就会导致资源丢失。我一般会在打包时生成依赖关系表加载时按表加载。一个常见的坑是同一个资源被多个Bundle引用。比如一个材质被多个模型使用如果这个材质被打进了多个Bundle就会在内存中存在多份拷贝。解决方法是把共享资源单独打成一个Bundle其他Bundle依赖它。4.3 Addressables系统的实战经验Addressables是Unity官方推荐的资源管理系统它在AssetBundle之上封装了引用计数、依赖管理、异步加载等功能。我用Addressables做过几个项目总结了一些经验分组策略Addressables的分组决定了资源的打包方式。我一般会按功能模块分组比如UI、角色、场景、特效各一组。同一组内的资源会被打进同一个Bundle所以要把同时加载的资源放在同一组避免频繁的Bundle切换。加载方式Addressables提供了LoadAssetAsync和LoadSceneAsync等异步加载接口。异步加载不会阻塞主线程但需要注意加载完成后的回调时机。我一般会用async/await来简化异步代码的编写。释放方式Addressables使用引用计数来管理资源。每次LoadAssetAsync会增加引用计数每次Release会减少引用计数。引用计数为0时资源会被自动卸载。但要注意Addressables的卸载是延迟的不会立即释放内存。预加载Addressables支持预加载可以在游戏启动时预先加载一些常用资源。但预加载会占用内存需要权衡。我踩过的一个坑是Addressables的引用计数和UnityEngine.Object的引用是两回事。你LoadAssetAsync得到一个GameObject即使你Destroy了这个GameObjectAddressables的引用计数也不会自动减少。你必须显式调用Release。这个坑让我在一个项目里多占了几十MB内存排查了很久才发现。5. 内存问题的排查与实战案例5.1 用Profiler定位内存问题Unity Profiler是排查内存问题的第一工具。我一般会关注这几个指标Total Allocated当前帧分配的总内存。GC Alloc当前帧分配的托管内存。Total Reserved托管堆保留的总内存。Total Used托管堆实际使用的内存。Texture Memory纹理占用的内存。Mesh Memory网格占用的内存。Audio Memory音频占用的内存。在Profiler里你可以看到每一帧的内存分配情况。如果GC Alloc持续很高说明代码里有频繁的托管内存分配。如果Texture Memory持续增长说明有纹理没有被正确卸载。Memory Profiler是更细粒度的工具它可以抓取内存快照看到每一个对象的引用链。我一般会在怀疑有内存泄漏的时候连续抓几次快照做对比看哪些对象在持续增长。5.2 常见内存问题速查表问题现象可能原因排查方法解决方案GC频繁触发帧率波动每帧产生大量托管内存Profiler查看GC Alloc缓存对象、用对象池、避免字符串拼接内存持续增长不释放资源未卸载或引用未释放Memory Profiler对比快照检查AssetBundle卸载、Addressables Release纹理内存过高纹理格式未压缩或Mipmap开启Profiler查看Texture Memory压缩纹理、关闭不需要的Mipmap加载新场景时闪退峰值内存超过设备限制真机测试、内存监控分步加载、卸载无用资源、降低纹理质量编辑器正常真机崩溃编辑器与真机内存管理差异真机Profiler真机测试、注意平台差异5.3 一个真实的内存优化案例我之前接手过一个项目游戏在低端机上跑几分钟就闪退。用Profiler一查发现每次切换场景时内存都会增长几十MB而且不释放。排查后发现几个问题第一场景切换时没有卸载上一个场景的AssetBundle。代码里只调用了SceneManager.LoadScene但没有调用AssetBundle.Unload。导致上一个场景的资源一直留在内存里。第二UI纹理没有压缩全部是RGBA32格式而且开启了Read/Write Enabled。几十张UI图加起来占了几百MB内存。第三代码里有一个全局的缓存字典缓存了所有加载过的资源但从来没有清理过。这个字典本身占用的内存不大但它持有的资源引用导致这些资源无法被GC回收。针对这三个问题我做了以下优化在场景切换时先卸载上一个场景的AssetBundle再加载新场景。把所有UI纹理改成ASTC压缩格式关闭Read/Write Enabled。把全局缓存字典改成弱引用字典或者定期清理不再使用的缓存项。优化之后游戏在低端机上的内存占用从原来的800MB降到了300MB左右闪退问题基本解决。5.4 内存优化的几个原则性建议做了这么多项目我总结了几个内存优化的原则第一尽早关注内存。不要等到项目后期才想起来优化内存那时候资源已经堆成山了改起来成本极高。我一般会在项目初期就建立内存监控机制每次提交代码都跑一下内存测试。第二用数据说话。不要凭感觉猜哪里占内存用Profiler和Memory Profiler去测。我见过太多人凭直觉优化结果优化了不占内存的地方真正的问题反而没解决。第三区分平台。不同平台的内存管理机制不同编辑器、iOS、Android、PC各有各的特点。编辑器里正常不代表真机正常高端机正常不代表低端机正常。一定要在目标设备上测试。第四留足余量。不要贴着设备的内存上限做优化要留出至少20%的余量。因为系统本身要占内存其他应用也可能占内存你的游戏不是独占设备的。第五持续监控。内存问题不是优化一次就一劳永逸的随着项目迭代新的资源、新的代码都可能引入新的内存问题。我一般会在CI流程里加入内存测试每次构建都自动跑一遍发现异常及时报警。6. 进阶话题与工具链6.1 托管堆碎片化与压缩托管堆碎片化是一个比较隐蔽的问题。当你在堆上分配和释放不同大小的对象时堆里会出现很多空洞。这些空洞虽然总大小够用但可能没有一个连续的空间能容纳一个大对象导致GC频繁触发或者分配失败。Mono的GC有压缩功能可以在GC时移动对象来消除碎片。但压缩的代价很高会暂停所有线程。IL2CPP的Boehm GC默认不压缩碎片化问题更严重。减少碎片化的方法主要有两个一是尽量分配大小一致的对象二是用对象池复用对象。我一般会避免在运行时频繁创建和销毁大对象如果确实需要就用池子管理。6.2 用Unsafe代码和NativeArray绕过GC对于性能敏感的场景Unity提供了NativeArray和Unsafe代码来绕过托管堆。NativeArray是Unity.Collections命名空间下的一个结构体它分配在原生内存中不受GC管理。你可以用NativeArray来存储大量数据比如顶点数据、粒子数据、路径点等。使用NativeArray需要注意几点一是需要手动释放用Dispose方法二是不能直接访问需要通过索引三是不能作为类的字段长期持有因为它是结构体拷贝时会出问题。Unsafe代码需要开启Allow unsafe Code选项可以用指针直接操作内存。但Unsafe代码容易出错我一般只在性能瓶颈非常明确的情况下才用。6.3 内存监控与自动化测试在团队协作中内存监控需要自动化。我一般会做这几件事在CI流程里加入内存测试每次构建后自动运行游戏加载所有场景记录内存峰值超过阈值就报警。在游戏里加入内存监控面板开发阶段可以实时查看内存占用方便定位问题。定期做内存快照对比每周或每个版本做一次内存快照和上一个版本对比看有没有异常增长。这些措施看起来麻烦但长期来看能省下大量排查时间。我经历过一个项目因为没有内存监控上线后才发现内存泄漏紧急修复导致延期了两周。如果早点做监控这个问题在开发阶段就能发现。6.4 不同平台的内存管理差异不同平台的内存管理机制差异很大我简单说一下几个主要平台的特点iOS使用ARC自动引用计数管理原生内存托管内存由IL2CPP的Boehm GC管理。iOS对内存的限制比较严格超过一定阈值就会被系统杀掉。iOS的纹理压缩格式是PVRTC和ASTC。Android托管内存由Mono或IL2CPP管理原生内存由系统管理。Android设备碎片化严重不同厂商的内存管理策略不同。Android的纹理压缩格式是ETC2和ASTC。PC内存相对充裕但也不能无限制使用。PC平台的纹理压缩格式是DXT和BC系列。主机内存管理最严格需要精确控制每一MB内存。主机平台的纹理压缩格式是平台专用的。跨平台项目需要针对不同平台做不同的内存策略。我一般会在代码里用预处理指令区分平台针对不同平台设置不同的纹理质量、加载策略和缓存大小。7. 我个人的一些经验体会写了这么多最后分享几个我在实际项目中总结出来的、文档里不会写的经验。关于GC不要试图完全消除GC那不现实。目标是让GC的频率和影响降到可接受的范围。我一般会把GC Alloc控制在每帧200B以内这样GC可能几分钟才触发一次玩家基本感知不到。关于资源加载异步加载不一定比同步加载好。异步加载虽然不阻塞主线程但它会增加代码复杂度而且异步加载的资源在加载完成前会占用额外的内存。对于小资源同步加载可能更合适。关于内存泄漏大部分内存泄漏不是真正的泄漏而是引用没有及时释放。我排查内存泄漏的第一步永远是检查引用链看是谁在持有不该持有的引用。关于工具Profiler和Memory Profiler是基础工具但有时候你需要更专业的工具。比如Xcode Instruments、Android Studio Profiler、RenderDoc等。不要局限于Unity自带的工具。关于优化顺序先优化大头再优化小头。纹理占内存最多就先优化纹理GC占CPU最多就先优化GC。不要一上来就抠那些只占几KB的小对象投入产出比太低。内存管理这件事说到底是经验和意识的积累。你做得越多对内存的敏感度就越高写代码的时候自然就会避开那些会产生垃圾的写法。希望这篇文章能帮你建立起一个完整的认知框架在实际项目中少踩一些坑。
阅读完成 · 觉得有帮助?
咨询建站