9.1 对象池设计

小游戏里每帧新建对象会把垃圾回收压力堆到帧预算上。这一节用 TypeScript 写一个定容对象池:acquire 借出时清零状态、release 归还进空闲表,容量耗尽按拒绝、覆盖最旧、扩容到上限三种策略处理,并统计命中率与峰值占用,让「复用是否真的发生」变成可断言的事。

站内已有一套《Go 语言运行时原理》 (30 篇)。那 30 篇讲的是 Go 运行时的 GC 与内存管理(三色标记、写屏障、GOGC/GOMEMLIMIT);本章讲的是浏览器 JS 引擎的 GC 压力:为什么小游戏里频繁分配对象会掉帧、对象池如何把分配压到接近零、以及弱机降级的判据。两者的堆对象不同,但「分配 → 回收」这条因果链相通,本章只对浏览器侧负责。

《合成小岛》每次合成、每次掉落、每次飘字都在制造短命对象。如果这些对象每帧新建、用完即丢,它们就会变成垃圾回收器要收的尸——收尸要时间,时间从帧预算里扣。这一节先把「复用」做成一个可断言的 TypeScript 内核:定容对象池。下一节再把它换算成分配总量与 GC 触发次数。

为什么「每帧新建对象」在小游戏里格外贵

在桌面浏览器里,每帧丢掉几十个对象几乎没人察觉;到了小游戏宿主,这件事会被三个约束同时放大。第一是帧预算窄:60 帧每秒意味着单帧只有约 16 毫秒,垃圾回收一旦落在一帧里,这一帧就可能顶破预算、表现为一次卡顿。第二是设备下限低:小游戏的分发决定了它必须跑在几年前的中低端机上,这些机器的可用内存和 GC 吞吐都远低于开发机。第三是玩法密度高:合成类小游戏的特效、飘字、掉落块是按帧产生的,分配速率不是「偶尔一次」而是「持续不断」。

对象池要解决的正是第三点带来的前两点后果。它的思路一句话讲完:把对象的生命周期和内存的生命周期解耦。对象在池里只创建一次,之后反复借出、归还;每次借出时把它的瞬时状态清零,归还时压回空闲表。稳态运行时,每帧的「新建次数」降到零,垃圾回收器就没有新的短命对象要收。这条链的量化版本放在 9.2,本节先把它做成代码。

D 层:把池子的参数写进配置

池子的行为由三个整数参数决定:初始容量、扩容上限、容量耗尽策略。它们和游戏逻辑无关,属于「工程旋钮」,所以放进配置而不是硬编码。下面这段是 D 层配置表。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "定容对象池参数。容量与突发量全为整数;policy 取值 reject / overwriteOldest / growToCap。禁浮点。",
  "capacity": 64,
  "maxCapacity": 96,
  "policy": "growToCap",
  "burstPerFrame": 20,
  "activeFrames": 30,
  "warmupAcquires": 64
}

capacity 是初始容量,maxCapacity 只在 growToCap 策略下有意义,burstPerFrame 与 activeFrames 是测试用的负载参数(每帧突发多少对象、跑多少帧)。policy 是字符串枚举,三选一。

P 层:定容对象池

下面这段是 P 层(内核层)TypeScript,core/src/e09/pool.ts。它只有三个动作:acquire 借出、release 归还、reset 回收;外加一组统计。全整数、无 Math.random()、无浮点,只用可擦除语法(禁 enum / namespace / 参数属性),node 能直接跑。

// file: core/src/e09/pool.ts
// tier: P
// verify: node --test 'core/test/e09_*.test.ts'
//
// 定容对象池:把「每帧新建对象」换成「从空闲表借、用完还」。借出时清零状态、归还时压回空闲表;
// 容量耗尽按策略处理(拒绝 / 覆盖最旧 / 扩容到上限)。全整数、无 Math.random()、无浮点。

export type ExhaustionPolicy = "reject" | "overwriteOldest" | "growToCap";

/** 被池管理的对象:字段对应一次「特效 / 飘字 / 掉落块」的瞬时状态。 */
export interface PooledObject {
  seq: number; // 借出序号(内部用:判「最旧」)
  active: boolean;
  kind: number;
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
}

export interface PoolConfig {
  readonly capacity: number; // 初始容量
  readonly maxCapacity: number; // growToCap 策略下的扩容上限
  readonly policy: ExhaustionPolicy;
}

