Skip to content

收集器演进史:Serial -> Parallel -> CMS -> G1 -> ZGC

本文是 JVM & GC 系统学习系列的核心篇(L2)。前置:GC 判定与算法演进——你得先知道标记-清除、复制、标记-整理这三种基础算法,后面每一代收集器都是它们的组合与工程化。 学完可以配合面试题食用:CMS 收集器原理与缺点G1 收集器原理与 Region 设计ZGC 着色指针

一切的开端:单线程的 Serial 搞不定大堆

JDK 1.2 时代只有 Serial 收集器:单线程,标记、清理全程 Stop The World。堆几十 MB 时一次停顿几毫秒,没人抱怨;等堆涨到几个 GB,一次 Full GC 就是秒级——期间所有业务线程冻住,外部看起来就是服务卡死。

于是一共出现了两条改进路线:

  • 多线程缩短单次停顿:活儿不变,几个人一起干,停顿时间除以核数——Parallel 系
  • 把大部分活儿挪到停顿之外:标记、清理和业务线程并发跑,只留极短的同步点——CMS,后来的 G1、ZGC 都在这条路上

再加上"碎片怎么处理""停顿能不能提前预测"两个工程问题,三十年里收集器一代代换代。演进主线一句话:吞吐 -> 低停顿 -> 可预测停顿 -> 亚毫秒

吞吐优先:Parallel 系

Parallel Scavenge(新生代,复制算法)+ Parallel Old(老年代,标记-整理),JDK 8 的默认组合。它的优化目标是吞吐量——业务运行时间占总时间的比例,参数 -XX:MaxGCPauseMillis-XX:GCTimeRatio 直接对着这个目标调。

特点:多线程 STW 一次性干完,停顿次数少、回收彻底、无碎片(复制和整理都会移动对象),但单次停顿不短,几百毫秒到秒级都正常。适合批处理、离线计算这类"总时长重要、卡一下无所谓"的场景:任务跑 10 分钟,中间停 2 次每次 500ms,没人在乎。

低停顿的第一次尝试:CMS

CMS(Concurrent Mark Sweep)只负责老年代,算法是标记-清除,JDK 14 里被正式移除。它把回收拆成四个阶段:

  1. 初始标记(STW,很短):只标记 GC Roots 直接关联的对象,不往下遍历
  2. 并发标记:从根出发遍历整棵引用图,与业务线程同时运行
  3. 重新标记(STW,较短):修正并发标记期间业务线程改动的那部分引用
  4. 并发清除:与业务线程同时运行,清掉垃圾

只有 1、3 两步停顿,且都不是全量工作,思路很漂亮。但三个问题最终把它送进坟墓:

  • 碎片:并发清除阶段不能整理内存——整理要移动存活对象,没法和业务线程并发。跑久了碎片化,某个大对象找不到连续空间,只能触发 Full GC
  • 并发失败(Concurrent Mode Failure):CMS 必须给老年代留余量,因为并发清理期间业务还在产生垃圾(浮动垃圾)、还在往老年代晋升对象。预留空间不够时 CMS 来不及收,JVM 退化为 Serial Old:单线程、标记-整理、全停顿,几个 GB 的堆上停几秒到十几秒。调优过 CMS 的人大多被这个坑过
  • 浮动垃圾:并发清理期间新产生的垃圾本轮收不掉,只能等下一轮,白白占着空间
mermaid
graph LR
    A["初始标记<br/>STW·短"] --> B["并发标记"]
    B --> C["重新标记<br/>STW·短"]
    C --> D["并发清除"]
    D -.碎片/浮动垃圾积累.- E["Concurrent Mode Failure<br/>退化 Serial Old<br/>单线程·秒级停顿"]

可预测停顿:G1

G1(Garbage First,JDK 9 起的默认收集器)换了组织方式:不再按整代回收,把堆切成约 2048 个等大 Region(1~32MB,2 的幂),每个 Region 动态扮演 Eden、Survivor、Old 或 Humongous(大对象专属,占连续多个 Region)。角色随 GC 变化,没有物理上的固定分代边界。

