主题
收集器演进史: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 里被正式移除。它把回收拆成四个阶段:
- 初始标记(STW,很短):只标记 GC Roots 直接关联的对象,不往下遍历
- 并发标记:从根出发遍历整棵引用图,与业务线程同时运行
- 重新标记(STW,较短):修正并发标记期间业务线程改动的那部分引用
- 并发清除:与业务线程同时运行,清掉垃圾
只有 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 -.入选.-> CSetG1 的核心是停顿预测模型:每个 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 GcDemoG1 日志里一次典型 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