垃圾回收算法:标记清除 / 复制 / 标记整理 / 分代
问题的提出
面试官问:"JVM 有哪些垃圾回收算法?各自优缺点是什么?为什么 HotSpot 要用分代收集?"
表层回答:背出四种算法定义。但面试官真正想听的是:你理解每种算法的核心瓶颈,知道为什么没有一种算法能通吃,以及分代设计在当今超大堆场景下怎么被挑战的。
四种经典算法
1. 标记-清除(Mark-Sweep)
过程:两阶段。先标记所有存活对象(从 GC Roots 遍历),再统一回收未标记的对象。
优点:实现简单,不需要移动对象。
致命缺点:内存碎片。回收后可用空间不连续,分配大对象时即使总空间够,也可能找不到连续区域,被迫提前触发 GC。
真实案例:某在线支付系统,CMS 收集器运行 12 小时后,老年代碎片率打到 35%。一个 128KB 的订单对象分配失败,触发 Concurrent Mode Failure,回退到 Serial Old 做 STW 的 Full GC,应用暂停 4.2 秒——QPS 从 8000 直接掉到 0。复盘发现是碎片积累导致,最终切换 G1 解决。
时序图(文字描述):
时间线 →
1. 初始状态:老年代有 3 块空闲区(64KB、32KB、48KB),总可用 144KB
2. 需要分配 80KB 对象 → 每块都不够 → 触发 Full GC
3. 标记-清除回收后 → 空闲区变 4 块(48KB、16KB、32KB、12KB)→ 碎片率更高
4. 再来一个 64KB 对象 → 又触发 Full GC → 恶性循环2. 复制(Copying)
过程:将内存分成两块,只使用其中一块。GC 时将存活对象复制到另一块,然后清空整块。
优点:无碎片,分配只需指针碰撞,效率高。
缺点:内存利用率只有 50%(如果严格对半分)。对象存活率高时,复制开销极大——存活对象越多,复制的成本越高。
HotSpot 的实际做法:新生代采用 Eden:S0:S1 = 8:1:1 的比例,实际可用内存 90%(Eden + 一个 Survivor),每次 GC 只浪费 10% 的"空闲区"。不是 1:1 的 50% 浪费,这是面试中容易踩的坑。
复制成本公式:
复制成本 = 存活对象数 × 对象大小 / 复制带宽
假设新生代 4GB,存活率 10%,复制带宽 2GB/s:
→ 复制成本 = 400MB / 2GB/s = 200ms(STW)
存活率升到 30%:
→ 复制成本 = 1.2GB / 2GB/s = 600ms(STW)这就是为什么存活率超过某个阈值后,复制算法不如标记-整理——晋升阈值(Tenuring Threshold)的调优意义就在这里。
3. 标记-整理(Mark-Compact)
过程:标记存活对象后,将所有存活对象向一端移动压缩,然后清理边界外的内存。
优点:无碎片、内存利用率高。
缺点:移动对象需要更新所有引用,STW 时间长。堆越大,整理成本越高。
Parallel Scavenge + Parallel Old 的整理成本:
堆大小 32GB,老年代存活对象 24GB
标记阶段:遍历 32GB 堆 → ~800ms
整理阶段:移动 24GB 对象 + 更新引用 → ~1.5s
总 STW:~2.3s这也是为什么 32GB+ 堆用 Parallel GC 会有秒级停顿——不是 GC 参数调错了,是算法本身的瓶颈。
现代 GC 的并发整理方案(ZGC 的转发指针、Shenandoah 的 Brooks Pointer)就是为了解决这个"移动成本"问题。它们把整理阶段的 STW 从 O(堆大小) 降到了 O(GC Roots 大小)。
4. 分代收集(Generational Collection)
分代依据:弱分代假说(Weak Generational Hypothesis)——绝大多数对象朝生夕死。
策略:
- 新生代:用复制算法(对象存活率低 → 复制成本低)
- 老年代:用标记-清除或标记-整理(对象存活率高 → 复制成本高)
为什么不能反过来?这一问面试官常用来区分"背答案"和"真理解":
- 新生代用标记-清除 → 碎片化严重,新生代频繁 GC 会让碎片问题雪上加霜
- 老年代用复制 → 老年代对象存活率高,复制成本爆炸,且没有 50% 空间浪费的容错
- 结论:分代 + 各代选不同算法,是综合最优解,但不是唯一解
算法对比核心表格
| 算法 | 内存碎片 | 空间利用率 | 典型 STW | 适用场景 | 真实案例 |
|---|---|---|---|---|---|
| 标记-清除 | 严重(碎片率可达 30%+) | 高 | 中(CMS 阶段有并发) | 老年代(CMS,已被废弃) | 碎片导致 4.2s 停顿 |
| 复制 | 无 | 偏低(新生代 90%) | 存活率低时 < 200ms | 新生代 | 晋升阈值调优可降 50% 停顿 |
| 标记-整理 | 无 | 高 | 高(32GB 堆 ~2s) | 老年代(Parallel Old) | 超大堆需换 ZGC |
| 分代 | 视具体算法 | 综合最优 | 秒级 | 通用(< 32GB 堆) | 默认配置,但非万金油 |
为什么分代不是终点
分代收集是 HotSpot 的经典设计,但它有几个固有缺陷:
跨代引用:Minor GC 时需要知道老年代哪些对象引用了新生代,因此需要 Card Table / Remembered Set 来记录。维护这些数据结构本身就有开销——一个 4GB 堆的 Card Table 约 8MB,但每次 Minor GC 扫描 Card Table 的脏页需要额外 CPU 时间。
大对象分配:大对象直接进入老年代,绕过了复制算法的优势。老年代不断积累,最终触发 Full GC。典型场景:Dubbo 接口返回的 1MB 级 JSON 响应体,每次调用都分配一个大对象,直接进老年代,每小时触发一次 Full GC。
超大堆上的停顿:新生代复制算法在堆达到几十 GB 时,即使存活率只有 10%,复制 10GB 对象的 STW 时间也到秒级。实践数据:某 64GB 堆的电商系统,Minor GC 平均 800ms,高峰期 1.5s,为此不得不把堆降到 32GB 来换响应时间。
分代带来的额外开销:维护两个代的结构、晋升策略、年龄阈值、动态年龄判定——这些逻辑本身就有代码复杂度,且参数调优对新手不友好。
业界趋势:Java 17 的默认 GC 已经是 G1 而非 Parallel Scavenge + Parallel Old。G1 用 Region 设计实现了逻辑分代 + 物理不分代,而 ZGC 在 JDK 17 默认不分代(JDK 21 才引入分代 ZGC 作为实验特性),Shenandoah 也不分代。这说明分代不是 GC 的唯一解,在超大堆和低延迟需求下,不分代的并发整理反而更优。
深度追问:算法的真实博弈
// 一段演示"复制算法"与"标记-整理"差异的伪代码
// 新生代:复制
Eden → 存活对象复制到 Survivor → 清空 Eden
// 成本 = O(存活对象数)
// 老年代:标记-整理
标记存活 → 移动所有存活对象 → 更新引用
// 成本 = O(堆大小) + O(对象数)关键洞察:复制算法的成本取决于存活对象数,标记-整理的成本取决于堆大小 + 对象数。所以当对象存活率上升时,复制算法的收益急剧下降,这就是为什么新生代的对象需要晋升到老年代——老年代用标记-整理虽然成本高,但不会因为对象存活率高而爆炸。
面试实战题:
Q: 一个 Spring Boot 应用,堆 16GB,Full GC 每次 5 秒,怎么排查?
A:
1. jstat -gcutil 看老年代使用率(如果持续增长 → 内存泄漏)
2. jmap -histo 看对象统计(byte[] 占大头 → 缓冲区未释放)
3. 如果老年代使用率稳定但 Full GC 还是频繁 → 检查碎片率(jmap -heap)
4. 碎片率高 → 切 G1(-XX:+UseG1GC),设置 -XX:G1HeapRegionSize=4m常见踩坑
"-Xmn 设太大":新生代过大,Minor GC 复制成本飙升。某团队把 32GB 堆的 -Xmn 设到 20GB,Minor GC 每次 2 秒,反而得不偿失。合理值:堆的 1/3 ~ 1/2。
"-XX:SurvivorRatio=8" 理解错:Eden:S0:S1 = 8:1:1,不是 Eden:Survivor = 8:1。这是 Eden 占总新生代 8/10,两个 Survivor 各占 1/10。
"存活对象多就调大 -XX:MaxTenuringThreshold":这是反直觉的。阈值越大,对象在新生代多熬几次 GC,复制次数越多。应该结合 -XX:+PrintTenuringDistribution 看实际年龄分布。
"G1 不分代?":G1 是逻辑分代,但物理上 Region 不分代。G1 的 Young GC 仍然是复制算法,但相比传统分代,G1 可以按需选取 Region 做 Mixed GC,不需要 Full GC 整理。
总结
- 标记-清除:碎片问题无解,CMS 因此被废弃(JDK 14 移除)
- 复制:新生代最优,但存活率 > 30% 时效率骤降
- 标记-整理:无碎片但 STW 长,32GB 堆 ~2s,现代 GC 用并发整理解决
- 分代:经典方案,但超大堆下需要更激进的架构(ZGC、Shenandoah)
面试时,不要只背算法定义。能说出**"为什么 HotSpot 新生代用复制 + 老年代用标记-整理,而不是反过来",以及"32GB 堆上 Parallel GC 的 Full GC 为什么是 5 秒,怎么解决"**,才算真正理解了分代设计。