5.1 对象池与 freelist
第 4 章我们把「内存」压到了裸字节层面:arena 只前进、不回收,靠一次性 reset() 释放全部。但《微光岛》里有一类东西不适合 arena——子弹、粒子、飘字、音效播放器。它们的数量随时间剧烈波动,生命周期有长有短,而且常常需要「按住一个句柄不放,几十帧后再还回来」。arena 做不到任意顺序的归还,于是我们请出这一章的第二个工具:对象池。
对象池的核心是一张空闲链表(freelist):不用的对象串成一条链,要用时从链上摘一个,用完复位再挂回去。本节先用最朴素的数组当链表,实现一个通用 ObjectPool<T>,然后真跑一组数据,看看它到底比 new 快多少——以及什么时候反而更慢。
本章与站内既有内容的分工
站内已经有人从「引擎子系统」和「架构」的角度讲过内存与池化,本节只负责「从零实现 + 真实基准」这一块:
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/defold-object-pooling-memory/ | Defold 里对象池与内存管理的用法与子系统视角 | 用空闲链表从零写一个通用 ObjectPool<T>,并实测 |
/game-engine-architecture-ecs/ | ECS 引擎骨架的全局架构 | 只关心「对象从哪来、怎么复用」这一层 |
/posts/game/engine/ | Unity / Unreal 等引擎的选型对比 | 不选型,直接手写内核 |
一句话:别人讲「是什么、怎么选」,本节讲「怎么搭出来、跑出什么数」。
空闲链表就是池子的骨架
池子最简单、也最稳的形态是定长:容量在构造时定死,对象预先造好。取出(acquire)就是从链表尾弹一个,归还(release)就是复位后压回去。为什么用「尾」而不是「头」?因为 Array.pop / Array.push 是 O(1),而 shift / unshift 要搬动整个数组。
下面这个文件是 helio 的对象池实现,纯 TS、Node 直跑,是本节第一块可运行产物。
// file: src/core/object-pool.ts
// tier: L1
// verify: npx vitest run src/core/object-pool.test.ts
//
// helio 内核:定长对象池 + 空闲链表(freelist)。
// 与 4.x 的 arena 不同,池化保留「对象身份」——同一个实例被反复取出、
// 复位、放回,因此适合子弹、粒子、音频源这类「生命周期短、字段形状固定」的实体。
/** 池耗尽时的策略:抛错,或临时扩容。 */
export type ExhaustionPolicy = "throw" | "grow";
export interface PoolStats {
/** 当前容量(grow 策略下会变大)。 */
readonly capacity: number;
/** 空闲链表里的对象数。 */
readonly free: number;
/** 正在被使用的对象数。 */
readonly inUse: number;
/** 累计真实构造次数(池的核心收益指标)。 */
readonly created: number;
/** 累计「从空闲链表里取到」的次数。 */
readonly recycled: number;
/** 累计触到容量上限的次数。 */
readonly exhausted: number;
}
export interface ObjectPoolOptions<T> {
create: () => T;
reset: (item: T) => void;
initial: number;
policy?: ExhaustionPolicy;
}
/**
* 定长对象池。预分配 `initial` 个实例塞进空闲链表;
* `acquire()` 从链表尾弹出,`release()` 复位后压回。
*/
export class ObjectPool<T> {
readonly #create: () => T;
readonly #reset: (item: T) => void;
readonly #policy: ExhaustionPolicy;
#free: T[];
#capacity: number;
#inUse = 0;
#created = 0;
#recycled = 0;
#exhausted = 0;
constructor(opts: ObjectPoolOptions<T>) {
if (!Number.isInteger(opts.initial) || opts.initial < 0) {
throw new RangeError(`initial must be a non-negative integer, got ${opts.initial}`);
}
this.#create = opts.create;
this.#reset = opts.reset;
this.#policy = opts.policy ?? "throw";
this.#capacity = opts.initial;
this.#free = [];
for (let i = 0; i < opts.initial; i++) {
this.#free.push(this.#create());
this.#created++;
}
}
get capacity(): number {
return this.#capacity;
}
get inUse(): number {
return this.#inUse;
}
get stats(): PoolStats {
return {
capacity: this.#capacity,
free: this.#free.length,
inUse: this.#inUse,
created: this.#created,
recycled: this.#recycled,
exhausted: this.#exhausted,
};
}
/** 取一个对象:优先复用空闲链表,链表空则构造(或按策略扩容/抛错)。 */
acquire(): T {
const reused = this.#free.pop();
if (reused !== undefined) {
this.#recycled++;
this.#inUse++;
return reused;
}
if (this.#created >= this.#capacity) {
this.#exhausted++;
if (this.#policy === "throw") {
throw new RangeError(
`ObjectPool exhausted: ${this.#inUse} in use, capacity ${this.#capacity}`,
);
}
this.#capacity++;
}
const fresh = this.#create();
this.#created++;
this.#inUse++;
return fresh;
}
/** 复位后放回空闲链表。重复 release 同一对象会破坏计数,故显式拦截。 */
release(item: T): void {
if (this.#inUse <= 0) {
throw new RangeError("ObjectPool.release called with nothing in use");
}
this.#reset(item);
this.#free.push(item);
this.#inUse--;
}
/** 池中空闲对象数——用来判断「池是否开得太大」。 */
get freeCount(): number {
return this.#free.length;
}
}
有三处设计值得单独说明。第一,create 与 reset 是外部注入的。池子本身不该知道对象长什么样——粒子有 life,子弹有 vx/vy,音频源有 buffer,它们各自的复位逻辑只有调用方清楚。把这两个函数做成参数,池子就成了一个与业务无关的容器。
第二,created 才是池子的收益指标。注意 recycled 会把「从预分配链表里取到」也计入——所以一个预分配 1024 的池子跑 10 万次 acquire,recycled 会涨到 10 万,而 created 始终是 1024。判断池子是否有效,看的是 created 有没有失控增长。
第三,release 里那句 inUse <= 0 的拦截不是洁癖。同一对象被 release 两次,会让它同时出现在空闲链表和「使用中」两处,计数就此崩坏。用一个廉价的前置判断把这种错误尽早炸出来,比事后对着一堆诡异的复用 bug 强。
可运行产物:池 vs new 的 10 万次构造
光有实现不算数,得拿数据说话。下面是基准脚本的核心循环(完整脚本在 bench/object-pool-bench.ts),三组负载都在 10 万次量级上:
// snippet: bench/object-pool-bench.ts 的核心循环
function churnPool(): { ms: number; created: number } {
const pool = new ObjectPool<Particle>({
create: makeParticle, reset: resetParticle, initial: 1024,
});
const t0 = performance.now();
for (let i = 0; i < N; i++) {
const p = pool.acquire();
p.x = i; p.life = 1; sink += p.x;
pool.release(p);
}
return { ms: performance.now() - t0, created: pool.stats.created };
}
function churnNew(): number {
const t0 = performance.now();
for (let i = 0; i < N; i++) {
const p: Particle = { x: 0, y: 0, vx: 0, vy: 0, life: 0, alive: false };
p.x = i; p.life = 1; sink += p.x; // p 在此失去引用,交给 GC
}
return performance.now() - t0;
}
本机实测(Node 26.5.0,node --expose-gc bench/object-pool-bench.ts,每组取 9 次运行的中位数):
| 负载 | 池 | new + 丢弃 | 结论 |
|---|---|---|---|
| A:10 万次 acquire/release | 1.12 ms(created=1024) | 1.41 ms(created=100000) | new 慢 1.26× |
| B:一次性造 10 万个且不释放 | 2.25 ms | 2.03 ms | 池慢 1.11× |
| C:堆增量(不强制 GC) | +1679 KiB | +8818 KiB | 池的未回收字节少 5.2× |
A 组是池子的主场:预分配 1024 个对象,10 万次循环里 created 始终是 1024,recycled 涨到 100000——一次新构造都没发生。C 组更说明问题:不强制 GC 时,new 版本在账上留下了 8.6 MiB 待回收的年轻对象,而池版本只有 1.6 MiB。
基准之外,还要有断言把「池子语义」钉死。下面这个测试文件覆盖复用同一实例、复位字段、耗尽抛错、扩容、计数守恒五件事,是本节第二块可运行产物:
// file: src/core/object-pool.test.ts
// tier: L1
// verify: npx vitest run src/core/object-pool.test.ts
import { describe, expect, it } from 'vitest';
import { ObjectPool } from './object-pool.ts';
interface Bullet {
x: number;
y: number;
vx: number;
vy: number;
alive: boolean;
}
function makeBullet(): Bullet {
return { x: 0, y: 0, vx: 0, vy: 0, alive: false };
}
function resetBullet(b: Bullet): void {
b.x = 0;
b.y = 0;
b.vx = 0;
b.vy = 0;
b.alive = false;
}
function newPool(initial: number, policy?: 'throw' | 'grow'): ObjectPool<Bullet> {
return new ObjectPool<Bullet>({ create: makeBullet, reset: resetBullet, initial, policy });
}
describe('ObjectPool', () => {
it('预分配不重复构造,且 acquire 复用同一实例', () => {
const pool = newPool(4);
expect(pool.stats.created).toBe(4);
const a = pool.acquire();
pool.release(a);
const b = pool.acquire();
expect(b).toBe(a); // 复用同一实例
expect(pool.stats.recycled).toBe(2); // 两次 acquire 都来自空闲链表
expect(pool.stats.created).toBe(4); // 全程没有新增构造
});
it('release 会复位字段', () => {
const pool = newPool(1);
const b = pool.acquire();
b.x = 42;
b.alive = true;
pool.release(b);
const again = pool.acquire();
expect(again.x).toBe(0);
expect(again.alive).toBe(false);
});
it('耗尽时 throw 策略抛 RangeError', () => {
const pool = newPool(1, 'throw');
pool.acquire();
expect(() => pool.acquire()).toThrow(RangeError);
expect(pool.stats.exhausted).toBe(1);
});
it('耗尽时 grow 策略扩容并构造新对象', () => {
const pool = newPool(1, 'grow');
const a = pool.acquire();
const b = pool.acquire();
expect(b).not.toBe(a);
expect(pool.stats.created).toBe(2);
expect(pool.stats.capacity).toBe(2);
});
it('inUse / free 计数守恒', () => {
const pool = newPool(3);
const xs = [pool.acquire(), pool.acquire()];
expect(pool.stats.inUse).toBe(2);
expect(pool.freeCount).toBe(1);
pool.release(xs[0]!);
expect(pool.stats.inUse).toBe(1);
expect(pool.freeCount).toBe(2);
});
it('空池 release 抛错(防重复归还)', () => {
const pool = newPool(2);
expect(() => pool.release(makeBullet())).toThrow(RangeError);
});
});
本机实测 npx vitest run src/core/object-pool.test.ts:Test Files 1 passed (1)、Tests 6 passed (6)。其中「复用同一实例」那条正是池子与 arena 的分水岭——arena 每次给你一块新地址,池子给你同一个对象。
什么时候池化反而更慢
上面 A 组只快了 1.26×,B 组甚至慢了 1.11×。这两个数字都真实,且都在告诉我们同一件事:池化不是免费的午餐。
先看 B 组为什么输。B 组「造 10 万个对象,全程持有,从不归还」——这恰恰是池子最不擅长的工作负载。每次 acquire 都要走一遍「弹链表 → 判空 → 判容量 → 扩容 → create」的路径,reset 那一步还完全白费(对象根本没被复用)。new 只做一件事:分配。当复用率是零时,池子额外付的是簿记成本,收益是零。
再看 A 组为什么赢得不多。在 C++ 里对象池动辄快几倍,是因为原生 malloc 要走系统分配器;而 V8 的年轻代分配本身就是「指针碰撞」——极快。JS 里 new 一个短命对象本来就便宜,池子能省下的主要是 GC 压力(C 组的 5.2×),而不是构造时间本身。
由此可以总结出一条够用的判据:
- 该池化:对象会被反复创建/销毁、字段形状固定、生命周期跨越多个帧、且 GC 压力已经体现在帧时间抖动上。
- 不该池化:对象只造一次就不再释放(用数组或 arena 更直接)、对象很小且纯函数式用完即弃、或者复用率低到簿记成本盖过收益。
- 中间态:先用
new跑,用 5.3 的分配计数快照确认「同一类对象每帧确实在反复生死」,再决定要不要池化。
换句话说,池化是一剂针对 GC 抖动的药,不是「让分配变快」的银弹。先测,再池。
小结
- 对象池 = 预分配 + 空闲链表;
acquire弹尾、release复位压回,都是 O(1)。 created是收益指标:预分配 1024、跑 10 万次循环后它仍是 1024。- 本机实测:高频 churn 下池比
new快 1.26×,但一次性创建场景池反而慢 1.11×。 - 池化的真实价值在降低 GC 压力(未回收字节少 5.2×),不是构造速度。
- 先测复用率,再决定池不池。
下一节我们把这套「复用」的思路下沉到裸内存:用 2 的幂把 ArrayBuffer 切成大小类,每类一条空闲链表——那才是对象池在字节层面的样子。
阅读导航:上一节:目录 · 下一节:5.2 块分配器与大小类 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。