
GameDevMind C# 对象池实战用 Get/Release 替代 new 消除 GC 抖动【免费下载链接】GameDevMind最全面的游戏开发技术图谱(Game Development Map)。帮助游戏开发者们在已知问题上节省时间省出更多的精力投入到更有创造性的工作中去。项目地址: https://gitcode.com/GitHub_Trending/ga/GameDevMind本文以 GameDevMind 仓库 C# 对象池示例 为骨架讲解如何在 Unity / 手游客户端中通过泛型对象池应对子弹、粒子、伤害数字等每帧大量创建销毁的场景。读完本文你将掌握对象池的核心 API、自动扩容机制、与 UnitySetActive生命周期的对接方式以及它与仓库中 C 版对象池的异同并了解错误使用引用导致的内存泄漏反面案例。一、为什么 C# 手游客户端需要对象池在 Unity 手游客户端中子弹、粒子、伤害数字、飘字等对象往往是每帧高频创建、短生命周期的典型对象。以射击游戏为例一帧可能同时存在数十乃至上百颗子弹每颗子弹存活不过数秒传统写法是var bullet new Bullet(); // 每帧大量 new // ... 使用 // 生命周期结束等待 GC 回收这种写法有两个直接后果GC 分配压力大每次new都会在托管堆上分配内存对象不再被引用后由 GC 统一回收分配的越多、回收的越频繁GC 暂停时间就越长GC 抖动GC Spike手游尤其在意帧率稳定性GC 暂停造成的卡顿掉帧在战斗中体验极差。对象池的思路非常直接预先分配一批对象需要时从池中取Get用完后归还Release而不是销毁。这样整个游戏过程中对象只被创建一次后续全部是借还复用托管堆不再有持续的分配与回收GC 压力被大幅摊平。仓库中 C#语言知识图谱 也把对象池列为应对 GC 暂停的标准手段见该文档中GC 暂停导致卡顿 → 减少分配对象池一节并给出了对应的 AI 编程提示词范例「这段技能释放逻辑每帧 new List导致 GC 卡顿请改为对象池或预分配Unity 移动端」这正说明对象池在移动端性能优化中的基础地位。二、快速运行示例示例项目位于 code/gamedevmind/1.基础能力/1.1.3.C#语言/object_pool/是一个标准的 .NET 控制台演示程序包含三个文件文件说明ObjectPool.cs泛型对象池核心实现 演示用Bullet类Program.cs模拟一帧内发射 6 颗子弹、随后统一回收的演示入口object_pool.csproj项目配置net8.0、可空引用类型开启在仓库根目录进入示例目录后直接运行cd code/gamedevmind/1.基础能力/1.1.3.C#语言/object_pool dotnet run预期输出大致如下 C# 对象池演示 发射 bullet#0 at (0, 100) 发射 bullet#1 at (10, 100) ... 池中总数: 6, 活跃: 6, 空闲: 0 回收后 — 总数: 6, 空闲: 6 要点Get/Release 替代 new/GC帧率更稳定。其中池中总数CountAll在回收前后保持不变说明对象没有被销毁而是被归还复用——这就是对象池的核心行为特征。三、核心 API 与源码剖析示例 README 给出的核心 API 如下方法说明Get()从池中取对象池空则新建Release(item)归还对象到池onGet/onRelease可选回调对应 Unity 中SetActive(true/false)对应源码见 ObjectPool.cs下面是完整实现public sealed class ObjectPoolT where T : class, new() { private readonly StackT _free new(); private readonly ActionT? _onGet; private readonly ActionT? _onRelease; private int _totalCreated; public ObjectPool(int initialSize 32, ActionT? onGet null, ActionT? onRelease null) { _onGet onGet; _onRelease onRelease; Grow(initialSize); } public int CountInactive _free.Count; public int CountAll _totalCreated; public T Get() { var item _free.Count 0 ? _free.Pop() : CreateOne(); _onGet?.Invoke(item); return item; } public void Release(T item) { if (item is null) throw new ArgumentNullException(nameof(item)); _onRelease?.Invoke(item); _free.Push(item); } public void Clear() _free.Clear(); private void Grow(int count) { for (int i 0; i count; i) _free.Push(CreateOne()); } private T CreateOne() { _totalCreated; return new T(); } }逐行拆解它的设计要点3.1 泛型约束where T : class, new()T必须是引用类型且带无参构造函数。这保证了两点new T()一定可用池扩容时可以无脑创建T是引用类型池内保存的是引用而非拷贝归还后复用的是同一个对象实例。注意约束中的class意味着值类型struct不能直接进池。这与知识图谱中避免值类型装箱的提示词范例相呼应——若想支持 struct 需要额外设计装箱或改用ArrayPoolT等方案本示例刻意保持最简单可靠的引用类型实现。3.2 用StackT做空闲栈LIFO 的 O(1) 借还内部用StackT存放空闲对象。Get()时Pop()取走栈顶Release()时Push()压回栈顶借出、归还都是 O(1)没有任何遍历或查找LIFO 顺序意味着最近归还的对象最先被复用对缓存局部性更友好刚用过的对象大概率仍驻留 CPU 缓存。CountInactive直接返回_free.Count当前空闲数量CountAll返回_totalCreated池累计创建的总数包含已被借出在用的。3.3 预分配 惰性扩容构造函数中Grow(initialSize)会一次性预创建initialSize默认 32个对象放入空闲栈覆盖常规需求避免运行期频繁触发分配。当Get()遇到池为空时会调用CreateOne()当场新建一个并放入栈中返回同时_totalCreated。这是一种按需扩容策略池容量会随着并发峰值自然增长但一旦涨上去_totalCreated就封顶了——此后所有取用都命中空闲栈不再产生任何分配。从源码结构看_totalCreated只增不减可以作为该池生命周期内真实到达过的最大并发对象数来观察用于后续调优initialSize。3.4 回调钩子对接 Unity 的 SetActiveonGet与onRelease是两个可选回调在借出和归还时触发var pool new ObjectPoolBullet( initialSize: 4, onGet: b b.Active true, onRelease: b b.Reset() );这正是 Unity 中对象池的经典用法——onGet对应SetActive(true)取出即激活onRelease对应SetActive(false)加状态重置归还即隐藏并归零// Unity 场景中的典型对应 onGet: obj obj.SetActive(true) onRelease: obj { obj.SetActive(false); obj.Reset(); }Release()还会对null入参抛出ArgumentNullException防止把空引用压入空闲栈导致后续Get()返回null。3.5 演示对象 BulletBullet类模拟一颗子弹提供X / Y坐标与Active标志以及一个Reset()方法public sealed class Bullet { public float X { get; set; } public float Y { get; set; } public bool Active { get; set; } public void Reset() { X Y 0; Active false; } }Reset()的意义在于对象被复用时必须回到初始状态否则上一任使用者的残留数据会泄漏到下一任。在实际项目中这通常表现为把子弹的速度、贴图、碰撞体状态等全部清零。四、演示流程解读一帧的借还闭环Program.cs 演示了完整的借还闭环var pool new ObjectPoolBullet( initialSize: 4, onGet: b b.Active true, onRelease: b b.Reset() ); // 模拟一帧内发射 6 颗子弹池只有 4 个会自动扩容 var active new ListBullet(); for (int i 0; i 6; i) { var bullet pool.Get(); bullet.X i * 10f; bullet.Y 100f; active.Add(bullet); } Console.WriteLine($\n池中总数: {pool.CountAll}, 活跃: {active.Count}, 空闲: {pool.CountInactive}); // 回收 foreach (var b in active) pool.Release(b); Console.WriteLine($回收后 — 总数: {pool.CountAll}, 空闲: {pool.CountInactive});关键信息点预分配 4 个实际要用 6 个前 4 次Get()命中空闲栈后 2 次触发按需扩容CreateOne()CountAll从 4 涨到 6回收后CountAll仍是 6没有任何对象被销毁全部回到空闲栈CountInactive变为 6下一帧可直接复用活跃列表由调用方持有Release后调用方必须主动放弃引用池不负责追踪谁在借用。这段演示对应到真实游戏循环就是Update中借子弹 → 更新飞行 →lifetime 0时归还与仓库 C 版 main.cpp 中的BenchmarkObjectPool循环结构完全同构。五、与 C 版对象池的对比仓库同时提供了 C 版对象池code/gamedevmind/1.基础能力/1.2.1.设计模式/object_pool/两版演示的是同一个设计模式但实现风格有明显差异维度C# 版C 版文件位置1.1.3.C#语言/object_pool/1.2.1.设计模式/object_pool/接口命名Get()/Release(item)Acquire()/Release(T obj)空闲容器StackT对象直接入栈free_list_空闲索引栈 vectorunique_ptrT存储扩容策略按需CreateOne()单次创建池耗尽时翻倍扩容Expand()状态重置依赖外部onRelease回调if constexpr检测对象是否有Reset()方法自动调用归还校验ArgumentNullException空值检查assert校验对象是否属于本池线程模型未加锁主线程使用明确标注线程不安全游戏主线程专用性能基准无教学演示内置 new/delete vs 对象池耗时对比基准几个值得注意的实现差异C 版用索引栈pool_持有对象的unique_ptrfree_list_只存空闲索引Acquire/Release都是 O(1) 弹栈/压栈且对象内存地址在池生命周期内不变C 版自动检测Reset()借助 C20 的requires表达式对象有Reset()就自动调用无需像 C# 版那样显式传入onRelease回调——两版是同一思想在不同语言约束下的两种落地C 版强调 Release 模式编译其 README 特别警告性能对比必须在 Release 模式下编译Debug 模式结果无参考意义因为 Debug 下vector、分配器、断言的开销会严重污染对比数据。C 版的性能基准逻辑300 帧 × 每帧 200 颗子弹见 main.cpp可作为对象池能带来多大收益的参考但切勿直接照抄其具体耗时数字——那是特定编译器与硬件下的测量结果应在自己项目环境实测。六、嵌入 Unity 项目的实操指引C# 版 README 明确说明去掉Program.cs即可直接嵌入 Unity 项目。具体步骤将 ObjectPool.cs 拷贝到 Unity 项目的Assets/Scripts/下任意目录Unity 会自动编译.cs文件将namespace GameDevMind.Examples改为你项目的命名空间或删掉命名空间为你的池化对象子弹、粒子、飘字、UI 列表项等编写带无参构造函数的类并实现Reset()清零所有字段在游戏初始化阶段创建全局对象池onRelease里做SetActive(false)Reset()战斗中一律pool.Get()代替Instantiatepool.Release(obj)代替Destroy。一个最小化的 Unity 子弹池骨架public class BulletPool : MonoBehaviour { private ObjectPoolBullet _pool; private void Awake() { _pool new ObjectPoolBullet( initialSize: 64, onGet: b { b.gameObject.SetActive(true); }, onRelease: b { b.gameObject.SetActive(false); b.Reset(); } ); } public Bullet Spawn(Vector3 pos, Vector3 dir) { var b _pool.Get(); b.transform.position pos; // ... 设置方向、速度 return b; } public void Despawn(Bullet b) _pool.Release(b); }接入后可通过pool.CountAll与pool.CountInactive做调试观察正常情况下CountAll会在开战首波后趋于稳定到达并发峰值CountInactive与CountAll的差值即当前活跃对象数。Unity 场景下的补充注意点初始大小按峰值估initialSize建议按同屏同时存在的最大对象数估算可先设小值观察CountAll峰值再回调预留过大浪费内存、过小则运行期频繁扩容池对象内部避免残留引用Reset()只清字段还不够若对象持有对其他 GameObject、组件、委托的引用归还前必须清空否则会意外延长被引用对象的生命周期这正是下文反面案例的根源不销毁 ≠ 不重置SetActive(false)只是隐藏对象仍占据内存与组件开销池的总内存 CountAll× 单对象体积需纳入内存预算。七、反面教材引用没放干净导致的内存泄漏对象池正确使用的前提是借了要还、还了要清。仓库 SLG 内存泄漏案例 记录了一起真实的线上事故一款 SLG 手游服务器内存 2 小时准时撞上 OOM Killer最终定位为shared_ptr循环引用——Player持有Guild的shared_ptrGuild又用vectorshared_ptrPlayer持有所有成员形成闭环后引用计数永远无法归零对象看似释放、实则常驻。该案例虽然不是 C# 对象池的直接代码但它揭示了一条对对象池同样致命的原则只要有一个引用没被正确释放/清除对象就永远不会真正离开内存。放到 C# 对象池语境中等价的问题形态包括onRelease回调里忘了清空对象持有的Sprite、Material、ListT等引用导致这些大对象随池对象一起被常驻引用游戏逻辑在Release后仍持有对象引用继续访问use-after-release读到的是下一位使用者的脏数据池对象注册了事件/委托却未在归还时注销委托目标被池对象间接引用而无法被 GC 回收。案例文档给出的方法论同样适用区分拥有与观察池持有对象所有权复用生命周期业务逻辑只应临时借用借完即弃加监控让泄漏无处可藏对池可定期记录CountAll随时间的变化曲线若持续上涨且不复归稳定说明存在未归还或引用残留归还即清零Release回调中统一执行Reset()把对象恢复到出厂状态杜绝状态与引用跨使用者传递。八、延伸阅读设计模式完整讲解含应用场景表与性能对比方法论设计模式 · 对象池其object_pool.hpp展示了 C 索引栈实现与翻倍扩容逻辑C 版基准与文件说明object_poolCC# 语言知识图谱GC 与对象池知识点C#语言反面教材SLG 内存泄漏。小结对象池是手游客户端应对高频创建销毁、控制 GC 抖动的第一选择。GameDevMind 仓库的 C# 示例以不到 60 行代码完整呈现了其核心机制StackT空闲栈的 O(1) 借还、构造时预分配、池空按需扩容、onGet/onRelease回调对接 UnitySetActive。把它嵌入项目只需去掉Program.cs再为每个池化对象补一个彻底的Reset()。记住三个关键动作——借出激活、归还重置、引用清零——你就能在子弹、粒子、飘字等高频场景获得稳定的帧率曲线。【免费下载链接】GameDevMind最全面的游戏开发技术图谱(Game Development Map)。帮助游戏开发者们在已知问题上节省时间省出更多的精力投入到更有创造性的工作中去。项目地址: https://gitcode.com/GitHub_Trending/ga/GameDevMind创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考