/** 池的统计:created 是「新建」次数,命中率与峰值占用都从这里读。 */
export interface PoolStats {
  created: number;
  acquired: number;
  released: number;
  hits: number;
  misses: number;
  rejected: number;
  overwritten: number;
  grown: number;
  peakLive: number;
  live: number;
  free: number;
  capacity: number;
}

export interface Pool {
  readonly cfg: PoolConfig;
  readonly free: PooledObject[];
  readonly live: PooledObject[];
  capacity: number;
  nextSeq: number;
  stats: PoolStats;
}

function blank(): PooledObject {
  return { seq: 0, active: false, kind: 0, x: 0, y: 0, vx: 0, vy: 0, life: 0 };
}

export function createPool(cfg: PoolConfig): Pool {
  const stats: PoolStats = {
    created: 0, acquired: 0, released: 0, hits: 0, misses: 0,
    rejected: 0, overwritten: 0, grown: 0, peakLive: 0, live: 0, free: 0, capacity: 0,
  };
  return { cfg, free: [], live: [], capacity: 0, nextSeq: 1, stats };
}

/** 借出前把对象的瞬时状态清零(复用对象与新对象必须对调用方等价)。 */
function resetItem(o: PooledObject, seq: number): void {
  o.seq = seq; o.active = true; o.kind = 0; o.x = 0;
  o.y = 0; o.vx = 0; o.vy = 0; o.life = 0;
}

function syncCounters(pool: Pool): void {
  pool.stats.live = pool.live.length;
  pool.stats.free = pool.free.length;
  pool.stats.capacity = pool.capacity;
  if (pool.live.length > pool.stats.peakLive) pool.stats.peakLive = pool.live.length;
}

/** 借一个对象;容量耗尽且策略不允许时返回 null。 */
export function acquire(pool: Pool): PooledObject | null {
  const s = pool.stats;
  s.acquired += 1;
  let o: PooledObject;
  if (pool.free.length > 0) {
    o = pool.free.pop() as PooledObject;
    s.hits += 1;
  } else if (pool.capacity < pool.cfg.capacity) {
    o = blank(); pool.capacity += 1; s.created += 1; s.misses += 1;
  } else if (pool.cfg.policy === "overwriteOldest" && pool.live.length > 0) {
    // 找借出序号最小的活跃对象(最旧),把它顶掉后原位复用。
    let idx = 0;
    for (let k = 1; k < pool.live.length; k += 1) if (pool.live[k].seq < pool.live[idx].seq) idx = k;
    o = pool.live[idx];
    pool.live.splice(idx, 1);
    s.overwritten += 1;
  } else if (pool.cfg.policy === "growToCap" && pool.capacity < pool.cfg.maxCapacity) {
    o = blank(); pool.capacity += 1; s.created += 1; s.misses += 1; s.grown += 1;
  } else {
    s.rejected += 1;
    return null;
  }
  resetItem(o, pool.nextSeq);
  pool.nextSeq += 1;
  pool.live.push(o);
  syncCounters(pool);
  return o;
}

/** 归还一个对象;若该对象本就不在活跃表里,返回 false。 */
export function release(pool: Pool, o: PooledObject): boolean {
  const idx = pool.live.indexOf(o);
  if (idx < 0) return false;
  pool.live.splice(idx, 1);
  o.active = false;
  pool.free.push(o);
  pool.stats.released += 1;
  syncCounters(pool);
  return true;
}

/** 回收全部活跃对象到空闲表,并清零「窗口统计」(created 与 capacity 保留)。 */
export function reset(pool: Pool): void {
  for (const o of pool.live) { o.active = false; pool.free.push(o); }
  pool.live.length = 0;
  const s = pool.stats;
  const window = ["acquired", "released", "hits", "misses",
    "rejected", "overwritten", "grown", "peakLive"] as const;
  for (const k of window) s[k] = 0;
  syncCounters(pool);
}

/** 统计快照(拷贝,避免调用方改到池内部状态)。 */
export function snapshot(pool: Pool): PoolStats {
  return { ...pool.stats };
}

/** 命中率千分比(整数地板除):复用次数 / 借出次数 × 1000。 */
export function hitRatePermille(s: PoolStats): number {
  if (s.acquired === 0) return 0;
  return Math.floor((s.hits * 1000) / s.acquired);
}

