站内已有一套《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 压力 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。