中EN
概念页

对象池

Object Pool

更新于 2026-10-11 约 11 分钟设计模式对象池内存管理碎片化粒子GPP

对象池 (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() 线性扫描第一个空闲粒子,找不到就不创建。作者说这已经足够发行一款游戏,只是创建是 O(n)O(n)。

空闲链表 (free list):逐个扫描找空槽在池很大且快满时会很慢。为每个未使用对象另存一个指针列表又要额外的数组。更巧的办法是:未使用的粒子本身的数据没有用处,就用一个 union 让它在“未使用”时存放指向下一个空闲粒子的指针,把空闲对象串成链表。分配时直接取链表头,O(1)O(1),而且不额外占内存。为了知道何时归还,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 的情况

与其他模式的关系

代码草图(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 等有垃圾回收的语言中,池可能更慢。
  1. Game Programming Patterns — object-pool
  2. Game Programming Patterns — object-pool(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) object-pool 章,gameprogrammingpatterns.com/object-pool.html

相关页面

游戏开发享元模式Flyweight Pattern享元模式 (Flyweight Pattern) 把对象的数据拆成可共享的“内在状态”和每个实例独有的“外在状态”,让大量对象共用一份内在状态。森林里成千上万棵树共用一份网格和纹理,以及瓦片地图中每格只存一个指向共享地形对象的指针,都是它的典型应用。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发 · UE5垃圾回收与对象生命周期Garbage Collection & Object LifecycleUE 用标记-清除式的可达性分析管理 UObject 内存:从根集合 (Root Set) 出发沿反射可见的强引用遍历对象图,找不到的对象在下次垃圾回收 (Garbage Collection, GC) 时被删除。因此“谁持有 UPROPERTY 引用”决定了对象活多久;Actor 调用 Destroy() 只是请求销毁,真正释放仍取决于引用是否全部放开。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发观察者模式Observer Pattern观察者模式 (Observer Pattern) 让一段代码宣布“发生了某件事”,而不必关心谁会收到通知:主体 (Subject) 维护一个观察者列表,在事件发生时逐个同步调用它们。它适合连接彼此几乎无关的子系统(例如物理与成就),但要注意同步阻塞、观察者的注销,以及运行时才能确定的通信关系难以推理。游戏开发游戏世界的加载与流送Loading & Streaming Game Worlds世界加载系统负责两件事:把世界块和所需资源从磁盘读进内存的文件 I/O,以及这些资源的内存分配与释放;另外还要管理游戏对象的生成与销毁。GEA 按演进顺序介绍:一次只载一个关卡的栈式加载(LSR 资源压栈底),用小块气闸 (air lock) 区域实现基本无缝的过渡,再到把所有资产切成等大块、用块池分配器按关卡加载区域流送的细粒度方案(Naughty Dog);对象生成的内存问题有"不许运行时分配"、按尺寸分池、小内存分配器和内存重定位四种对策;存档则分检查点和随处存档(GEA4 卷 II 17.4)。综合游戏中的内存管理Memory Management in Games本页把 wiki 中关于内存的内容串成一条线:内存从两方面影响游戏,一是访问模式(缓存未命中远比计算昂贵),二是分配本身(通用堆分配慢且产生碎片,而主机上没有自动换页的虚拟内存兜底)。对策按生命周期组织:常驻与关卡数据用栈、每帧临时数据用单帧/双缓冲分配器、同尺寸小对象用池或小内存分配器、大小不一的动态对象用堆加碎片整理与句柄;资源按引用计数和分块加载管理,流送系统把世界切成等大的块;UE 则在 UObject 层用垃圾回收管理对象生命周期。最后,没有准确的追踪工具,以上一切都无从验证。各结论的细节与出处见所链接的概念页。游戏开发 · UE5Gameplay 能力系统Gameplay Ability SystemGameplay 能力系统 (Gameplay Ability System, GAS) 是 UE 用来构建「Actor 可拥有并反复触发的能力」的框架:能力系统组件 (Ability System Component, ASC) 是持有者,Gameplay 能力 (Gameplay Ability) 编排执行过程,属性集 (Attribute Set) 存数值,Gameplay 效果 (Gameplay Effect, GE) 改数值和标签,Gameplay 标签 (Gameplay Tags) 做状态与互斥,Gameplay Cue 负责表现。它内建复制与客户端预测,代价是概念多、上手重,游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。游戏开发 · UE5音频系统与 MetaSoundsAudio System & MetaSoundsUE5 的音频资产分三层:音源 (Sound Source) 负责产生声音,UE5 主推 MetaSounds,它是一张由设计师搭建的数字信号处理 (DSP) 图,能精确到单个采样;分组与混音用 Sound Class 和 Sound Class Mix 批量调整音量和音高;空间与资源由 Sound Attenuation(随距离衰减和空间化)和 Sound Concurrency(限制同时播放数量)控制。需要和节拍对齐的玩法可以用 Quartz 做精确到采样的调度。游戏开发状态模式与有限状态机State Pattern & Finite State Machines状态模式 (State Pattern) 把对象“依赖状态的行为”分散到各个状态类中,对象只持有指向当前状态的指针并把调用委托给它,状态切换就是换指针。它是实现有限状态机 (Finite State Machine, FSM) 的一种方式。FSM 能用很强的结构约束理清一团乱麻的标志位代码;状态组合爆炸、需要复用和需要“回到之前状态”时,分别用并发状态机、分层状态机和下推自动机来扩展。游戏开发类型对象Type Object类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原