三个动作里最关键的是 acquire 里那句 resetItem。对象池最容易被写错的地方,就是借出来的对象带着上一次的脏状态——位置还在上一帧的坐标、life 还是上一帧的剩余寿命。resetItem 把七个瞬时字段逐个归零,保证「复用的对象」和「新建的对象」对调用方完全等价。seq 是内部序号,只在 overwriteOldest 策略里用来判「谁最旧」,它不参与游戏逻辑。

容量耗尽时的三种策略

容量耗尽不是边角情况,而是对象池设计的核心分歧点。三种策略对应三种产品判断:

  • reject:借不到就返回 null,调用方决定「这次特效不放」还是「排队等下一帧」。语义是硬上限——宁可少放一个特效,也不让内存无界增长。
  • overwriteOldest:把最旧(借出序号最小)的活跃对象顶掉,原位复用。语义是视觉优先——屏幕上永远保留最新的对象,最老的提前消失。适合飘字、拖尾这类「旧的不重要」的反馈。
  • growToCap:容量没到 maxCapacity 就扩容,到了上限再退化成拒绝。语义是预留余量——平时按小容量跑,遇到合成连击这类突发再临时扩容,但总有一个天花板。

reject 与 overwriteOldest 都不会让 capacity 增长,growToCap 会让它增长但封顶在 maxCapacity。这个区别直接体现在统计字段 rejected / overwritten / grown 上——三种策略各自累加各自的计数器,互不干扰。

P 层测试:把「复用是否真的发生」变成断言

光有一个池子不够,还要能证明它真的在复用。下面这段是 P 层测试,core/test/e09_pool.test.ts。它用配置里的 burstPerFrame 与 activeFrames 造一个「每帧借 20 个、用完全还、跑 30 帧」的负载,断言新建次数只发生在第一帧。

// file: core/test/e09_pool.test.ts
// tier: P
// verify: node --test 'core/test/e09_*.test.ts'
//
// 硬断言:稳态负载下新建次数封顶,三种容量耗尽策略各自行为正确。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createPool, acquire, release, reset, snapshot, hitRatePermille, type PoolConfig } from "../src/e09/pool.ts";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e09_pool.json"), "utf8"));
const base: PoolConfig = { capacity: cfg.capacity, maxCapacity: cfg.maxCapacity, policy: cfg.policy };
type Got = NonNullable<ReturnType<typeof acquire>>;

test("预热后稳态复用:新建次数封顶在容量,之后全是命中", () => {
  const pool = createPool(base);
  const warm: Got[] = [];
  for (let i = 0; i < base.capacity; i += 1) warm.push(acquire(pool) as Got);
  assert.equal(pool.stats.created, base.capacity, "预热应新建恰好 capacity 个");
  assert.equal(pool.stats.hits, 0);
  for (const o of warm) release(pool, o);
  for (let round = 0; round < 10; round += 1) {
    const batch: Got[] = [];
    for (let i = 0; i < base.capacity; i += 1) batch.push(acquire(pool) as Got);
    for (const o of batch) release(pool, o);
  }
  const s = snapshot(pool);
  assert.equal(s.created, base.capacity, "稳态不应再新建对象");
  assert.equal(s.hits, base.capacity * 10, "稳态全部命中");
  assert.equal(s.rejected, 0);
  assert.equal(s.free, base.capacity);
});

test("每帧突发:30 帧各借 20 个并全还,新建只发生在第一帧", () => {
  const pool = createPool(base);
  for (let f = 0; f < cfg.activeFrames; f += 1) {
    const batch: Got[] = [];
    for (let i = 0; i < cfg.burstPerFrame; i += 1) batch.push(acquire(pool) as Got);
    for (const o of batch) release(pool, o);
  }
  const s = snapshot(pool);
  assert.equal(s.created, cfg.burstPerFrame, "突发 20 个对象只应在第一帧新建 20 个");
  assert.equal(s.hits, cfg.burstPerFrame * (cfg.activeFrames - 1), "其余帧全部命中");
  assert.equal(s.peakLive, cfg.burstPerFrame, "峰值活跃 = 单帧突发量");
});

test("reject 策略:容量耗尽返回 null 并计一次 rejected", () => {
  const pool = createPool({ capacity: 4, maxCapacity: 4, policy: "reject" });
  for (let i = 0; i < 4; i += 1) assert.notEqual(acquire(pool), null);
  assert.equal(acquire(pool), null, "第 5 次应被拒绝");
  assert.equal(pool.stats.rejected, 1);
});

