Skip to content

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 问题常表现为时间尖峰,而不是内存持续增长。


九、练习与答案 ​

  1. 哪些写法可能在 update 中产生临时对象?
  2. GC 为什么会产生偶发卡顿?
  3. 对象池需要重置哪些状态?
  4. 如何验证一个优化是否减少了 GC?

答案:

  1. 新 Vector、数组、字符串、闭包、集合和 Promise 等。
  2. 垃圾积累到阈值后,回收会增加某一帧 CPU 工作。
  3. 属性、事件、Tween、schedule、显示状态和外部引用。
  4. 对比分配量、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);
            }
        }
    }
}

观察 ​

  1. Web 预览用 DevTools Memory/Performance 观察分配与 GC。
  2. 对比 allocate 与 reuse 的 Frame Time 分布。
  3. 逐步改变 5000 数量,找到目标设备敏感区。
  4. Native 用对应平台工具复测,JS 引擎行为可能不同。

复用数组本身也占长期内存;不要为了零分配预留无限大缓存。

减少分配的正确优先级 ​

  1. 先定位热点路径与分配类型;
  2. 降低调用频率和对象数量;
  3. 复用明确的小对象/缓冲;
  4. 避免每帧闭包和临时集合;
  5. 检查优化后代码可读性与生命周期;
  6. 用 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 项目必须在目标运行环境验证。

  1. 为什么 GC 卡顿不一定每帧发生? 分配累积到阈值后才触发回收,表现为周期性尖峰。
  2. filter/map 为什么可能产生压力? 通常创建新数组,并执行大量回调;热点中可用复用缓冲或直接循环。
  3. 对象池能否消除所有 GC? 不能,配置、闭包、字符串、数组和事件 payload 仍可能分配。
  4. 零分配是否永远最佳? 不是。过度复用增加长期内存和复杂状态;优先优化已测热点。

十、本课总结 ​

text
高频小分配会累积成 GC 尖峰。
复用和对象池要配合完整的生命周期重置。
是否优化必须通过分配和帧时间数据验证。

十一、下一课预告 ​

text
Lesson064|对象池解决了什么,又可能制造什么问题?