第三章 常见 GC 算法:标记-清除、复制、标记-整理、分代

说到 GC 算法,很多人能背出名字,但一问到“到底怎么工作的”,就容易卡壳。我早年调优一个社交 App 时,就因为不清楚标记-清除的碎片问题,踩了个大坑。今天咱们把这几种算法掰开揉碎了聊一遍。

3.1 标记-清除算法

原理:分两步走——先标记所有可达对象,再统一清除不可达对象。说白了就是先把该留的做个记号,然后把这些记号以外的内存全回收。

优点:实现简单,不移动对象,对老年代友好。

缺点:两个问题我最头疼——

  • 效率不稳定:标记和清除都要全堆扫描,堆越大越慢。
  • 内存碎片化:回收完留下一堆小空隙,下次分配一个大对象可能直接 OOM。我遇到过线上服务因为碎片触发连续 Full GC,帧率直接掉到个位数。

核心指标:标记-清除的碎片率通常超过 20% 时,分配速度会明显下降。

3.2 复制算法

原理:把内存分成两块(比如 From 和 To),只用其中一块。回收时把存活对象一股脑儿复制到另一块,然后清空当前块。你想想看,这么一来交换角色后,内存就是连续的了。

优点:没有碎片,分配只需移动指针,速度极快。

缺点:空间浪费一半(留着一块啥也不干)。我早期觉得太浪费,后来发现对于朝生夕死的对象(比如临时变量),复制算法效率真的高。

适用场景:新生代 GC 的首选。Java 里的 Eden 区 + Survivor 区就是典型的复制算法实现。Survivor 浪费的空间实际上只有 10% 左右,并不像表面那么夸张。

3.3 标记-整理算法

这个可以看作是标记-清除的升级版。标记完后不急着清除,而是把存活对象往一端搬,搬完再清理边界之外的空间。

核心思路:既解决碎片问题,又不浪费空间。不过搬移对象的成本很高。

  • 适合老年代:老年代对象存活率高,搬移次数少,整体还能接受。
  • STW 时间长:因为要一边搬一边更新引用,暂停时间会比复制算法长一些。

我曾经在电商大促时遇到过一个问题:老年代标记-整理耗时超过 500ms,导致界面卡顿被大量投诉。后来通过调大 CMS 触发阈值才缓解。

3.4 分代收集算法

现在所有主流 JVM 都没用单一的算法,而是分代收集——根据对象存活周期把堆分成新生代和老年代。

  • 新生代:对象死得快,用复制算法。Minor GC 效率极高,一次几十毫秒。
  • 老年代:对象活得久,用标记-整理或标记-清除。Major GC 频率低,但单次耗时可能上百毫秒。

为什么这样分?因为 80% 的对象都是“朝生夕死”。我刚开始不明白,直到实测发现新生代 GC 后对象存活率通常不到 5%,复制算法的浪费其实很少。

算法 碎片问题 空间利用率 GC 耗时特点 常用区域
标记-清除 严重 高 STW 时间随存活对象比例增长 老年代(配合 CMS)
复制算法 无 低(50%) STW 短,随存活对象数量增长 新生代
标记-整理 无 高 STW 长,随堆总大小增长 老年代(G1 的 mixed GC)
分代收集 综合 综合最优 各代独立,整体可控 所有区域

3.5 知识点图谱

下面这张图把四种算法之间的关系、优缺点梳理清楚,大家存一下,面试和调优都能用上。

常见 GC 算法对比 GC 算法 标记-清除 复制算法 标记-整理 分代收集 ✅ 实现简单 ❌ 碎片严重 ✅ 无碎片 ❌ 浪费空间 ✅ 无碎片 ❌ 搬移耗时 ✅ 综合效率高 ❌ 跨代引用复杂 避坑提示 • 标记-清除:碎片超过 20% 必须考虑内存整理,否则分配大对象可能触发 Full GC。 • 复制算法:Survivor 区大小根据存活率动态调整,默认 -XX:TargetSurvivorRatio=50。 • 分代收集:跨代引用需要用记忆集(Remembered Set)维护,否则扫描老年代代价高。

我个人习惯在排查帧率问题时,优先关注对象的晋升情况。如果 Survivor 区总是满的,说明对象存活率异常,这时候就要考虑是不是代码里创建了大量长生命周期对象。

总结:GC 算法没有银弹。Android 开发中多用分代收集 + 复制算法(新生代),但每台设备的分代大小和 GC 策略不同。调优时一定要结合自己 App 的对象生命周期来选——不要盲目照搬。