05.3 - 垃圾收集器详解(Serial 到 ZGC)
定位: 从 Serial 单线程到 ZGC 全并发——每一代 GC 都是为了解决前一代在特定场景下的痛点而生 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 收集器选型、CMS 的缺陷、G1 相比 CMS 的改进、ZGC 为什么快、收集器组合兼容性
一、这是什么?为什么需要它?
**GC基本算法(标记清除与复制与标记整理)** 介绍了三种基本回收算法。但实际 JVM 需要把这些算法组织成一个完整的垃圾收集器——处理并发、STW、晋升、引用更新等复杂工程问题。
不同收集器面对不同的核心矛盾:
- 吞吐量优先: 尽可能花时间在业务代码上(Parallel)
- 延迟优先: 尽可能缩短 GC 停顿(CMS → G1 → ZGC)
- 简单优先: 实现简单、无额外开销(Serial)
没有银弹——每款收集器都是在特定场景下做出的工程权衡。
二、原理拆解
2.1 Serial / Serial Old——最简单
为什么存在?单核时代,或客户端应用,或小堆 < 100MB 时,单线程足够简单可靠
架构:
┌──────── 用户线程 ────────┐
│ T1 ── T2 ── T3 ── T4 │
└──────────┬───────────────┘
│ STW (暂停所有线程)
▼
┌──────── GC 线程 ─────────┐
│ 单线程 GC 工作 │
│ Young: Serial (复制) │
│ Old: Serial Old (整理) │
└───────────────────────────┘
│ GC 完成
▼
┌──────── 用户线程 ────────┐
│ T1 ── T2 ── T3 ── T4 │
└───────────────────────────┘
特点:
- 单线程 GC (即使在多核 CPU 上也只用 1 核)
- 简单的单线程 STW 工作
- GC 期间所有应用线程暂停适用场景: 客户端应用(java -jar 命令行工具)、堆 < 100MB 的服务、嵌入式 JVM。
2.2 Parallel Scavenge / Parallel Old——吞吐量之王
为什么存在?服务器有多核 CPU,Serial 只用一个核太浪费。Parallel 让所有核心一起 GC
架构:
┌──────── 用户线程 ────────┐
│ T1 ── T2 ── T3 ── T4 │
└──────────┬───────────────┘
│ STW
▼
┌──────── GC 线程 ─────────┐
│ GC-1 ── GC-2 ── GC-3 │ ← 多线程并行 GC
│ GC-4 ── GC-5 ── GC-6 │ 所有 CPU 核参与
└───────────────────────────┘
特点:
- 多线程并行 GC
- 目标: 最大化吞吐量 (= 应用运行时间 / (应用时间 + GC 时间))
- -XX:MaxGCPauseMillis 和 -XX:GCTimeRatio 控制目标
- 支持自适应调优 (-XX:+UseAdaptiveSizePolicy)使用 Parallel 的核心原因是吞吐量导向。批处理、离线计算、数据仓库等场景中,GC 停顿几秒是可以接受的,但要尽量压缩 GC 的总时间占比。
# JDK 8 默认收集器组合: Parallel Scavenge + Parallel Old
java -XX:+PrintCommandLineFlags -version
# 输出: -XX:+UseParallelGC -XX:+UseParallelOldGC ...| 参数 | 说明 | 默认值 |
|---|---|---|
-XX:MaxGCPauseMillis | 期望最大停顿 ms | 无默认(由吞吐量目标决定) |
-XX:GCTimeRatio | 吞吐量目标 = 1 / (1 + ratio) | 99(目标 99% 吞吐量,即 1% GC 时间) |
-XX:+UseAdaptiveSizePolicy | 自动调整新生代比例、Survivor 比例 | true |
2.3 ParNew — CMS 的"搭档"
为什么需要单独的 ParNew?Parallel Scavenge 不能和 CMS 配合使用。ParNew 本质上是 Serial 的多线程版本,但能和 CMS 的 Old GC 协作
ParNew (Young) + CMS (Old):
┌───────────────────────────────┐
│ ParNew: 多线程复制算法 (Young) │
│ CMS: 并发标记清除 (Old) │
└───────────────────────────────┘ParNew 存在的唯一理由就是兼容 CMS。JDK 9 中 CMS 被标记废弃后,ParNew 也一同淡出。
2.4 CMS(Concurrent Mark Sweep)——低延迟先驱
为什么存在?应用服务器需要在 GC 时仍然响应用户请求。之前的收集器都要求长时间 STW。CMS 尝试把大部分工作和应用并发执行
工作阶段
CMS 的 4 + 1 个阶段:
① 初始标记 (Initial Mark) ← 短暂 STW, 标记 GC Roots 直接引用的对象
┌──── STW ────┐
│ 只需标记 Roots 的直接引用 │ ← 很快 (毫秒级)
└──────────────┘
② 并发标记 (Concurrent Mark) ← 与应用线程同时运行
┌──── 并发 ────┐
│ 从 Roots 开始遍历整个对象图 │ ← 最耗时的阶段, 但与应用并发, 不 STW
└──────────────┘
③ 重新标记 (Remark) ← 短暂 STW, 修正并发期间变化的部分
┌──── STW ────┐
│ 增量更新 (Incremental Update)│ ← 修正并发标记阶段的遗漏
│ 处理并发期间引用变化的部分 │
└──────────────┘
④ 并发清除 (Concurrent Sweep) ← 与应用线程同时运行
┌──── 并发 ────┐
│ 回收未标记的对象 │ ← 不会移动对象 (标记-清除!)
└──────────────┘CMS 的三大硬伤
① 并发模式失败 (Concurrent Mode Failure)
┌─────────────────────────────────────────────────┐
│ CMS 正在并发标记,但应用线程分配太快 │
│ 老年代剩余空间不够用了 → 停止 CMS │
│ → 退化为 Serial Old 单线程 Full GC │
│ → 停顿时间飙升到几秒甚至十几秒 │
│ │
│ 触发条件: 老年代占用超过 -XX:CMSInitiatingOcc- │
│ upancyFraction (默认 68%) │
└─────────────────────────────────────────────────┘
② 浮动垃圾 (Floating Garbage)
┌─────────────────────────────────────────────────┐
│ 并发标记期间,对象变为不可达 → 但已被标记为存活 │
│ → 本轮不回收 → 等待下一轮 → 老年代占用升得更快 │
│ → 加剧并发模式失败的风险 │
└─────────────────────────────────────────────────┘
③ 内存碎片化 (因为使用 Mark-Sweep)
┌─────────────────────────────────────────────────┐
│ CMS 不做压缩整理 → 老年代碎片化 → 大对象分配失败 │
│ → 触发 Full GC (Promotion Failed) │
│ CMS 提供 -XX:+UseCMSCompactAtFullCollection │
│ (Full GC 时做一次整理) 但这是"亡羊补牢" │
└─────────────────────────────────────────────────┘# CMS 常用参数 (JDK 8)
-XX:+UseConcMarkSweepGC # 启用 CMS (JDK 9 起标记废弃)
-XX:CMSInitiatingOccupancyFraction=75 # 老年代 75% 时开始并发标记
-XX:+UseCMSInitiatingOccupancyOnly # 只使用上面这个百分比 (不自动调整)
-XX:+CMSScavengeBeforeRemark # Remark 前先做一次 Young GC (减少 Remark 停顿)2.5 G1(Garbage First)——分区 + 可预测停顿
为什么存在?要替代 CMS。CMS 的碎片化、并发模式失败、不可预测的停顿都是硬伤。G1 的核心理念是:把堆分成小块,每次只收集"垃圾最多"的几块,从而控制暂停时间
核心创新一:Region 分区
G1 堆布局:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ E │ E │ E │ S │ E │ O │ O │ H │ ← H = Humongous (大对象区域)
├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ O │ O │ E │ E │ E │ E │ S │ E │ ← 每个 Region 大小 1-32MB
├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤ (power of 2)
│ E │ O │ O │ O │ O │ E │ E │ E │ ← E=Eden, S=Survivor, O=Old
├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ E │ E │ H │ H │ E │ E │ E │ E │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
关键区别 vs 传统分代:
- 没有固定的 Eden/Survivor/Old 边界
- Region 在年轻代和老年代之间动态切换
- 大对象 (H) 占用连续多个 Region为什么 Region 化? 因为每次 GC 不需要扫描整个堆,可以只选几个 Region 来收集。收集哪几个?Garbage First——"垃圾最多的 Region 优先"。每次只处理一部分 Region,停顿时间就变得可预测了。
核心创新二:RSet(Remembered Set)
RSet 解决了"跨 Region 引用"问题:
┌──────────────────┐ ┌──────────────────┐
│ Region-1 (Young) │ │ Region-5 (Old) │
│ 对象 A │ │ 对象 B │
│ ┌────────────┐ │ │ ↑ │
│ │ RSet │ │ │ │ A 被 B 引用 │
│ │ 记录: R5 │ │ ←───────┘ │
│ │ 有 Region-5 │ │ │ │
│ │ 引用了本区 │ │ │ ┌────────────┐ │
│ └────────────┘ │ │ │ RSet: (空) │ │
└──────────────────┘ │ └────────────┘ │
└──────────────────┘
当只收集 Young 区时:
- 只有 Young 区需要扫描 Roots
- Old 区通过 RSet 知道引用了哪些 Young 对象
- 不需要扫描整个 Old 区 → GC 停顿不随堆大小增长RSet 是对跨代引用的"索引":老年代对象引用了年轻代对象。如果没有 RSet,每次 Young GC 都得扫描整个老年代来找引用——那就失去了分代的意义。
G1 的 GC 周期
G1 的 GC 周期:
Minor GC (Young GC)
┌─────────────────────────────────────┐
│ 只用 Young Region + RSet 中的引用 │
│ 存活对象复制到 Survivor Region 或 Old│
│ 每次触发: Eden Region 用完 │
│ STW: 短暂 (随 MaxGCPauseMillis 目标) │
└─────────────────────────────────────┘
并发周期 (Concurrent Cycle)
┌─────────────────────────────────────┐
│ ① 初始标记 (STW, 伴随 Minor GC) │
│ ② 并发根区扫描 (并发) │
│ ③ 并发标记 (并发, 遍历对象图) │
│ ④ 重新标记 (STW, SATB 处理) │
│ ⑤ 清除 (STW, 统计存活信息) │
│ 触发: IHOP (默认 45%) │
└─────────────────────────────────────┘
混合 GC (Mixed GC)
┌─────────────────────────────────────┐
│ 收集 Young + 部分 Old Region │
│ 每次选"垃圾最多"的 Old Region │
│ 多次 Mixed GC 直到 Old Region │
│ 存活率达标 │
└─────────────────────────────────────┘
Full GC (备选)
┌─────────────────────────────────────┐
│ 单线程 Full GC (Serial Old) │
│ 只有并发周期来不及回收才触发 │
│ 应尽量避免 │
└─────────────────────────────────────┘# G1 关键参数 (JDK 9+, JDK 11 默认)
-XX:+UseG1GC # 启用 G1 (JDK 9+ 默认)
-XX:MaxGCPauseMillis=200 # 目标最大停顿 200ms (默认)
-XX:G1HeapRegionSize=4m # Region 大小 (1-32MB, power of 2)
-XX:InitiatingHeapOccupancyPercent=45 # 启动并发周期的堆占用阈值 (%)
-XX:G1NewSizePercent=5 # 年轻代初始大小 (堆的 %)
-XX:G1MaxNewSizePercent=60 # 年轻代最大大小 (堆的 %)
-XX:+G1UseAdaptiveIHOP # 自适应 IHOP (JDK 9+ 默认)
-XX:ConcGCThreads=4 # 并发 GC 线程数2.6 ZGC——彩色指针 + 全并发
为什么存在?G1 的停顿还是在 10-200ms 量级。对于高频交易、实时响应等场景,需要亚毫秒级停顿。ZGC 的目标是:暂停时间 < 1ms,且不随堆大小增加
核心技术:彩色指针(Colored Pointers)
64位 JVM 指针布局 (ZGC):
传统指针:
┌──────────────────────────────────────────────────┐
│ 0 │ 对象地址 (42 bits) │ 未使用 (21 bits)│
└────┴────────────────────────────┴───────────────┘
ZGC 彩色指针:
┌──────────────────────────────────────────────────┐
│ 0 │ 对象地址 (42 bits) │M0│M1│R│ 未使用(18b) │
└────┴────────────────────────┴─┴─┴─┴────────────┘
↑ ↑ ↑
Finalizable/Remapped/
M0/M1 元数据位
地址映射方式:
- 仅需在 64 位 JVM 上工作 (32 位没有多余地址位)
- 指针中的标志位不是存储在对象上 → 不修改对象内存 → 无需 STW
- 多个位用于标记: 可终结、M0/M1 (双重映射)、重映射 (Remapped)为什么彩色指针是革命性的? 传统 GC 需要在对象头上记录标记信息 → 需要 STW 来修改对象头。ZGC 直接把标记信息编码在指针里 → 访问指针时就能知道对象状态 → 不需要 STW 修改对象。
核心技术:负载屏障(Load Barrier)
// 用户代码:
Object o = obj.field; // 这行代码 → JIT 编译后插入负载屏障检查
// 实际执行 (伪代码):
Object o = obj.field;
if (isBadColor(o)) { // ← 负载屏障: 检查指针颜色
o = relocateObject(o); // ← 如果对象被移动了 → 转发
}
return o;负载屏障在读取引用时检查指针颜色。如果对象被移动了(在并发整理中),屏障会找到新地址并修正指针。整个过程对应用代码完全透明。
ZGC vs G1 的关键区别:
ZGC 加载屏障:
obj.field ──→ 读指针 → 检查颜色位 → 如果过期 → 更新指针 → 返回
│ │
每次读取引用时 代价: 额外的分支检查
都在做 (JIT 优化后约 2% 性能损失)
G1 写屏障 + RSet:
obj.field = val ──→ 写屏障记录跨区域引用 → 更新 RSet → 继续
│
只在写引用时触发ZGC 的工作阶段
ZGC 并发周期:
① 暂停 (STW, <1ms)
┌──────────────────────┐
│ 扫描 GC Roots │ ← 仅扫描 Roots, 非常快
│ 标记 Roots 中的指针 │
└──────────────────────┘
② 并发标记 (与应用并发)
┌──────────────────────┐
│ 遍历对象图 │
│ 负载屏障标记指针颜色 │
│ 对象可在此阶段被移动 │
└──────────────────────┘
③ 暂停 (STW, <1ms)
┌──────────────────────┐
│ 处理引用/终结/Symbol │ ← 少量的"收尾"工作
│ 确认并发标记完成 │
└──────────────────────┘
④ 并发整理 (与应用并发)
┌──────────────────────┐
│ 把存活对象移动到新址 │
│ 更新旧地址的转发表 │
│ 负载屏障引导访问到新址│
└──────────────────────┘
⑤ 并发重映射 (与应用并发)
┌──────────────────────┐
│ 更新所有指向旧地址 │
│ 的指针到新地址 │
│ 用"记忆"方式逐步完成 │
└──────────────────────┘分代 ZGC(JDK 21+)
JDK 21 引入分代 ZGC:年轻代 + 老年代。为什么需要分代? 年轻代对象多且死亡率高 → 只扫描年轻代比扫描整个堆快得多 → 吞吐量提升。
# 启用分代 ZGC (JDK 21+)
-XX:+UseZGC -XX:+ZGenerational2.7 Shenandoah——Brooks 指针并发压缩
为什么与 ZGC 不同的路径?Shenandoah 用 Brooks 指针(对象头中的转发指针)代替彩色指针。Red Hat 的贡献。不依赖 64 位地址位,可在 32 位工作
Brooks 指针:
┌─────────────────────────────────────────────┐
│ MarkWord (原有) │ Brooks Pointer (转发指针) │ ← 对象头增加一个字
│ 8 bytes │ 8 bytes │ 指向对象的新地址
└─────────────────────────────────────────────┘
│
▼
对象在新地址 (移动后)
访问路径:
对象访问 → 读 MarkWord → 检查 Brooks Pointer → 如果非自引用 → 转发到新地址
↑
每次对象访问都需要额外读取三、图解全景
收集器组合兼容性:
年轻代 老年代
┌───────────────┐ ┌───────────────┐
│ Serial │ ←→ │ Serial Old │ ← 单线程组合
├───────────────┤ ├───────────────┤
│ ParNew │ ←→ │ CMS │ ← 低延迟组合 (已废弃)
├───────────────┤ ├───────────────┤
│ Parallel │ ←→ │ Parallel Old │ ← 高吞吐量组合
└───────────────┘ └───────────────┘
┌───────────────────────────────┐
│ G1 (自包含: 所有代都在 Region 中)│ ← 通用默认 (JDK 9+)
└───────────────────────────────┘
┌───────────────────────────────┐
│ ZGC (自包含, JDK 21+ 支持分代) │ ← 超低延迟
└───────────────────────────────┘
┌───────────────────────────────┐
│ Shenandoah (自包含) │ ← 超低延迟
└───────────────────────────────┘四、实战验证
收集器选择与验证
# 查看当前 JVM 使用的收集器
java -XX:+PrintCommandLineFlags -version
# JDK 8 输出示例:
# -XX:+UseParallelGC -XX:+UseParallelOldGC ... ← Parallel 默认
# JDK 11 输出示例:
# -XX:+UseG1GC ... ← G1 默认
# JDK 17 输出示例:
# -XX:+UseG1GC ... ← 仍为 G1 (ZGC 需显式启用)
# JDK 21 输出示例:
# -XX:+UseZGC -XX:+ZGenerational ... ← 需显式启用G1 的 Mixed GC 日志解读
# VM 参数: -Xms512m -Xmx512m -XX:+UseG1GC -Xlog:gc*=info
# 典型日志:
2026-06-15T10:00:01.234+0800: 0.123: [GC pause (G1 Evacuation Pause)
(young) 100M->20M(512M), 0.015s] ← Young GC: 15ms
[Parallel Time: 12.0ms, GC Workers: 4]
2026-06-15T10:05:00.567+0800: 0.567: [GC pause (G1 Evacuation Pause)
(mixed) 300M->180M(512M), 0.045s] ← Mixed GC: 包含 Old Region
[Parallel Time: 38.0ms, GC Workers: 4]
2026-06-15T10:10:00.001+0800: 0.789: [GC pause (G1 Humongous Allocation)
(young) (initial-mark) 400M->250M(512M), 0.050s] ← 大对象分配触发并发周期五、面试视角
| 追问 | 答案要点 |
|---|---|
| CMS 的优缺点? | 优点:并发标记清除,低延迟。缺点:1) 并发模式失败→退化为串行 Full GC;2) 浮动垃圾;3) 内存碎片化 (Mark-Sweep 不压缩) |
| G1 相比 CMS 有哪些改进? | 1) Region 分区→不固定分代边界;2) RSet 避免全堆扫描;3) 可预测停顿 MaxGCPauseMillis;4) 复制算法→无碎片;5) Mixed GC 同时收集年轻代+部分老年代 |
| G1 如何做到可预测停顿? | 每次只选部分 Region 收集。选"垃圾最多"的 Region → 每次停顿时间正比于 Region 数量 → 通过控制每次收集的 Region 数量来控制停顿时间 |
| ZGC 为什么这么快? | 1) 彩色指针→标记信息在指针中,不修改对象头;2) 负载屏障→在读取引用时检查;3) 几乎全并发(标记/整理/重映射都并发);4) 暂停时间仅用于根扫描(数毫秒内完成) |
| ZGC 和 Shenandoah 的区别? | ZGC 用彩色指针(指针元数据位),Shenandoah 用 Brooks 指针(对象头转发指针)。ZGC 仅限 64 位;Shenandoah 在 32/64 位都能工作 |
| 如何选择垃圾收集器? | Client 小堆 → Serial;批处理吞吐量优先 → Parallel;Web 服务通用 → G1(JDK 9+ 默认);超低延迟大堆 → ZGC |
| CMS 为什么有"并发模式失败"? | CMS 并发标记时,应用线程继续分配。如果老年代填充速度 > CMS 回收速度 → 预留空间耗尽 → 暂停并执行 Full GC。根本原因是:CMS 并发阶段需要预留内存给应用继续分配,预留量不够就会失败 |
| G1 的 RSet 和 ZGC 的负载屏障有什么区别? | RSet 是写时记录跨区域引用(空间换时间,占用额外内存);负载屏障是读时检查指针颜色(时间换空间,无额外内存开销)。G1 写屏障在赋值时触发;ZGC 读屏障在取值时触发 |
📚 相关链接
- **GC基本算法(标记清除与复制与标记整理)** — 各收集器底层基于这三种基本算法
- **Java堆** — 堆的分代结构是理解收集器设计的基础
- **GC日志分析** — 不同收集器的日志格式不同
- **GC调优参数速查** — 各收集器的参数配置
- ← 返回 **GC索引**