外观
Stage05|Performance
Lesson063|JavaScript GC 与高频临时对象
Tags: #Creator2.x #Stage05 #JavaScript #GC #MemoryDifficulty: ⭐⭐⭐⭐☆
真实问题:帧率大部分正常,为什么每几秒突然卡一下
战斗 update 每帧创建大量 cc.Vec2、数组、字符串和闭包。分配本身看似很快,但临时对象不断进入堆:
text
持续分配
→ 堆空间增长
→ GC 扫描/回收
→ 某些帧暂停或增加工作症状不是持续慢,而是规律或不规律的长帧。
一、本课目标
本课解释为什么“平时很快,偶尔突然卡一下”可能和 JavaScript 内存分配有关。
重点理解:
- 临时对象如何进入堆。
- GC 为什么会造成帧时间尖峰。
- 如何减少高频分配,而不是盲目禁止所有对象创建。
二、什么会产生临时对象
常见写法:
ts
update() {
const p = cc.v2(this.node.x, this.node.y);
const values = this.items.filter(item => item.visible);
const message = `score: ${this.score}`;
}可能产生:
- Vector 对象。
- 新数组。
- 闭包和回调。
- 字符串。
- 临时 Map / Set。
- Promise 和事件包装对象。
单次很小,但每帧、每个 Item、每个粒子叠加后会快速增长。
三、GC 为什么会造成卡顿
text
业务持续分配
→ 堆中垃圾增加
→ GC 需要扫描和回收
→ 某一帧暂停或增加 CPU 工作
→ 出现 Frame Time 尖峰GC 并不一定每帧发生,也不一定每次都很重,所以经常表现为周期性卡顿。
四、减少分配的方向
复用对象
ts
private tempPos = cc.v2();
update() {
this.tempPos.x = this.node.x;
this.tempPos.y = this.node.y;
}复用数组
ts
this.visibleItems.length = 0;只在变化时创建字符串
ts
if (this.score !== this.lastScore) {
this.label.string = String(this.score);
this.lastScore = this.score;
}具体是否复用,要权衡代码复杂度和实际采样结果。
五、事件和闭包分配
在高频路径中反复创建回调:
ts
update() {
this.node.on('touchend', () => this.fire(), this);
}不仅会分配闭包,还会重复注册事件。事件应在明确的生命周期阶段注册一次,并在对应阶段取消。
六、对象池不是万能 GC 解决方案
对象池可以减少频繁创建和销毁,但会带来:
- 池中对象长期占用内存。
- 回收时状态重置遗漏。
- 事件、Tween、定时器残留。
- 峰值过高时池无限增长。
对象池是生命周期策略,需要定义容量、重置和淘汰规则。
七、案例:粒子和伤害数字
每次命中都:
text
instantiate
→ 创建 Label
→ 创建 Tween
→ 注册回调
→ 结束 destroy高频战斗中会产生大量分配。优化方向:
- 复用数字节点。
- 复用 Tween 或显式状态机。
- 统一管理显示对象。
- 减少临时数组和闭包。
- 采样 GC 前后的帧时间。
八、常见误区
误区一:所有 new 都一定造成可见卡顿
要看频率、对象大小、回收时机和目标设备。
误区二:为了避免 GC 把所有对象做成全局单例
这会造成状态污染和长期内存占用。
误区三:对象池只要 put/get 就完成了
还要重置状态、事件、动画、定时器和引用。
误区四:只看内存占用,不看帧时间尖峰
GC 问题常表现为时间尖峰,而不是内存持续增长。
九、练习与答案
- 哪些写法可能在 update 中产生临时对象?
- GC 为什么会产生偶发卡顿?
- 对象池需要重置哪些状态?
- 如何验证一个优化是否减少了 GC?
答案:
- 新 Vector、数组、字符串、闭包、集合和 Promise 等。
- 垃圾积累到阈值后,回收会增加某一帧 CPU 工作。
- 属性、事件、Tween、schedule、显示状态和外部引用。
- 对比分配量、GC 次数/时间、帧时间 P95/P99 和实际场景操作。
高频分配来自哪些代码形态
ts
const position = cc.v2(x, y); // 新对象
const visible = items.filter(fn); // 新数组
const label = 'HP:' + hp; // 新字符串
bus.on('hit', () => this.hit()); // 新闭包
return { x, y, speed }; // 新对象不是看到 new 才有分配。数组方法、展开、模板字符串、Promise 和隐式装箱都可能创建对象。
Creator 2.4.x 实验:每帧分配与复用
GcPressureProbe.ts
ts
const { ccclass } = cc._decorator;
@ccclass
export default class GcPressureProbe extends cc.Component {
private allocate = false;
private points: cc.Vec2[] = [];
onLoad() {
for (let i = 0; i < 5000; i++) {
this.points.push(cc.v2());
}
}
toggle() {
this.allocate = !this.allocate;
}
update() {
if (this.allocate) {
const values: cc.Vec2[] = [];
for (let i = 0; i < 5000; i++) {
values.push(cc.v2(i, i));
}
} else {
for (let i = 0; i < this.points.length; i++) {
this.points[i].set(i, i);
}
}
}
}观察
- Web 预览用 DevTools Memory/Performance 观察分配与 GC。
- 对比 allocate 与 reuse 的 Frame Time 分布。
- 逐步改变 5000 数量,找到目标设备敏感区。
- Native 用对应平台工具复测,JS 引擎行为可能不同。
复用数组本身也占长期内存;不要为了零分配预留无限大缓存。
减少分配的正确优先级
- 先定位热点路径与分配类型;
- 降低调用频率和对象数量;
- 复用明确的小对象/缓冲;
- 避免每帧闭包和临时集合;
- 检查优化后代码可读性与生命周期;
- 用 GC 次数、p99 和内存平台复验。
不要把所有函数改成全局可变对象,造成状态污染。
真实项目故障:伤害数字对象池仍有 GC 尖峰
节点已经池化,但每次显示仍创建:
text
Tween 配置对象
匿名完成回调
颜色对象
字符串格式数组
事件 payload节点池只消除了 Node instantiate/destroy,没有消除每次使用的临时数据。
修复:
- 复用 Tween/状态配置或使用明确动画组件;
- 保存稳定回调函数;
- 避免不必要数组链;
- 数字格式只在值变化时创建;
- 对象池
unuse清理闭包/事件; - 重新采样分配时间线。
GC 故障诊断
内存锯齿且长帧对齐下降点
这是 GC 强证据,再用 Allocation Profile 找主要类型与调用栈。
堆持续增长不回落
可能是真正的长期引用泄漏,而不是临时分配;检查 EventBus、闭包、缓存和常驻对象。
复用后内存更高
缓存容量过大或永不收缩。复用减少 GC,不代表长期内存免费。
版本边界与练习答案
Web 的 V8 与 Native JS 引擎 GC 策略、JIT 和工具不同。Creator 2.4.x 项目必须在目标运行环境验证。
- 为什么 GC 卡顿不一定每帧发生? 分配累积到阈值后才触发回收,表现为周期性尖峰。
filter/map为什么可能产生压力? 通常创建新数组,并执行大量回调;热点中可用复用缓冲或直接循环。- 对象池能否消除所有 GC? 不能,配置、闭包、字符串、数组和事件 payload 仍可能分配。
- 零分配是否永远最佳? 不是。过度复用增加长期内存和复杂状态;优先优化已测热点。
十、本课总结
text
高频小分配会累积成 GC 尖峰。
复用和对象池要配合完整的生命周期重置。
是否优化必须通过分配和帧时间数据验证。十一、下一课预告
text
Lesson064|对象池解决了什么,又可能制造什么问题?