test("overwriteOldest 策略:返回并顶掉最旧的对象,活跃数不变", () => {
  const pool = createPool({ capacity: 3, maxCapacity: 3, policy: "overwriteOldest" });
  const a = acquire(pool) as Got;
  const b = acquire(pool) as Got;
  const c = acquire(pool) as Got;
  assert.ok(a.seq < b.seq && b.seq < c.seq, "借出序号应递增");
  assert.equal(acquire(pool), a, "应复用最旧的对象 a");
  assert.equal(pool.live.length, 3, "覆盖后活跃数仍为容量");
  assert.equal(pool.stats.overwritten, 1);
});

test("growToCap 策略:扩容到上限后转为拒绝", () => {
  const pool = createPool({ capacity: 4, maxCapacity: 6, policy: "growToCap" });
  for (let i = 0; i < 6; i += 1) assert.notEqual(acquire(pool), null);
  assert.equal(pool.capacity, 6);
  assert.equal(pool.stats.grown, 2, "从 4 扩到 6 共扩容 2 次");
  assert.equal(acquire(pool), null, "到上限后应拒绝");
  assert.equal(pool.stats.rejected, 1);
});

test("reset 回收全部活跃并清零窗口统计,created 与 capacity 保留", () => {
  const pool = createPool(base);
  for (let i = 0; i < 30; i += 1) acquire(pool);
  const before = pool.stats.created;
  reset(pool);
  const s = snapshot(pool);
  assert.equal(s.live, 0);
  assert.equal(s.acquired, 0, "窗口统计应清零");
  assert.equal(s.created, before, "created 是历史累计,不应被 reset 清掉");
});

test("release 未借出的对象返回 false,命中率为整数地板除", () => {
  const pool = createPool(base);
  const o = acquire(pool) as Got;
  assert.equal(release(pool, o), true);
  assert.equal(release(pool, o), false, "同一个对象不能归还两次");
  assert.equal(release(pool, acquire(createPool(base)) as Got), false, "别的池的对象不能归还到本池");
  assert.equal(hitRatePermille({ ...snapshot(pool), acquired: 3, hits: 2 }), 666);
  assert.equal(hitRatePermille({ ...snapshot(createPool(base)), acquired: 0, hits: 0 }), 0);
});

在本机跑它(node --test 'core/test/e09_*.test.ts'),7 个断言全过:

✔ 每帧突发:30 帧各借 20 个并全还,新建只发生在第一帧
✔ overwriteOldest 策略:返回并顶掉最旧的对象,活跃数不变
✔ growToCap 策略:扩容到上限后转为拒绝
ℹ tests 7
ℹ pass 7
ℹ fail 0

命中率与峰值占用怎么读

两个统计字段决定池子调得对不对。命中率(hitRatePermille)是 hits / acquired:它高,说明借出绝大多数来自空闲表,池子真的在复用;它低,说明要么容量太小一直在新建、要么对象根本没归还。峰值占用(peakLive)是任意时刻活跃对象数的最大值:它决定容量该设多大——峰值 20 的负载配容量 64 是浪费,峰值 80 的负载配容量 64 会频繁触发耗尽策略。

这两个数字的关系是:容量要略高于峰值,命中率才高。容量低于峰值,reject 开始丢特效、overwriteOldest 开始让对象提前消失、growToCap 开始扩容(如果上限够)。所以调参顺序是:先用 peakLive 估容量,再用命中率验证容量选得对不对,最后才决定耗尽策略。这比「先拍一个容量」要靠得住。

小结与下一步

这一节把「复用」从性能口号变成了可断言的 TypeScript 内核:定容对象池有 acquire / release / reset 三个动作,容量耗尽有 reject / overwriteOldest / growToCap 三种策略,命中率与峰值占用把「调得对不对」变成两个可读的数字。7 个断言全部通过,created 在稳态负载下封顶在单帧突发量。

但对象池只是「少分配」,它到底省掉了多少 GC 压力,还需要一个量化对照。下一节就把这一节的对象池与「每帧新建」摆在一起,用一个分配量级的解析模型算出两者的分配总量与 GC 触发次数——注意,那是一个模型,不是 V8 实测。

阅读导航:上一节:客户端校验与反作弊 · 下一节:内存与 GC 压力 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制