mermaid
graph TB
    subgraph G1Heap["堆 ≈ 2048 个等大 Region(1~32MB,角色动态切换)"]
        direction LR
        R1["Eden *"]
        R2["Eden *"]
        R3["Survivor"]
        R4["Old *"]
        R5["Old"]
        R6["Old *"]
        R7["Humongous"]
        R8["Free"]
    end
    CSet["CSet:本轮要回收的 Region 集合<br/>按 MaxGCPauseMillis 目标挑回收性价比最高的"]
    R1 & R2 & R4 & R6 -.入选.-> CSet

G1 的核心是停顿预测模型:每个 Region 记录存活字节数和回收耗时的统计数据,Young GC / Mixed GC 时按 -XX:MaxGCPauseMillis(默认 200ms)的目标,估算并挑选一组"回收性价比最高"的 Region 组成 CSet(Collection Set),把存活对象复制到空 Region。复制天然无碎片,"Garbage First"这个名字就来自这个贪心策略——垃圾最多的 Region 优先收。

并发标记怎么保证不漏标?靠 SATB(Snapshot At The Beginning):标记开始时对对象图拍快照,并发期间业务线程如果覆盖了某个引用字段,写屏障把旧引用记下来,按快照口径补标。SATB 解决的是漏标(存活对象被当垃圾回收,致命错误),代价是可能多标已死对象——又变成浮动垃圾,可接受。跨 Region 引用靠每个 Region 的 RSet 记录"谁引用我",避免回收时扫全堆。

日常需要记住的 G1 行为:Young GC 照常跑;老年代占用达到 -XX:InitiatingHeapOccupancyPercent(默认 45%)后触发并发标记,完成后跟若干次 Mixed GC——年轻代加一部分老年代 Region 一起回收。G1 的设计目标是永远别发生 Full GC;JDK 10 之前它的 Full GC 是单线程的,一旦触发比 Parallel 还惨。

亚毫秒:ZGC

G1 把停顿做到"可预测的几十到两百毫秒"。再往下压,瓶颈只剩一个:对象移动没法和业务线程并发——复制存活对象的同时,业务线程可能正拿着旧地址用。ZGC(JDK 11 实验、15 转正)用两个底层手段拆掉了这堵墙:

  • 着色指针:64 位指针里借 4 个 bit 存 GC 元数据(Marked0、Marked1、Remapped 等),对象处于标记、转移的哪个阶段直接写在指针里,不用额外查表
  • 读屏障:业务线程每次从堆里加载引用,JIT 插入一小段检查:发现指针颜色不对(指向转移前的旧地址)就当场修正成新地址

效果:转移阶段业务线程照常跑,读到旧地址就顺手"自愈"。ZGC 全程只剩三个极短停顿(初始标记、再标记、初始转移,各几十微秒),标记、引用处理、转移全部并发。停顿与堆大小无关——8GB 和 800GB 的堆,单次停顿都是亚毫秒级。代价也明确:读屏障带来 5%~10% 的吞吐损耗;非分代设计(分代 ZGC 要到 JDK 21)意味着每次都是全堆并发标记,分配速率特别高的应用可能回收跟不上。另一条低延迟路线 Shenandoah(转发指针方案)单独放在面试题 21讲。

动手实操:同一程序跑 G1 和 ZGC

写一个制造"大量朝生夕死 + 少量长期存活"负载的小程序:

java
// GcDemo.java
import java.util.ArrayList;
import java.util.List;

public class GcDemo {
    static List<byte[]> keep = new ArrayList<>(); // 长期存活,模拟老年代占用

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 32; i++) {
            keep.add(new byte[256 * 1024]);        // 每轮 256KB 进老年代
            for (int j = 0; j < 200; j++) {
                byte[] tmp = new byte[16 * 1024];  // 朝生夕死,喂 Young GC
            }
            Thread.sleep(50);                       // 给并发 GC 留窗口
        }
        System.out.println("done: " + keep.size());
    }
}

JDK 17 下分别用两个收集器跑,统一日志开关 -Xlog:gc*

bash
# G1:512M 堆
java -Xms512m -Xmx512m -XX:+UseG1GC -Xlog:gc*:file=g1.log GcDemo

