7.1 内存模型与 GC
《方块冲刺》在卷二 13.2 已经算过一次纹理内存,也定下了「谁 load 谁 decRef」的纪律。但那只回答了「占多少」与「谁释放」,没回答三个更底层的问题:这些字节到底躺在哪块内存里?一个 Asset 从加载到消失经历了什么?垃圾回收到底在什么时候被触发? 这三个问题不搞清楚,优化就只剩猜。
本节把 Cocos 运行时的内存切成三块,逐块说明谁持有、谁回收;再用 cc.d.ts 里的签名证明 Asset 的引用计数语义;最后给一个纯 TypeScript 的「对象池 + 触发式回收」模型,用 vitest 断言其行为。站内 saas/game-engine/ 那 24 篇是从商业视角谈自研引擎,graphics/ 谈图形学理论;本节只谈 Cocos 运行时里能实证的部分。
7.1.1 三块内存,三套回收规则
新手谈「内存优化」时,脑子里通常只有一块内存。实际上 Cocos 运行时同时占用三块,它们的持有者、可见性和回收方式完全不同:
| 内存块 | 装什么 | 谁分配 | 谁回收 | 怎么观测 |
|---|---|---|---|---|
| JS 堆 | Node / Component / 普通对象 / 数组 / 闭包 | V8 或 JavaScriptCore | JS 垃圾回收器 | performance.memory(Chrome) |
| 引擎/原生堆 | 图片解码后的像素缓冲、Spine 数据、音频 PCM | 引擎(Web 上是 ArrayBuffer,原生端在 C++ 堆) | 引用计数 + 显式释放 | profiler.stats.bufferMemory |
| GPU 显存 | 上传后的纹理、顶点/索引缓冲、RenderTexture | gfx 设备 | Asset 释放时 destroy 底层 GPU 资源 | profiler.stats.textureMemory |
三块里只有第一块是 JS 垃圾回收器能看见的。后两块由引擎按引用计数管理——这正是「资源内存不能靠 GC 兜底」的根因:一个纹理的 JS 包装对象可能早被 GC 收走,但它引用的 GPU 显存还稳稳占着,直到引用计数归零。
还有一个平台差异要记牢:Web 端「原生堆」这一层其实也是 ArrayBuffer,仍在 JS 堆的可见范围内;原生端(Android / iOS)才真正落到 C++ 堆,performance.memory 之类完全看不到。所以同一份「内存涨了」的现象,在 H5 和原生上的排查路径并不一样。
7.1.2 一个 Asset 的生命周期
把 Asset 的一生画成状态,比背 API 有用得多。以 bundle-levels 里的关卡图集为例:
- 未加载:
assets/bundle-levels/下只有文件与.meta,内存里没有对应对象。 - 加载中:调用
resources.load或bundle.load后,请求进入队列,AssetManager去读文件、解码。 - 已加载:资源对象进缓存(cache),同时加载行为本身会把引用计数置为 1。
- 被持有:任何需要长期使用它的模块调用
addRef(),计数递增,阻止自动释放。 - 引用归零:调用
decRef()减到 0,若引擎判定无其他依赖,资源进入可释放状态。 - 真正释放:底层 GPU / 原生资源被
destroy,缓存条目被移除。
第 3 步到第 5 步之间,就是「泄漏」最常发生的地方——只 load 不 decRef,计数永远 ≥ 1,资源永远不释放。
7.1.3 引用计数不是 GC
Cocos 的 Asset 是引用计数管理的,cc.d.ts 里三行签名把语义钉死了:
// 来源:@cocos/creator-types@3.8.8, engine/cc.d.ts(Asset 类)
get refCount(): number;
addRef(): Asset;
decRef(autoRelease?: boolean): Asset;
三行的分工:refCount 是只读的当前计数;addRef() 加一,用于「我要长期持有」;decRef(autoRelease = true) 减一,减到 0 且 autoRelease 为真时资源会被自动释放。注意 decRef 的参数是可选的,传 false 就只减计数、不触发释放——这在「批量切场景、想延后统一清理」时有用,但日常别用,容易把释放时机搞乱。
引用计数和 GC 的关系,一句话说清:GC 管 JS 对象图,引用计数管资源句柄。两者是两条独立的线,谁也替不了谁。工程纪律因此只有一条:谁 load,谁 decRef,成对出现。不要写「反正有 GC」——GC 看不到那三块内存里的后两块。
7.1.4 三种最常见的泄漏形态
引用计数是纪律,纪律被破坏就有泄漏。工程里翻来覆去就三种:
- 只加不减:
addRef()之后忘了decRef()。典型是「把资源存进一个全局单例的数组,切场景时只清空数组、不遍历减引用」——数组是空的,refCount却还挂在那儿。 - 事件回调持有:
node.on('x', this.handler, this)注册后,节点销毁了,但回调仍被某个长生命周期对象引用,闭包捕获的this又把整棵节点树拖住。 - 定时器未清:
this.schedule(...)创建的定时器如果不unschedule,组件销毁后仍可能被Scheduler持有。它不占显存,但会让 JS 堆持续增长。
第三种最隐蔽,因为它不在「资源」这条线上,肉眼扫 addRef / decRef 完全扫不出来。一个可执行的自查习惯:每个 onEnable 都要能在 onDisable 里找到对应行,每个 schedule 都要能在 onDestroy 里找到 unschedule。写成对,就不会漏:
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
@ccclass('EnemySpawner')
export class EnemySpawner extends Component {
onEnable(): void {
this.schedule(this.spawn, 1);
}
onDisable(): void {
this.unschedule(this.spawn);
}
private spawn(): void {}
}
Component.schedule 的签名是 schedule(callback, interval?, repeat?, delay?),unschedule 收的是同一个回调引用——所以回调必须是稳定的成员方法或同一个函数引用,写成内联箭头函数会 unschedule 不掉。
7.1.5 GC 到底什么时候触发
JS 垃圾回收不可精确控制。现代引擎普遍是分代式:新生代用 scavenge(复制存活对象、回收快),老生代用标记-整理(mark-compact,开销大)。触发点由分配速率和堆阈值共同决定,不取决于你「想不想回收」。你能做的只有三件事,把「不可控」压缩成「可预期」:
- 减少每帧分配:这是最有效的。每帧
new一个Vec3、每帧[...arr]拷贝一个数组,都会推高分配速率,让 GC 提前、变频繁。用对象池复用是标准手段。 - 主动触发一次回收:
sys.garbageCollect()。签名是garbageCollect(): void,文档明确写着仅原生平台有效;Web 端调它是空操作。别指望它在浏览器里救场。 - 响应系统的低内存告警:监听
Game.EVENT_LOW_MEMORY,在系统内存吃紧时主动释放非必要资源,比等系统来杀进程体面得多。
下面这段 L2 脚本把第 3 件事落到工程里,只依赖 cc 公开 API,能过 tsc --noEmit:
import { _decorator, Component, game, Game, assetManager } from 'cc';
const { ccclass } = _decorator;
@ccclass('LowMemoryHandler')
export class LowMemoryHandler extends Component {
onEnable(): void {
game.on(Game.EVENT_LOW_MEMORY, this.onLowMemory, this);
}
onDisable(): void {
game.off(Game.EVENT_LOW_MEMORY, this.onLowMemory, this);
}
private onLowMemory(): void {
// 先清缓存管理器里的下载缓存(Web / 小游戏上尤其有效)
assetManager.cacheManager?.clearLRU();
// 原生端可以再补一刀强制回收
console.warn('[mem] low memory, caches cleared');
}
}
Game.EVENT_LOW_MEMORY 是 Game 的静态成员,事件名必须写成 Game.EVENT_LOW_MEMORY(用实例 game.EVENT_LOW_MEMORY 编译不过)。cacheManager 在 cc.d.ts 里的类型是 AssetManager.CacheManager | null,所以访问要带可选链。
7.1.6 L1:对象池 + 触发式回收
上面三条是「减分配、能主动、能响应」。要验证「减分配」到底省了多少,得有一份可跑的模型。下面这个对象池是纯 TypeScript,零 cc 依赖,直接单测即可复现。
// mini2d/pool/object-pool.ts —— 纯 TS,无任何 cc 依赖
export interface PoolOptions {
/** 空闲对象数上限,超过即触发一次「回收」 */
maxSize: number;
/** 单次回收最多丢弃多少个,避免一次性大回收造成卡顿 */
collectPerStep: number;
}
export class ObjectPool<T> {
private readonly factory: () => T;
private readonly reset: (item: T) => void;
private readonly options: PoolOptions;
private free: T[] = [];
private created = 0;
private collected = 0;
constructor(factory: () => T, reset: (item: T) => void, options: PoolOptions) {
this.factory = factory;
this.reset = reset;
this.options = options;
}
get size(): number {
return this.free.length;
}
get createdCount(): number {
return this.created;
}
get collectedCount(): number {
return this.collected;
}
acquire(): T {
const reused = this.free.pop();
if (reused !== undefined) {
return reused;
}
this.created += 1;
return this.factory();
}
release(item: T): void {
this.reset(item);
this.free.push(item);
if (this.free.length > this.options.maxSize) {
this.collect();
}
}
/** 触发式回收:按步长丢弃多余对象,交回给 JS GC */
private collect(): void {
let dropped = 0;
while (this.free.length > this.options.maxSize && dropped < this.options.collectPerStep) {
this.free.pop();
dropped += 1;
}
this.collected += dropped;
}
}
设计上有两个刻意的选择。第一,release 时先 reset(item) 再入池,避免把脏状态(比如残留的坐标、速度)带进下一次 acquire——这是对象池最经典的 bug 来源。第二,回收是分步的:collectPerStep 限制单次丢弃数量,对应到真实引擎里就是「不要把几百个对象在某一帧集中销毁」,因为集中释放同样会在那一帧制造一个 GC 尖峰,把帧时间顶上去。
配一份 vitest 断言,把「复用」和「触发式回收」两个行为都钉住:
import { describe, expect, it } from 'vitest';
import { ObjectPool } from './object-pool';
describe('ObjectPool', () => {
it('复用对象,且 release 时被重置', () => {
const pool = new ObjectPool<{ x: number }>(
() => ({ x: 0 }),
(o) => { o.x = 0; },
{ maxSize: 8, collectPerStep: 4 },
);
const a = pool.acquire();
a.x = 42;
pool.release(a);
const b = pool.acquire();
expect(b).toBe(a); // 同一个对象被复用
expect(b.x).toBe(0); // 脏状态已清
expect(pool.createdCount).toBe(1);
});
it('空闲数超过上限时按步长触发回收', () => {
const pool = new ObjectPool<object>(() => ({}), () => {}, {
maxSize: 4,
collectPerStep: 3,
});
const items = Array.from({ length: 12 }, () => pool.acquire());
for (const it of items) {
pool.release(it);
}
expect(pool.size).toBeLessThanOrEqual(4);
expect(pool.collectedCount).toBeGreaterThan(0);
});
});
跑起来:
cd mini2d && npx vitest run pool/object-pool.test.ts
第二段断言能通过,是因为每次 release 把空闲数推过上限 4 时,collect 就丢一个回来。12 次释放里有 8 次触发了丢弃,最终空闲数稳定在上限以内。这正是「触发式回收」相对「攒着不管」的差别:内存曲线是一条有上界的锯齿,而不是单调上升的斜坡。
7.1.7 工程决策:什么时候用池,什么时候不
对象池不是免费的。把对象留在池里,等于主动拒绝了一次 GC 回收——它占的内存是常驻的,只是避免了反复分配的开销。所以判断标准是「分配频率」而不是「对象大小」:
| 场景 | 该不该池 | 理由 |
|---|---|---|
| 每帧发射的子弹、金币、飘字 | 该池 | 高频分配,GC 尖峰的直接来源 |
| 关卡切换时创建一次的敌人 | 不必池 | 低频,池化收益抵不过常驻开销 |
| 长生命周期、状态复杂的实体 | 不必池 | 池化要写 reset,状态越复杂越容易漏字段 |
临时向量/矩阵(Vec3 等) | 该池 | 数学库对象小、分配极频繁 |
最后一条要和引擎自身对照:Cocos 的 Vec3 等数学类型是引擎自己管理的可变对象,v.set(...) 这类原地修改的 API 就是为了避免分配。在 update 里写 node.position = new Vec3(x, y, 0) 每帧都会分配一个 Vec3,而 node.setPosition(x, y, 0) 不会——同一件事,分配次数差一个数量级。
下一节从 JS 堆转到 GPU 显存:既然资源内存不归 GC 管,那它到底该按什么口径估、按什么预算管?我们会把格式枚举、块压缩对齐和整包预算表一次算清。
阅读导航:上一节:目录 · 下一节:7.2 纹理、显存与资源预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。