Skip to content

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 的总时间占比

bash
# 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 时做一次整理)        但这是"亡羊补牢"   │
   └─────────────────────────────────────────────────┘
bash
# 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)         │
  │ 只有并发周期来不及回收才触发        │
  │ 应尽量避免                           │
  └─────────────────────────────────────┘
bash
# 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)

java
// 用户代码:
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:年轻代 + 老年代。为什么需要分代? 年轻代对象多且死亡率高 → 只扫描年轻代比扫描整个堆快得多 → 吞吐量提升。

bash
# 启用分代 ZGC (JDK 21+)
-XX:+UseZGC -XX:+ZGenerational

2.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 (自包含)            │  ← 超低延迟
         └───────────────────────────────┘

四、实战验证

收集器选择与验证

bash
# 查看当前 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 日志解读

bash
# 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索引**

Knowledge4J — Java 知识库