对象池
Object Pool
对象池 (Object Pool) 预先一次性分配一组可复用对象,需要时从池里取一个未使用的对象并初始化,用完标记为未使用,而不是逐个 new/delete。它能避免频繁分配的开销和堆碎片,常用于粒子、子弹、正在播放的声音等。代价是池大小要调校、同时活跃的数量有上限、复用的对象不会被自动清零,在有 GC 的环境里还可能让本该回收的对象一直活着。
意图
通过从固定的池中复用对象,而不是逐个分配和释放,来提升性能和内存使用效率。(Game Programming Patterns — object-pool)
机制
定义一个池类,维护一组可复用对象,每个对象支持“是否在用”查询。池初始化时一次性创建全部对象(通常是一次连续分配),并全部设为“未使用”。需要新对象时向池要一个:池找到一个可用对象,初始化为“在用”并返回;不再需要时设回“未使用”。这样对象可以自由地“创建”和“销毁”,而不需要真正分配内存或其他资源。(Game Programming Patterns — object-pool)
动机:碎片化的诅咒。书中的例子是施法时迸出成百个粒子的粒子系统。主机和移动平台编程更接近嵌入式:内存稀缺、玩家要求极其稳定、很少有高效的压缩式内存管理器,因此内存碎片是致命的。碎片化指堆的空闲空间被切成许多小块:总共 14 字节空闲,却被一块在用内存分成两个 7 字节,就分配不了 12 字节的对象(像路边车停得太散,总空间够却停不进去)。即使碎片很少发生,也会逐渐把堆变成一堆无法使用的空洞;主机厂商要求的“浸泡测试 (soak test)”让游戏在演示模式下连跑几天,失败的原因通常就是缓慢积累的碎片或内存泄漏。最简单的办法是开局一次申请大块内存、结束才释放,但需要运行中创建销毁的系统就很难受。对象池两全其美:对内存管理器来说只是开局分配一大块且不释放;对池的使用者来说可以随意“分配”和“释放”。(Game Programming Patterns — object-pool)
书中先给出最简单的实现:粒子用 framesLeft_ > 0 判断是否在用,不另存标志;create() 线性扫描第一个空闲粒子,找不到就不创建。作者说这已经足够发行一款游戏,只是创建是 。
空闲链表 (free list):逐个扫描找空槽在池很大且快满时会很慢。为每个未使用对象另存一个指针列表又要额外的数组。更巧的办法是:未使用的粒子本身的数据没有用处,就用一个 union 让它在“未使用”时存放指向下一个空闲粒子的指针,把空闲对象串成链表。分配时直接取链表头,,而且不额外占内存。为了知道何时归还,animate() 改为在粒子当帧死亡时返回 true,池随即把它挂回链表头,于是创建和删除都是常数时间。(Game Programming Patterns — object-pool)
下图表示空闲链表:池中未使用的槽借用自身内存,串成一条链,分配和归还都只是改链表头。
flowchart LR H["firstAvailable_"] --> F1["槽 3(空闲:next → 槽 7)"] F1 --> F2["槽 7(空闲:next → 槽 1)"] F2 --> F3["槽 1(空闲:next → null)"] U1["槽 0、2、4…(在用:存粒子状态)"]
分配时取走链表头并把它初始化为粒子;粒子死亡时把它重新挂回链表头。
何时使用
需要频繁创建和销毁对象;对象大小相近;在堆上分配很慢或可能造成内存碎片;每个对象封装了获取代价高、可以复用的资源(如数据库或网络连接)。(Game Programming Patterns — object-pool)
取舍与坑(Game Programming Patterns — object-pool)
- 池可能在不需要的对象上浪费内存:池太小,崩溃会提醒你;池太大,也要注意。
- 同时活跃的数量有上限:这也有好处,按对象类型划分内存,大量爆炸不会吃光全部内存、导致关键的新敌人生成不出来。池满时的策略:
- 彻底防止:调校池大小,保证永不溢出,适合敌人、关键道具(关卡末尾的 Boss 生成不出来时没有“正确”的处理方式);可以按场景用不同的池大小。
- 干脆不创建:粒子全在用时屏幕上已经很热闹了,少一个爆炸没人注意。
- 强制杀掉一个现有对象:声音池满时,把正在播放的最小声的声音换成新声音,新声音会掩盖旧声音被切断。原则是:现有对象的消失不如新对象的缺席明显时就这样做。
- 扩大池:在运行时扩容,或者另建溢出池;想一想以后要不要缩回去。
- 每个对象的内存大小固定:池里放不同大小的子类时,每个槽都要按最大对象分配,否则会踩坏相邻内存;大小差异大时浪费内存(像过安检时用装行李的大托盘只放钥匙和钱包),可以按尺寸拆成多个池;这也是很多高速内存管理器的实现方式,按块大小维护多个池。
- 复用的对象不会被自动清空:内存管理器的调试功能(例如用
0xdeadbeef填充)帮不上忙了,而槽里的残留数据来自同类型的上一个对象,几乎看不出漏初始化。初始化必须完整,必要时加一个回收时清空槽的调试功能。 - 未使用的对象仍留在内存里:有 GC 的系统通常会替你处理碎片,所以池较少见;但在 CPU 较慢、GC 较简单的移动设备上,池仍可用来避免分配与回收开销。与 GC 一起用时,池中对象如果还引用着其他对象,会让 GC 无法回收那些对象。对象不用时要清空它持有的引用。
设计决策(Game Programming Patterns — object-pool)
| 决策 | 选项 | 要点 |
|---|---|---|
| 对象是否与池耦合 | 耦合 | 实现简单(对象自己带“在用”标志);可以让池成为友元、构造函数私有,保证只能由池创建;也许可以用已有状态判断是否存活(例如粒子已飞出屏幕),省掉标志 |
| 不耦合 | 可以写通用池类;“在用”状态要在外部记录(如独立的位域) | |
| 谁负责初始化复用对象 | 池内部 | 可以完全封装对象,防止外部持有可能被复用的引用;但池接口要转发对象所有的初始化方式 |
| 外部代码 | 池接口简单,只返回对象引用;外部要处理池满返回 NULL 的情况 |
与其他模式的关系
- 和 享元模式 都维护一组可复用对象,但“复用”的含义不同:享元是同一时刻被多个拥有者共享,对象池是原拥有者用完之后随时间先后复用。(Game Programming Patterns — object-pool)
- 同类型对象紧密排在一起,迭代时有利于缓存,见 数据局部性。(Game Programming Patterns — object-pool)
代码草图(C++)
按书中空闲链表版本整理(init() 的函数体书中沿用前面简单版本,此处为把字段放进 union 后的对应写法):
class Particle {
public:
Particle() : framesLeft_(0) {} // 默认“未使用”
void init(double x, double y, double xVel, double yVel, int lifetime) {
state_.live.x = x; state_.live.y = y;
state_.live.xVel = xVel; state_.live.yVel = yVel;
framesLeft_ = lifetime;
}
bool animate() { // 当帧死亡时返回 true
if (!inUse()) return false;
framesLeft_--;
state_.live.x += state_.live.xVel;
state_.live.y += state_.live.yVel;
return framesLeft_ == 0;
}
bool inUse() const { return framesLeft_ > 0; } // 用寿命判断,不另存标志
Particle* getNext() const { return state_.next; }
void setNext(Particle* next) { state_.next = next; }
private:
int framesLeft_;
union {
struct { double x, y; double xVel, yVel; } live; // 在用时的状态
Particle* next; // 空闲时:下一个空闲粒子
} state_;
};
class ParticlePool {
public:
ParticlePool();
void create(double x, double y, double xVel, double yVel, int lifetime);
void animate();
private:
static const int POOL_SIZE = 100;
Particle particles_[POOL_SIZE];
Particle* firstAvailable_;
};
ParticlePool::ParticlePool() {
firstAvailable_ = &particles_[0]; // 一开始全部可用
for (int i = 0; i < POOL_SIZE - 1; i++)
particles_[i].setNext(&particles_[i + 1]); // 每个指向下一个
particles_[POOL_SIZE - 1].setNext(NULL); // 最后一个结束链表
}
void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) {
assert(firstAvailable_ != NULL); // 确认池未满
Particle* newParticle = firstAvailable_; // 从空闲链表取下
firstAvailable_ = newParticle->getNext();
newParticle->init(x, y, xVel, yVel, lifetime);
}
void ParticlePool::animate() {
for (int i = 0; i < POOL_SIZE; i++) {
if (particles_[i].animate()) { // 刚死亡:挂回链表头
particles_[i].setNext(firstAvailable_);
firstAvailable_ = &particles_[i];
}
}
}
UE 与理论页中的对应
- 规避 GC 卡顿 → 垃圾回收与对象生命周期:可达性分析默认要在一帧内完成,对象越多卡顿越明显;该页列出的传统规避手段就包括对象池、严格的 UObject 预算和游戏中禁用 GC(UE 后来又加入了增量可达性分析)。这正好对应“有 GC 的环境里,池仍可用来避免分配和回收开销”。
- 复用的对象要手动清理 → Gameplay 能力系统:Instanced per Actor 的能力只在首次使用时创建、之后复用,该页提醒“每次执行之间要手动清理成员变量”,就是本模式“复用的对象不会被自动清空”的坑。
- 声音池满时挤掉低优先级声音 → 音频系统与 MetaSounds:Sound Class 的 Always Play 选项可以防止该类声音被优先级更低的声音挤出语音池 (voice pool),对应“强制杀掉一个现有对象”的溢出策略。
- 内存池的通用取舍 → 多线程渲染与任务系统:该页指出,启动时为同尺寸对象分配大池并自行管理分配释放,连续记录更缓存一致;但在 C#、Java 等有垃圾回收的语言中,池可能更慢。
出处
- Game Programming Patterns — object-pool
- Game Programming Patterns — object-pool(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) object-pool 章,gameprogrammingpatterns.com/object-pool.html