什么是垃圾回收(GC)?

垃圾回收,简称 GC(Garbage Collection),说白了就是 JVM / ART 自动帮我们回收不再使用的对象。

你想想看,C/C++ 里申请内存要自己 malloc/free,一不小心就内存泄漏。Java 和 Android 开发就舒服多了——对象 new 出来,用完了不用你管,GC 会在后台悄悄把没用的对象清理掉。

但问题也出在这里:GC 不是免费的。它需要暂停你正在跑的业务线程。暂停多了,帧率就崩了。

核心一句话:GC 就是自动化的内存回收机制,它负责把不会再被引用的对象标记出来,然后释放其占用的堆内存。

GC 的基本工作流程

不管哪种 GC 实现,大体流程都是类似的。我画了一张图帮你理清整个脉络:

GC 工作流程与对帧率的影响 ① 分配内存 ② 触发 GC ③ 标记垃圾 ④ 回收内存 ⑤ 暂停时间 ⑥ 帧率下降 ⚠ GC 的暂停时间累积到一定程度,就会导致 UI 线程错过 16.6ms 的垂直同步信号,直接掉帧。

GC 的核心环节拆解

1. 什么时候触发 GC?

不是随机的。常见的触发条件就这几个:

  • 堆内存分配失败(Eden 区满了)
  • 显式调用 System.gc()(不建议你这么干,除了某些场景)
  • JVM / ART 策略决定(比如老年代占用达到阈值)
避坑提醒:我曾经在一个 IM 项目里看到,开发为了“及时释放内存”每收到一条消息就调一次 System.gc(),结果手机发烫、帧率直接掉到个位数。千万别手贱主动调 GC。

2. 标记阶段:确定谁是垃圾

GC 从 GC Roots 出发,沿着引用链遍历,能到达的对象就是“活的”,不能到达的就是垃圾。

GC Roots 包括:栈帧中的局部变量、静态变量、JNI 引用等。

3. 回收阶段:把垃圾清走

标记完了,怎么清?不同算法不同策略:

算法 核心思路 优点 缺点
标记-清除 标记存活对象,清除所有未标记 实现简单 内存碎片,容易触发连续 OOM
标记-复制 把存活对象复制到另一半空间 无碎片,分配高效 浪费一半内存,存活率高时性能差
标记-整理 标记存活,然后向一端移动,整理出连续空间 无碎片,内存利用率高 移动对象耗时更长
分代收集 新生代复制,老年代标记-整理 兼顾效率和碎片控制 实现复杂,调优参数多
个人经验:Android ART 默认使用的是并发标记 + 部分 Compaction 的算法。但即使在 ART 上,如果短时间内大量创建临时对象,依然会导致频繁 GC 和明显的掉帧。我习惯在性能分析时先看 GC 日志——adb logcat | grep "GC" 能直接看到每次 GC 暂停了多少毫秒。

为什么 GC 会导致帧率下降?

原因很简单:GC 会“Stop-The-World”。

哪怕最先进的并发 GC,在标记初始、重新标记阶段也需要暂停所有应用线程。这个暂停时间虽然可能只有几毫秒,但如果 GC 频率高(比如每秒触发好几次),或者单次暂停超过 16.6ms,UI 线程就来不及绘制下一帧。

你想想看,用户正在滑动列表,突然 GC 暂停了 UI 线程,这一帧就丢了,肉眼就能感觉到卡顿。

Android 上 GC 的典型场景

  1. 频繁创建临时对象:比如在 onDraw 里 new 一个 Paint、Path 或者 Bitmap,每帧都分配,很快撑爆新生代。
  2. 大量 String 拼接:用 + 连接字符串,底层生成大量临时 StringBuilder 和 String 对象。
  3. 集合类频繁增删:比如 HashMap 不断 put 又 remove,会导致老年代膨胀。
  4. Bitmap 未及时回收:尤其是大图,占用巨大堆内存,很容易触发 Concurrent GC。
真实案例:我曾经优化过一个短视频 App 的 feed 流,每次滑到新卡片都会创建新的 ImageView 和 Bitmap,结果每 3-4秒触发一次 GC_FOR_ALLOC,暂停时间平均 12ms,帧率只能跑在 35fps 左右。后来改用 RecyclerView + 对象池,GC 频率降到了 2 分钟一次,帧率稳定在 58fps。

小结

GC 不是 bug,是 JVM/ART 的内存管理标配。但如果你不了解它的触发条件和暂停影响,写代码时稍微不注意,GC 就会变成帧率的杀手。

记住:GC 频率 × 单次暂停时间 = 掉帧总时长。下一阶段我们会深入分析如何通过日志和工具定位 GC 问题。