20 KiB
高性能对象池系统设计文档
1. 设计目标
本文设计一个面向 Unity 大型项目的对象池系统,目标不是“能用”,而是同时满足以下要求:
- 高性能:热点路径稳定 O(1) 或近似 O(1),避免全表扫描进入业务帧。
- 低内存:控制元数据膨胀,支持容量上限、分层配额、预热与回收策略。
- 0GC:运行时热点路径不产生托管垃圾,调试和统计默认不侵入业务路径。
- 高扩展:支持 GameObject、纯 C# 对象、句柄对象、资源实例、复合对象、分片对象。
- 易调试:运行时诊断与编辑器面板分离,支持快照、采样、事件流和错误定位。
- 适合大型项目:支持多域、多场景、多模块、多团队协作,不依赖单一对象模型。
- 支持复杂池子:支持多 Key、多状态、多生命周期、多租户、异步预热、延迟销毁。
这套系统默认服务“小众但大型”的 Unity 项目,即:
- 代码量大,模块边界复杂。
- 有长期演进需求,不能接受一次性写死。
- 对线上性能和可观测性要求高。
- 池子类型多,不只是子弹和特效。
2. 非目标
以下能力不作为第一阶段核心目标:
- 透明跨线程直接复用 UnityEngine.Object。
- 自动把任意未池化代码转换成池化逻辑。
- 用一套 API 完全覆盖所有业务差异。
- 在所有对象类型上强行追求完全一致的内部实现。
原因很简单:对象池的统一接口可以统一,但底层策略不能假装一样。GameObject、原生资源句柄、纯托管对象、本地缓存块,本质差异很大,强行统一通常会换来性能和可维护性同时变差。
3. 总体架构
系统分为 6 层:
PoolServicePoolDomainPoolRegistryPoolCore<TItem, TKey>PoolPolicyPoolDiagnostics
职责如下:
PoolService:全局入口,负责生命周期、配置装载、域管理、更新调度。PoolDomain:逻辑域隔离。比如UI、FX、Gameplay、Net、Addressable各自独立。PoolRegistry:注册池定义、类型工厂、键类型、扩展策略。PoolCore<TItem, TKey>:真正的池实现,处理获取、归还、预热、扩容、淘汰。PoolPolicy:定义创建、重置、校验、回收、容量、淘汰和调试采样策略。PoolDiagnostics:只读观测层,负责统计快照、事件记录、调试视图和导出。
核心原则:
- 业务代码不直接碰底层容器。
- 池核心不直接依赖编辑器。
- 统计系统不阻塞业务路径。
- 高级功能通过策略和扩展挂上去,而不是污染主流程。
4. 模块拆分
建议目录:
Runtime/
Pool/
Core/
Handle/
Policy/
Registry/
Domain/
Diagnostics/
Adapters/
Unsafe/
Editor/
Pool/
Window/
Inspector/
Profiler/
Tests/
Runtime/
Editor/
4.1 Core
核心只保留高频逻辑:
RentReturnTryRentPrewarmTrimShutdown
4.2 Policy
策略层负责差异化:
- 创建策略
- 重置策略
- 释放策略
- 容量策略
- 淘汰策略
- 健康检查策略
- 调试命名策略
4.3 Diagnostics
诊断层只订阅事件,不进入关键路径的复杂计算。
4.4 Adapters
适配层处理:
GameObjectComponentScriptableObjectNativeArray包装Addressables实例- 自定义资源句柄
5. 核心对象模型
我不会让业务对象强制继承一个 ObjectBase。继承式对象池扩展性很差,会把“池系统”反向侵入所有业务对象。
我会采用“组合 + 句柄”模型。
5.1 核心接口
public interface IPool
{
PoolId Id { get; }
PoolRuntimeInfo RuntimeInfo { get; }
}
public interface IPool<T> : IPool
{
PoolHandle<T> Rent();
bool TryRent(out PoolHandle<T> handle);
void Return(in PoolHandle<T> handle);
void Prewarm(int count);
void Trim(int count);
}
public interface IPoolFactory<T>
{
T Create();
void Destroy(T item);
}
public interface IPoolLifecycle<T>
{
void OnCreate(T item);
void OnRent(T item);
void OnReturn(T item);
void OnReset(T item);
bool CanRecycle(T item);
}
5.2 句柄设计
PoolHandle<T> 是值类型句柄,不直接暴露池内部节点引用:
public readonly struct PoolHandle<T>
{
public readonly T Value;
internal readonly int SlotIndex;
internal readonly ushort Version;
internal readonly PoolId PoolId;
}
作用:
- 防止重复归还。
- 防止把 A 池的对象还到 B 池。
- 支持失效检测。
- 允许调试系统用句柄定位历史事件。
这个设计比“直接传对象归还”更适合大型项目,因为大型项目里误还、重还、跨池还、跨域还都很常见。
6. 数据结构设计
6.1 主存储结构
每个池维护以下固定结构:
Slot[] _slotsint _freeHeadint _activeCountint _inactiveCountFastQueue<int> _inactiveQueueFastMap<TKey, Bucket>或Dictionary<TKey, Bucket>BitSet _inUseBitsBitSet _dirtyBits
其中 Slot 包含:
internal struct Slot<T>
{
public T Item;
public int NextFree;
public int NextInBucket;
public int PrevInBucket;
public int StateFlags;
public int LastRentTick;
public int LastReturnTick;
public ushort Version;
public int RentCount;
public int OwnerThreadId;
}
设计意图:
- 不用链表节点对象,避免额外分配和引用跳转。
- 尽量数组化,提升缓存命中率。
- 用索引串联空闲链和桶链,避免托管容器层层嵌套。
6.2 Why not LinkedList / MultiDictionary
我不会把“按名字多值管理”建立在 LinkedListNode<T> 和泛型多值字典上,原因是:
- 节点对象本身有额外内存占用。
- 指针跳转多,缓存局部性差。
- 按名字查桶后仍可能线性扫完整桶。
- 热点路径结构太泛化,不够专门化。
更好的做法是:
- 预先把每个对象映射到
slotIndex。 - 每个 Key 有自己的空闲链和活跃链。
- 对“可租用集合”和“所有对象集合”分开维护。
6.3 双索引模型
对于复杂项目,我会同时维护两种索引:
Item -> SlotIndex:归还、校验、调试定位。Key -> AvailableHead:按键快速获取可用对象。
这样 Rent(key) 不需要扫描整个桶,只需要从该 Key 的可用链表头部直接拿一个元素。
这比“遍历名字桶,找到第一个没在用的对象”要稳定得多。
7. 核心状态机
单个池内对象状态定义:
UninitializedAvailableReservedInUseReturningDisposed
7.1 状态迁移
正常路径:
- Create -> Available
- Available -> Reserved
- Reserved -> InUse
- InUse -> Returning
- Returning -> Available
- Available -> Disposed
异常保护:
- 已归还句柄再次归还,直接拒绝。
- 版本不匹配,直接判定句柄失效。
- 正在释放或已销毁对象,拒绝租用。
Reserved 这个状态不是多余的,它是给以下场景准备的:
- 异步加载后的延迟激活
- 跨系统交接
- 带初始化流水线的对象
- 需要分两阶段构建的复杂实例
8. 0GC 设计原则
0GC 不是口号,必须明确哪些路径严格零分配。
8.1 严格 0GC 的路径
RentTryRentReturnFast Validate按 Key 查询可用数量池内状态位更新
8.2 允许分配的路径
- 首次创建池
- 扩容
- 预热
- 编辑器快照
- 导出 CSV / JSON
- 显式调试采样
8.3 实现要求
- 不在热点路径创建委托、闭包、枚举器类。
- 不返回
IEnumerable<T>给业务热路径。 - 不在热点路径 new
List<>。 - 不依赖反射创建对象。
- 不在热点路径拼接字符串。
- 不在运行时默认收集完整调用栈。
8.4 时间戳设计
我不会用 DateTime.UtcNow 记录热点使用时间。
我会统一使用:
int tickuint frameIndexdouble realtimeSinceStartup
其中热路径记录 frameIndex 或逻辑 tick,只有调试导出时再转换成人类可读时间。
原因:
- 更轻
- 更稳定
- 更容易做排序和过期比较
- 更适合回放与录制
9. 获取与归还算法
9.1 Rent
目标复杂度:
Rent():O(1)Rent(key):O(1)
基本步骤:
- 从
key available head拿slotIndex - 校验
slot状态和版本 - 从可用链移除
- 标记
InUse - 更新计数、tick、统计
- 调用
OnRent - 返回值类型句柄
9.2 Return
目标复杂度:
Return(handle):O(1)
基本步骤:
- 校验池 ID
- 校验版本号
- 校验状态必须为
InUse - 调用
CanRecycle - 调用
OnReturn - 调用
OnReset - 放回
key available chain - 更新统计
9.3 扩容
扩容不是每次 +1,而是分级策略:
- 小池:2 倍扩容
- 中池:1.5 倍扩容
- 大池:固定块增长
原因:
- 小池要减少扩容次数
- 大池要控制峰值浪费
10. 容量与回收策略
大型项目不能只有一个 Capacity。
我会支持以下层级:
MinCapacitySoftCapacityHardCapacityPrewarmCapacityPerKeySoftCapacityPerDomainBudget
10.1 容量语义
MinCapacity:至少保留,不主动裁剪。SoftCapacity:超过后进入延迟裁剪候选。HardCapacity:超过后禁止继续扩容,走降级策略。PerDomainBudget:整个域共享内存预算,防止单池失控。
10.2 回收策略
不做“每次超容立刻全量扫描”,而是做三段式:
- 标记超容
- 放入裁剪队列
- 在预算帧内分批处理
这很关键。大型项目最怕把维护性工作同步砸进战斗帧。
10.3 裁剪候选组织
每个池维护一个“可裁剪最小堆”或分层队列,排序因子可配置:
- 最久未使用
- 最低优先级
- 冷热分层
- Key 局部配额
- 是否锁定
这样 Trim 可以渐进式处理,而不是每次遍历全池。
11. 多 Key / 复杂池支持
一个现代对象池不能只支持“按 name 拿对象”。
我会支持以下 Key 模型:
PoolKey.NoneintFixedString64- 复合 Key
- 结构化 Key
例如:
public struct FxPoolKey
{
public int FxId;
public byte QualityLevel;
public byte Theme;
}
应用场景:
- 同一特效不同质量等级
- 同一 UI Cell 不同模板
- 同一角色不同皮肤部件
- 同一网络包对象不同协议版本
11.1 多态池
支持一个池管理多个派生类型,但不是默认开启。
理由:
- 多态池灵活,但查找和重置逻辑更复杂。
- 默认建议一池一类型,特殊场景再用联合池。
联合池通过注册表完成:
BaseTypePool- 子类型工厂映射
- 子类型生命周期映射
12. 特殊类型池设计
12.1 GameObject 池
特点:
- 需要处理激活状态
- 需要处理父节点
- 需要处理场景归属
- 需要处理组件重置成本
建议:
- 池内对象挂
PooledInstanceTag - 禁止业务直接
Destroy - 使用
PoolHandle<GameObject> OnReset里只做必要重置,不做全组件反射扫描
12.2 Component 池
对外返回 TComponent,内部仍以宿主实例为核心管理,避免组件和对象分离失控。
12.3 纯托管对象池
适合:
- 临时上下文
- 行为树节点运行数据
- 网络解析缓存对象
- UI 虚拟列表单元数据容器
优先使用泛型池,不依赖 Unity 生命周期。
12.4 集合池
单独提供高频集合池:
ListPool<T>DictionaryPool<TKey, TValue>HashSetPool<T>StringBuilderPool
但集合池必须分离于主对象池系统,不要共用复杂的池元数据。
原因:
- 集合池需求简单
- 热点极多
- 追求极低开销
12.5 原生内存包装池
支持包装:
NativeArrayNativeListUnsafeList
但要求:
- 主线程与 Job 边界清晰
- 回收前必须完成 Job 依赖
- 诊断系统记录 owner 和 frame fence
12.6 异步资源实例池
例如 Addressables:
Create可能异步Return后不能立刻销毁- 实例可能依赖引用计数
对此引入 AsyncPoolAdapter<T>:
- 支持
ReserveAsync - 支持加载中状态
- 支持失败回滚
- 支持 warmup pipeline
13. 扩展点设计
13.1 Policy 驱动
每个池通过配置组合:
FactoryLifecycleCapacityPolicyTrimPolicyValidationPolicyDebugNameProvider
13.2 Feature Flag
不是所有池都启用所有能力。
例如按位配置:
TrackStackTraceTrackOwnerEnableDoubleReturnCheckEnableLeakDetectionEnablePerItemMetricsEnableAsyncReserve
低端池只开基础能力,高风险池再开强诊断。
13.3 自定义验证器
public interface IPoolValidator<T>
{
PoolValidationResult ValidateOnRent(T item);
PoolValidationResult ValidateOnReturn(T item);
}
用来做:
- GameObject 是否被外部销毁
- 资源引用是否断裂
- Component 缓存是否失效
- Native 容器是否已释放
14. 调试与观测设计
调试要“非常轻松方便”,但不能靠在 Inspector 每帧遍历整个池。
我的做法是“事件流 + 快照缓存 + 编辑器订阅”。
14.1 运行时观测层
运行时只维护固定大小统计:
- 总租用次数
- 总归还次数
- 当前活跃数
- 当前空闲数
- 峰值活跃数
- 创建次数
- 销毁次数
- 扩容次数
- 裁剪次数
- 失败租用次数
这些都存在 struct 里,常量开销更新。
14.2 环形事件缓冲
仅在开启诊断时记录事件到无锁环形缓冲:
- Rent
- Return
- Grow
- Trim
- LeakWarning
- InvalidReturn
- DestroyOutsidePool
每条事件固定大小,不分配字符串正文,只记录:
PoolIdSlotIndexFrameEventTypeArg0Arg1
UI 读取时再解码。
14.3 快照机制
编辑器面板不直接扫运行时容器,而是请求一个“快照构建器”:
- 默认每 500ms 构建一次
- 只在窗口可见时工作
- 可按域、池名、类型过滤
- 可显示 Top N 热池
快照属于调试层分配,不污染业务路径。
14.4 泄漏检测
泄漏检测分等级:
- Level 0:只统计未归还数量
- Level 1:记录最近 owner id
- Level 2:记录采样调用栈
调用栈采样不能默认全开,只能:
- 仅编辑器
- 仅开发构建
- 仅采样部分对象
- 仅指定池启用
14.5 Debug 面板能力
我会做以下视图:
- 域总览
- 池列表
- 单池详情
- Key 分布
- 活跃对象列表
- 最近错误事件
- 租用热点排行
- 裁剪压力排行
- 扩容时间线
- 泄漏嫌疑列表
并支持:
- 搜索
- 排序
- 冻结快照
- 导出报告
- 点击对象定位场景实例
15. 线程模型
Unity 项目里线程问题不能模糊。
15.1 主线程池
默认所有 Unity 对象池都归主线程管理。
15.2 Worker 池
纯托管对象池可选线程安全版本。
线程安全不是给所有池统一加锁,而是分实现:
MainThreadPool<T>ConcurrentPool<T>
原因:
- 主线程池要极致轻量
- 并发池要接受额外同步成本
15.3 Job 边界
如果池对象会跨 Job 使用,必须通过显式借用协议:
- 主线程
Rent - 写入 job token
- job 完成后
Return
不允许在 Job 内直接操作托管池。
16. 错误防护
大型项目里最值钱的是“错误早点炸,而且能查到是谁干的”。
系统必须捕获:
- 双重归还
- 跨池归还
- 归还已失效句柄
- 外部销毁池对象
- 未初始化对象进入可用链
- 锁定对象被裁剪
- 异步加载失败后错误入池
16.1 处理策略
不同构建等级不同处理:
Editor/Dev:强校验 + 报错 + 事件记录Release:快速失败 + 计数器上报 + 可选降级
16.2 降级策略
当池进入异常状态,可选:
- 拒绝继续租用
- 回退到直创建直销毁
- 标记池熔断
- 上报监控并隔离该域
17. 配置系统
大型项目不能全靠代码硬编码。
建议配置对象:
PoolDomainConfigPoolDefinitionAssetPoolRuntimeOverride
配置字段包括:
- PoolId
- 类型
- Key 类型
- 预热数量
- 容量阈值
- 扩容策略
- 裁剪策略
- 调试等级
- 泄漏检测等级
- 场景切换策略
- 低内存回调策略
17.1 配置分层
- 默认配置
- 平台配置
- 场景配置
- 运行时覆盖
例如移动端和 PC 不应该共享完全相同的池容量。
18. 生命周期与场景切换
对象池需要明确域生命周期:
GlobalSceneMatchFeatureTemporary
18.1 场景池
进入场景时预热,退出场景时可批量回收或转移。
18.2 Global 池
如 UI 和通用 FX,不随场景直接销毁,但需要做场景引用清洗。
18.3 Temporary 池
例如一次性事件活动模块,生命周期结束直接销毁整个域。
19. 性能预算建议
建议在设计时就定义预算:
Rent/Return平均 < 0.5usTrim支持分帧预算,如每帧最多处理 16 或 32 个候选- Debug 关闭时每池统计更新为常量成本
- 快照构建不进入游戏主热帧
19.1 大池优化建议
当池数量 > 100 或对象总量 > 100000 时:
- 禁止全池排序式裁剪
- 禁止调试全量对象枚举默认开启
- 使用域级调度队列
- 使用 Top-K 热点缓存代替实时全排序
20. 测试策略
必须覆盖:
- 单线程正确性
- 高频 Rent/Return 压测
- 大容量扩缩容
- Key 分桶正确性
- 双重归还检测
- 无效句柄检测
- 异步加载失败回滚
- 场景切换清理
- 泄漏检测
- 调试快照一致性
20.1 Benchmark
基准要对比:
- 当前实现
- 新实现
- Unity 内置
ObjectPool<T> - 直创建直销毁
指标:
- CPU
- GC Alloc
- Peak Memory
- P99 延迟
- Trim 尖峰
21. 推荐的最小落地版本
如果让我先做第一版,不会一次塞满所有高级功能,而是按下面顺序:
Phase 1
MainThreadPool<T>- 值类型句柄
- 固定数组槽位
- O(1) Rent/Return
- 基础预热和扩容
- 基础统计
- Dev 下双重归还检测
Phase 2
- 多 Key 支持
- 分帧 Trim
- 域与预算
- GameObject / Component 适配器
- 编辑器快照面板
Phase 3
- 异步资源实例池
- 泄漏采样
- Native 包装池
- 并发池
- 报表导出和线上遥测
22. 我会避免的设计
以下方案我不会采用:
- 强制所有池对象继承统一基类
- 用字符串当唯一主 Key 贯穿所有热路径
- 超容后立刻全表扫描清理
- 每帧在 Inspector 里直接遍历完整池状态
- 在热路径用
DateTime.UtcNow - 在热路径构造
List<>、ToArray()、LINQ - 为了“统一”而让所有池都加线程锁
- 调试信息和业务数据共用同一套重型结构
23. 总结
如果要同时做到高性能、低内存、0GC、高扩展、易调试,我的核心思路是:
- 用数组槽位和索引链代替托管链表对象。
- 用句柄和版本号解决大型项目里的误用问题。
- 用多索引结构保证
Rent/Return真正稳定。 - 用分帧预算和候选队列替代同步全表释放。
- 让调试成为旁路系统,而不是主路径负担。
- 不强迫所有类型共用一种对象模型,而是统一接口、分离适配器。
这套设计的关键不是“对象池”,而是“把对象池当成一套运行时基础设施”来做。只有这样,它才扛得住大型项目、复杂类型、长期演进和多人协作。
24. 后续实现建议
如果继续往下落地,我建议按这个顺序开发:
- 先实现纯 C# 泛型池核心,完成句柄、槽位、版本、O(1) 可用链。
- 再实现 GameObject 适配器,不要一开始就把 Unity 特性揉进核心。
- 接着做域、预算和分帧裁剪,否则大项目上线后一定出现维护尖峰。
- 最后做调试窗口和快照系统,确保调试能力强但不反噬运行时。
如果你愿意,我下一步可以直接基于这份文档,在 Packages/com.alicizax.unity.framework/Runtime/ABase/ObjectPool 旁边给你起一版可落地的代码骨架。