第三章 常见 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 知识点图谱
下面这张图把四种算法之间的关系、优缺点梳理清楚,大家存一下,面试和调优都能用上。
我个人习惯在排查帧率问题时,优先关注对象的晋升情况。如果 Survivor 区总是满的,说明对象存活率异常,这时候就要考虑是不是代码里创建了大量长生命周期对象。
总结:GC 算法没有银弹。Android 开发中多用分代收集 + 复制算法(新生代),但每台设备的分代大小和 GC 策略不同。调优时一定要结合自己 App 的对象生命周期来选——不要盲目照搬。