一、先搞懂啥是ECS架构和非托管内存泄漏
很多人写游戏或者做高并发服务时,会用到ECS架构,简单说就是把“物体”拆成三部分:实体(就是个编号)、组件(存数据,比如位置、血量)、系统(写逻辑,比如让实体移动)。这种架构跑起来特别快,因为数据是堆在一起存的,CPU找起来不用绕弯子。
但它有个坑:非托管内存泄漏。啥叫非托管内存?就是你自己管的内存,系统不会帮你自动回收——比如你申请了一块存游戏地图顶点的内存,用完忘了还,这块内存就一直占着,越占越多,最后程序卡崩。
为啥ECS里容易出这个问题?因为ECS要速度,经常会自己申请非托管内存存临时数据,比如粒子效果的顶点、物理计算的临时缓存,要是没写好回收逻辑,就漏了。
二、核心工具:DOTS的Profiler咋用
找内存泄漏得靠工具,Unity的DOTS(数据导向技术栈)自带的Profiler就是专门干这个的。先给你说清楚这个工具的用法,别上来就瞎点。
首先,你得开对Profiler的窗口:打开Unity,点Window→Analysis→Profiler,然后在Profiler的设置里,把“非托管内存”“GC Alloc”这几个选项勾上——不然看不到非托管内存的变化。
然后,你要先跑一遍程序的“正常流程”,比如从进游戏到打一局再退到主菜单,这时候你得看Profiler里的“非托管内存曲线”:要是跑正常流程的时候,内存曲线是升上去、然后降下来回到初始值,就没漏;要是跑一次流程内存涨一点,跑一次涨一点,那肯定漏了。
举个实际的例子,比如你做一个打怪兽的游戏,每次召唤怪兽都会申请一块非托管内存存怪兽的攻击逻辑缓存,怪兽死了没回收,那每次召唤内存都涨,退到主菜单也没降,这就是漏了。
三、具体定位步骤,每一步都给你讲透
3.1 第一步:先确定是不是非托管内存泄漏
很多人会把GC内存泄漏和非托管搞混,所以第一步要先区分开。GC内存是系统自动回收的,要是GC内存涨了,可能是你忘了把引用设为null;但非托管内存是你自己管的,必须手动回收。
咋区分?看Profiler的两个曲线:“GC Alloc”曲线和“非托管内存”曲线。如果只有非托管内存涨,GC没涨,那就是非托管的问题;要是两个都涨,可能既有GC漏又有非托管漏。
比如你写了一个临时的粒子系统,每次生成粒子都申请非托管内存,粒子消失没回收,那非托管曲线会一直涨,GC曲线可能没变化,因为你没生成新的托管对象。
3.2 第二步:用Profiler的内存快照找漏的地方
确定是非托管漏了,接下来要找具体是哪块代码申请的内存没回收。这时候要用Profiler的“内存快照”功能。
操作方法:在Profiler里,点“Memory”标签,然后点“Take Snapshot”(拍快照)。你要拍两次快照:第一次是在程序跑正常流程前,比如刚进游戏的主菜单;第二次是跑了一次流程后,比如打完一局回到主菜单。
然后对比两次快照的“非托管内存分配”列表:找那种“分配大小没变、数量变多”的项——比如你第一次快照里有10个“怪兽攻击缓存”,第二次变成20个,那就是这个缓存没回收。
举个例子,比如你写了一个怪兽的组件,里面有一个非托管的缓存,代码是这样的(技术栈:Unity DOTS):
// 技术栈:Unity DOTS
using Unity.Entities;
using Unity.Collections;
using UnityEngine;
// 怪兽的组件,存攻击缓存
public struct MonsterAttackCache : IComponentData
{
// 申请的非托管内存,用来存攻击的临时数据
public NativeArray<float> AttackData;
}
// 怪兽生成的系统
public partial class MonsterSpawnSystem : SystemBase
{
protected override void OnUpdate()
{
// 每帧生成一个怪兽(只是举例,实际不会每帧生成)
Entity monster = EntityManager.CreateEntity();
// 给怪兽加组件,申请100个float的非托管内存
EntityManager.AddComponentData(monster, new MonsterAttackCache
{
AttackData = new NativeArray<float>(100, Allocator.Persistent) // 这里用了Persistent分配器
});
}
}
// 怪兽死亡的系统,这里有bug:没回收AttackData
public partial class MonsterDeathSystem : SystemBase
{
protected override void OnUpdate()
{
// 遍历所有血量为0的怪兽
Entities.WithAll<MonsterAttackCache>().ForEach((Entity monster, in MonsterAttackCache cache) =>
{
// 怪兽死了,直接删除实体,忘了回收AttackData!
EntityManager.DestroyEntity(monster);
}).Run();
}
}
这段代码里,怪兽死了只删了实体,没回收AttackData,所以每次生成怪兽都会申请100个float的内存,越积越多。对比两次快照的时候,你会看到“MonsterAttackCache.AttackData”的数量越来越多,大小每次涨400字节(一个float是4字节,100个就是400)。
3.3 第三步:用Allocator分配器定位漏的类型
DOTS里的非托管内存分配器有三种:Allocator.Temp、Allocator.TempJob、Allocator.Persistent。不同的分配器,泄漏的情况不一样,回收的要求也不一样。
- Allocator.Temp:临时分配,一帧结束自动回收,一般不会漏,除非你把Temp的内存存到了组件里,一帧结束系统回收了,你再用就会报错。
- Allocator.TempJob:给Job系统用的,Job跑完自动回收,要是你在Job里申请了TempJob的内存,然后存到了组件里,Job跑完回收了,再用也会报错。
- Allocator.Persistent:永久分配,必须手动回收,最容易漏的就是这种。
咋看分配器类型?在Profiler的内存快照里,每个分配项的详情里会显示分配器类型。比如刚才的例子里,AttackData用的是Allocator.Persistent,所以是最容易漏的类型。
3.4 第四步:写测试代码验证漏的点
找到怀疑的代码后,要写测试代码验证是不是真的漏了。比如刚才的怪兽例子,你可以写一个测试:每次生成一个怪兽,然后让它死,看内存是不是回到原来的大小。
测试代码(技术栈:Unity DOTS):
// 技术栈:Unity DOTS
using Unity.Entities;
using Unity.Collections;
using UnityEngine;
public class MemoryLeakTest : MonoBehaviour
{
private EntityManager _entityManager;
private int _monsterCount = 0;
private void Start()
{
_entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
// 测试:生成一个怪兽,然后让它死
TestMonsterLeak();
}
private void TestMonsterLeak()
{
// 生成怪兽
Entity monster = _entityManager.CreateEntity();
_entityManager.AddComponentData(monster, new MonsterAttackCache
{
AttackData = new NativeArray<float>(100, Allocator.Persistent)
});
_monsterCount++;
// 让怪兽死
Entities.WithAll<MonsterAttackCache>().ForEach((Entity e, in MonsterAttackCache cache) =>
{
// 这里改了:先回收内存,再删实体
cache.AttackData.Dispose(); // 回收非托管内存
_entityManager.DestroyEntity(e);
_monsterCount--;
}).Run();
// 打印内存变化
Debug.Log($"当前怪兽数量:{_monsterCount},非托管内存:{Profiler.GetTotalAllocatedMemoryLong()} 字节");
}
}
改了之后,每次怪兽死都会先回收AttackData,内存就不会涨了。
四、应用场景、优缺点和注意事项
4.1 应用场景
非托管内存泄漏的定位方法,适合所有用ECS架构的场景,比如:
- 游戏开发(尤其是3D游戏、多人在线游戏,比如MOBA、MMO)
- 高并发服务(比如实时计算服务、大数据处理服务,用ECS架构处理海量数据)
- 嵌入式开发(资源有限,内存泄漏会直接导致设备崩溃)
4.2 技术优缺点
优点
- 速度快:ECS架构用非托管内存,数据连续存,CPU缓存命中率高,比面向对象架构快好几倍。
- 可控性强:非托管内存自己管,能精准控制内存的分配和回收,适合对性能要求高的场景。
缺点
- 容易漏:非托管内存必须手动回收,一旦忘了就会泄漏,比GC内存难定位。
- 门槛高:要懂内存管理、分配器类型,对新手不友好。
4.3 注意事项
- 一定要用对分配器:临时数据用Temp或TempJob,不要用Persistent;长期数据用Persistent,必须手动回收。
- 养成“谁申请谁回收”的习惯:谁申请的非托管内存,谁负责回收,不要让别人帮你回收,容易乱。
- 定期拍内存快照:开发过程中定期拍快照,提前发现泄漏,不要等上线了才发现。
- 用Profiler的“泄漏检测”功能:Unity DOTS的Profiler有专门的非托管内存泄漏检测,开了之后会自动提示哪里漏了。
五、文章总结
非托管内存泄漏是ECS架构里的常见坑,但只要掌握正确的定位方法,就能快速解决。核心就是:先确定是不是非托管泄漏,再用Profiler的内存快照找漏的代码,然后根据分配器类型分析漏的原因,最后写测试代码验证修复。
记住几个关键点:非托管内存是自己管的,必须手动回收;DOTS的Profiler是专门的工具,一定要会用;“谁申请谁回收”是避免泄漏的核心原则。只要把这些点掌握好,就能轻松搞定ECS架构下的非托管内存泄漏问题。
Comments