19. Object Pool
19. Object Pool:预分配固定槽位并复用短命对象,控制碎片和运行时分配,通过可复位因果实验和反例证据验收。
19. Object Pool
学习目标
- 能画出池的取还流程
- 能解释空闲列表的O(1)
- 能制定复位契约与容量策略
为什么"19. Object Pool"从问题证据开始
你的游戏要高频生成短命对象:弹幕、粒子、敌人尸体。最直觉的做法是"需要就 new,用完就 delete"。代码今天能跑,但两个问题浮现:内存碎片(频繁 new/delete 让堆变得坑坑洼洼,大对象分配变慢)、GC/分配抖动(有垃圾回收的语言,频繁分配触发 GC 暂停——游戏卡顿的来源)。
本页把"无模式基线"定义为"高频 new/delete 短命对象"的代码,然后引入对象池作为候选机制,验证它是否真的让"分配与释放的开销消失"。通过条件是对象创建不触发堆分配、销毁不触发堆释放——而是从池里取、用、还。
🔮 猜一猜:并发子弹数超过池容量时,acquire 会怎样?
来源、版本与独立重写边界
本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。
本章机制与术语
理解 Object Pool 需要四个核心概念:
- ↡:预分配固定数量的对象槽位,运行时只做"取-还"——不碰堆。
- acquire/release:从池中取空闲对象(acquire)与归还复位(release)——替代 new/delete。
- ↡:池内"空闲槽"的链表/栈——O(1) 取还的关键结构。
- ↡:高频 new/delete 导致堆内存零散——池化消灭它。
四个概念共同约束"预分配固定槽位并复用短命对象,控制碎片和运行时分配":任何结论都必须回到"运行时是否零分配、槽位是否够用、复用是否正确复位"三个可观察量。
Object Pool — 预分配,别现造
第 1 / 4 步 · ① 对象池:预分配固定槽位(弹幕池)
池子预分配好,运行时只做取-还:高频对象永远不碰堆分配。
💡 对着动画看:动画上方是 BulletPool——预分配 6 个槽(白色=空闲,红色=使用中),下方"发射:acquire()"与"爆炸:release()"两个动作框。注意 acquire 取的是空闲槽(不是新内存),release 做的是复位(不是释放)。读"自由列表"一节时,想象池里 6 个槽被反复取还——同一块内存服务了成千上万颗子弹。
官方结构逐项深读
19. Object Pool
Object Pool 的主张:预分配一批对象,运行时只做"取用-归还",彻底避开堆分配。池在启动时一次建好 N 个槽位;运行时需要对象就 acquire(标记槽位在用,返回它),用完 release(复位状态,标记空闲)。没有任何 new/delete 发生在运行期——分配与碎片化同时消失。
Intent
意图:把"创建-销毁"从运行期挪到启动期。短命对象(子弹、粒子)的生命周期很短、频率很高——每次 new/delete 都要碰堆(慢、碎片化、GC 压力)。对象池让"创建"变成"查空闲槽"(O(1))、"销毁"变成"复位标记"(O(1))——运行期零堆操作。代价是固定容量(池多大就有多少对象同时存在)。
Motivation
弹幕游戏:一帧可能发射 50 颗子弹,每颗存活几秒。用 new/delete:每帧 50 次分配 + 50 次释放,堆内存反复震荡。C++ 无 GC 会碎片化(大块可用内存被小洞割碎);C#/Java 有 GC 会周期暂停(游戏卡顿)。对象池把 50 次 new 换成 50 次"取槽",50 次 delete 换成 50 次"复位"——堆完全不被碰。
The curse of fragmentation
碎片化的诅咒:堆分配/释放的顺序不规则时,空闲内存被切成许多小碎片——想分配一个大对象时,总内存够但没有连续的一块。碎片化让"分配变慢"(要扫更多空闲块)甚至失败。游戏里碎片化尤其致命:堆使用量大、分配频率高、要求低延迟。对象池的固定槽位从根上避免碎片——对象从不单独在堆上移动。
The best of both worlds
对象池的"两全其美":像 new 一样灵活(任何时刻都可以创建对象——只要池有空槽)又像静态数组一样稳定(内存固定、无碎片、无 GC 压力)。它介于"动态分配"(灵活但贵)与"静态数组"(稳定但死板)之间——预分配 + 运行时复用,既是动态的又是稳定的。
The Pattern
模式结构:对象池类(vector<Bullet> 固定数组 + 空闲索引结构)→ acquire()(找个空闲槽,标记在用,返回引用/指针)→ release(obj)(复位对象状态,标记空闲)。关键:池容量固定,并发活跃对象数 ≤ 池大小——超出则 acquire 失败(策略:返回 null、覆盖最旧、或扩容)。所有对象在池启动时构造一次,之后永不构造/析构。
When to Use It
何时用对象池:对象短命且高频(子弹、粒子、连接、UI 弹窗)、分配成本可测量(profile 显示分配是热点)、并发对象数有上限(池容量可估)。何时不用:对象长寿(池子白占内存)、并发数波动巨大(池不够用频繁 acquire 失败)、对象超大(池内存成本过高)。判断标准:"创建频率 × 生命周期"是否让堆震荡——是,用池。
Keep in Mind
四个注意点:池可能浪费内存(预分配的对象大多时候空闲);同时活跃数有上限(超出的 acquire 要处理);对象大小固定(池是同一类型的槽位数组,不能装不同类型);复用不自动清理(release 必须复位状态,否则"脏"对象残留);未用对象留在内存(池不释放闲置槽位)。
The pool may waste memory on unneeded objects
池的内存浪费:预分配 1000 颗子弹,但实际同时活跃的通常只有 50 颗——950 个槽位闲着。池大小是"峰值需求"不是"平均值":按峰值定容量保证不失败,但平时大量闲置。权衡:池小了 acquire 失败(体验问题),池大了内存浪费(资源问题)——按"峰值 × 1.2 安全余量"定。
Only a fixed number of objects can be active at any one time
容量上限:池同时活跃对象数固定——超过就 acquire 失败。处理策略:返回 null/空引用(调用方判空,如"没槽就不发射")、拒绝本次创建(最直接,但可能丢需求)、回收最旧对象(把活跃最久的强制 release 给新的用——破坏"旧对象还活着"的语义)。按游戏规则选:弹幕池通常"没槽就发射失败",最简单。
Memory size for each object is fixed
对象大小固定:池是同类型槽位数组——每槽大小相同、类型相同。不能一个池混装子弹和粒子(除非用变体/联合)。这限制了池的复用(每种对象要自己的池),也带来好处(同构数组 = 数据局部性好,与 Data Locality 契合)。多类型高频对象 = 多个池。
Reused objects aren't automatically cleared
复用不自动清理:release 只"标记空闲",不会自动清除对象内容。如果 acquire 时直接返回旧对象,旧状态(上一颗子弹的位置、伤害)还在——必须 acquire 时初始化(设默认值)或 release 时复位(清状态)。作者建议:release 时复位更安全(对象在池里就是干净的),acquire 只标记在用。
Unused objects will remain in memory
闲置对象留在内存:池对象创建后永不销毁——即使游戏不需要它们了,内存也不回收(除非池本身销毁)。这对"资源随场景增减"的场景是问题(进菜单了,战斗弹幕池还占着内存)。处理:按场景重建池(进入战斗建池、离开销毁)——池的大小随场景峰值调整,而非全局固定。
Sample Code
示例代码:ParticlePool——Particle particles[POOL_SIZE] + bool inUse[POOL_SIZE] + 空闲列表。acquire() 从空闲列表弹一个索引,标记在用,返回 particles[i];release() 复位粒子、把索引压回空闲列表。遍历更新时 for (i : inUse) if (inUse[i]) particles[i].update(dt)。核心是"索引管理"而非"指针管理"——数组 + 索引天然连续。
A free list
自由列表:空闲槽的链表/栈——acquire() 弹栈顶(O(1))、release() 压栈(O(1))。比"遍历找空闲"(O(N))高效得多。实现:空闲索引数组 + 栈顶指针,或"对象内嵌 next 指针"(空闲对象自身串成链,见 Design Decisions)。自由列表是池的 O(1) 取还的关键——池的性能就靠它。
Design Decisions
两个关键设计决策:对象与池是否耦合(对象知道自己在池里 vs 完全不知)和 谁负责初始化复用对象(acquire 时 vs release 时)。前者决定对象能否独立存在,后者决定复用的干净程度——下面两节展开。
Are objects coupled to the pool?
耦合方式:对象不知道池(对象是普通类,池是外部容器管理它的槽位——对象可独立使用,但池要知道"如何复位对象")与对象知道池(对象持 release() 方法内部调池——对象自己"还自己",方便但对象脱离池无法存在)。作者倾向:对象不知道池(用外部 release)——对象保持普通类,可复用可测试;池只管槽位与复位。内嵌指针(next)属于实现细节,不算业务耦合。
What is responsible for initializing the reused objects?
初始化时机:acquire 时初始化(取出来就设默认值——新对象视角,直观,但每次 acquire 都要写初始化逻辑)与release 时复位(还回来就清状态——对象在池里始终干净,acquire 只管标记)。作者建议:release 时复位更优——状态清理集中在"归还"一处,acquire 简单;且"释放后对象干净"让错误使用旧状态的可能性降低。
See Also
- Data Locality:池的固定数组天然连续——与数据局部性契合,遍历池对象缓存友好。
- Flyweight:享元省"重复内容",对象池省"重复分配"——一个去重、一个复用,互补。
- Command/Event Queue:池化命令/事件对象——高频创建的队列元素也可以从池取。
可迁移实现或计算骨架
initial state -> 预分配 N 槽位 -> acquire 取空闲槽(O(1))-> 使用 -> release 复位还槽(O(1))
fault injection -> 高频 new/delete 短命对象
pass condition -> 运行期零堆分配、复用对象复位干净、并发活跃数 ≤ 池容量
reset -> initial state对应实现要点:固定数组 + 空闲列表(栈)实现 O(1) 取还;release 时复位状态;acquire 失败策略明确(拒绝/覆盖);按场景峰值定池大小。该骨架只保存实验合同;真实项目还要固定池容量、并发上限与复位契约,并保留基线实现以便回退。
本章练习与节点验证矩阵
练习
问题 1:分配对比。
操作:对比 new/delete 与对象池各发射一万颗子弹的耗时
问题 2:容量上限。
操作:让并发子弹数超过池容量,观察 acquire 失败行为
问题 3:复位验证。
操作:release 后立即 acquire,检查对象状态是否残留旧值
问题 4:空闲列表。
操作:实现"遍历找空闲"与"自由列表"两种 acquire,对比复杂度
术语复核
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 对象池
- 空闲列表
- 碎片化
练习答案参考
练习判据即答案:每个练习的"验证判据"列给出了通过标准——先自己动手,再对照判据核验。
术语复核与本章回顾
掌握"19. Object Pool"意味着能从"高频 new/delete 短命对象导致碎片与 GC 抖动"出发,解释对象池、acquire/release、空闲列表与碎片化四者的关系,再用"运行时是否零分配、槽位是否够用、复用是否正确复位"三个可观察量推翻或保留实现。若三个判据不能同时满足,本章仍未通过。
一句话回顾:预分配一批槽,运行时只做取-还——子弹、粒子这些短命客,从池里借、用完还,堆上从来不留痕迹,碎片与 GC 一起消失。
阅读导航
← 上一页:18. Dirty Flag · 下一页:20. Spatial Partition → ← 上一页:18. Dirty Flag · 下一页:20. Spatial Partition →