# ZGC:同一程序同一负载
java -Xms512m -Xmx512m -XX:+UseZGC -Xlog:gc*:file=zgc.log GcDemo

G1 日志里一次典型 Young GC(节选):

[3.412s][info][gc,start    ] GC(9) Pause Young (Normal) (G1 Evacuation Pause)
[3.419s][info][gc,heap     ] GC(9) Eden regions: 96->0(96)
[3.419s][info][gc,heap     ] GC(9) Survivor regions: 12->16(16)
[3.419s][info][gc,heap     ] GC(9) Old regions: 38->40
[3.419s][info][gc          ] GC(9) Pause Young (Normal) (G1 Evacuation Pause) 146M->58M(512M) 6.923ms

最后一行是重点:这次 Young GC 整体 STW 6.9ms,回收 96 个 Eden Region(96MB)。

ZGC 日志里对应一轮回收(节选):

[4.812s][info][gc,start  ] GC(12) Garbage Collection (Warmup) Java(168M)->S(96M)
[4.812s][info][gc,phases ] GC(12) Pause Mark Start 0.017ms
[4.829s][info][gc,phases ] GC(12) Concurrent Mark 16.213ms
[4.829s][info][gc,phases ] GC(12) Pause Mark End 0.008ms
[4.837s][info][gc,phases ] GC(12) Concurrent Select Relocation Set 5.624ms
[4.838s][info][gc,phases ] GC(12) Pause Relocate Start 0.011ms
[4.849s][info][gc,phases ] GC(12) Concurrent Relocate 11.187ms
[4.850s][info][gc        ] GC(12) Garbage Collection (Warmup) Java(168M)->S(58M) 37.912ms

对比着读:ZGC 这轮总耗时 37.9ms,但真正停顿的只有三个 Pause 行——加起来 0.036ms,其余全是并发阶段,业务线程没停。这就是"总耗时更长、停顿更短"的取舍:G1 用 6.9ms 一次干完,ZGC 花 37.9ms 但只打扰你 0.036ms。响应延迟敏感的服务选后者,吞吐敏感的批处理选前者。

怎么选

  • JDK 8 / 后台批处理 / 停顿不敏感:Parallel,吞吐最高,别折腾
  • JDK 9+ 服务端默认:G1,覆盖绝大多数场景,堆 4~32GB 都稳
  • 堆 8GB 以上、要求 P99 延迟 < 10ms:ZGC(JDK 17+ 体验更好,21 有分代版)
  • CMS:迁移走,JDK 14 起已移除,没有第二个选项

实践顺序:先用默认(JDK 17 就是 G1)跑,拿到真实的 GC 日志和延迟指标,有问题先修分配行为(缓存滥用、大对象、内存泄漏),最后才轮到换收集器。

常见误区与小结

  • "换 ZGC 吞吐不受影响"——读屏障摆在那,高分配率下吞吐掉 10% 以上很常见
  • "CMS 退化 Serial Old 是 bug"——是设计内的兜底路径,触发条件是预留空间不足,参数只能降低概率
  • "G1 把 MaxGCPauseMillis 设成 10ms 就真能 10ms"——预测模型只能少选耗时超标的 Region,目标给太低会频繁 Young GC、吞吐崩掉,200ms 的默认值别乱动
  • "收集器选型决定一切"——多数 GC 问题是代码分配行为造成的,先看内存分配曲线再谈换收集器
  • "Parallel 早就过时了"——批处理场景它还是吞吐之王

小结:这篇把五代收集器串成一条线——Parallel 用多线程换吞吐,CMS 用并发换低停顿但倒在碎片和并发失败上,G1 用 Region 化加停顿预测模型做到"停顿可预算",ZGC 用着色指针加读屏障把停顿压到亚毫秒。每一步都在解决上一步的遗留问题。下一篇进入 JIT 编译与优化:Java 怎么在虚拟机里跑出接近原生的速度。

参考

  • 周志明《深入理解 Java 虚拟机(第 3 版)》第 3 章
  • JEP 333: Z Garbage Collector / OpenJDK wiki - ZGC
  • Oracle HotSpot Virtual Machine Garbage Collection Tuning Guide

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1