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 导致堆内存零散——池化消灭它。

四个概念共同约束"预分配固定槽位并复用短命对象,控制碎片和运行时分配":任何结论都必须回到"运行时是否零分配、槽位是否够用、复用是否正确复位"三个可观察量。

Optimization Pattern · Object Pool

Object Pool — 预分配,别现造

▷ 可交互
短命对象不 new 不 delete——从池里取、用、还预分配固定槽位,控制碎片与运行时分配BulletPool(预分配 6 个槽)⚪ 空闲🔴 使用中⚪ 空闲🔴 使用中⚪ 空闲⚪ 空闲发射:acquire()取空闲槽 → 初始化 → 使用爆炸:release()复位状态 → 标记空闲同一块内存反复使用:0 次 malloc、0 次 free、0 碎片——GC 压力归零

第 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 →

资料与写作方式声明

本章以Robert Nystrom《Game Programming Patterns》(游戏编程模式)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…