Java GC 全景指南:从垃圾回收算法到 JDK 8、17、21、25

本文以 B 站视频《一小时速通所有 Java GC》及作者发布的同名文字稿为叙事起点,补充中村成洋、相川光《垃圾回收的算法与实现》中的基础算法框架,并以 HotSpot/OpenJDK 为讨论范围。

所谓“全部 GC”,在本文中指 JDK 8、17、21、25 这几条主线中可见的 HotSpot/OpenJDK 垃圾回收器及其底层算法积木,而不是穷举 OpenJ9、Azul Prime、GraalVM Native Image 等所有 JVM 实现。

Hexo 渲染说明:文件已设置 mermaid: true,但最终是否能显示流程图仍取决于主题或 Mermaid 插件是否启用;未启用时,图会以 Mermaid 代码块保留,不影响正文。

一、先给结论:GC 不是“背收集器名字”

很多 GC 文章的学习路线是:Serial、Parallel、CMS、G1、ZGC,挨个记特点。这样可以应付一轮面试,但很难处理真实生产问题。

真正稳定的方法,是先回答六个问题:

  1. 对象如何分配? 是指针碰撞、空闲链表,还是线程本地分配缓冲区?
  2. 谁还活着? 使用引用计数,还是从根集合做可达性追踪?
  3. 死亡对象如何回收? 清扫、复制,还是压缩?
  4. 业务线程如何与 GC 协作? 全程暂停、分段暂停,还是并发执行?
  5. 对象移动后,旧引用如何修正? 集中更新、转发表、读屏障,还是转发指针?
  6. 什么时候触发回收? 空间阈值、分配失败、预测模型,还是并发周期调度?

所有 Java 收集器,都是对这六个问题的不同组合。名字会变,底层矛盾不会变。

flowchart LR
    A[对象分配] --> B[识别存活对象]
    B --> C[回收死亡对象]
    C --> D[整理或转移存活对象]
    D --> E[修正引用]
    E --> F[释放或复用内存]
    G[Mutator 业务线程] <--> H[Collector GC 线程]
    H --> B
    H --> C
    H --> D

1.1 GC 永远在做多目标权衡

GC 通常同时面对四个目标:

  • 吞吐量:业务线程真正工作的时间占比;
  • 延迟:一次停顿多长,尤其是 P99、P999;
  • 内存占用:堆、元数据、转发表、记忆集、GC 预留空间等;
  • CPU 成本:并发标记、屏障、复制和引用处理都会消耗 CPU。

没有一个收集器能在所有维度无条件领先。低停顿往往需要更多并发 CPU、额外内存和屏障开销;极致吞吐量往往接受更长的 Stop-The-World;极小内存占用又可能牺牲并行度和预测能力。

flowchart TD
    A[GC 目标] --> B[低延迟]
    A --> C[高吞吐]
    A --> D[低内存占用]
    A --> E[低 CPU 开销]
    B -.通常需要.-> F[并发执行与更多内存余量]
    C -.通常接受.-> G[更长但更少的 STW]
    D -.通常限制.-> H[并发复制与预测空间]
    E -.通常减少.-> I[复杂屏障和后台线程]

二、从对象出生开始:分配并不只是 new

2.1 new 出来的对象不一定真的进入堆

从 Java 语义看,new User() 创建对象;从 HotSpot 实现看,JIT 可能通过逃逸分析执行:

  • 标量替换;
  • 锁消除;
  • 分配消除。

因此,代码里出现一次 new,不等于运行时一定在 Java 堆中分配一个完整对象。

2.2 大多数小对象通过 TLAB 快速分配

HotSpot 通常为线程分配 TLAB(Thread-Local Allocation Buffer)。线程在自己的 TLAB 中通过“指针碰撞”分配对象,常见路径只需移动指针,不必每次都竞争全局堆锁。

flowchart LR
    A[线程执行 new] --> B{JIT 能否消除分配}
    B -->|可以| C[标量替换或分配消除]
    B -->|不可以| D{TLAB 是否有足够空间}
    D -->|有| E[TLAB 指针碰撞]
    D -->|没有| F[申请新 TLAB 或慢路径分配]
    F --> G{堆空间是否充足}
    G -->|是| H[完成分配]
    G -->|否| I[触发或等待 GC]

2.3 大对象是特殊变量

大对象可能绕过普通 TLAB 路径。以 G1 为例,当对象大小达到 Region 大小的一半或以上时,会按 Humongous Object 处理,并占用一个或多个连续 Region。

这类对象的风险不是“它很大”这么简单,而是:

  • 需要连续 Region;
  • 可能造成尾部空间浪费;
  • 高频创建会显著提高回收压力;
  • 容易暴露 Region 选择、并发周期启动过晚和碎片问题。

所以排查 GC 时,必须同时看 对象分配速率、对象大小分布、存活时间和晋升速率,而不是只看堆用了多少。

三、什么对象才算垃圾

3.1 Java 采用可达性分析,而不是简单引用计数

一个对象是否可回收,关键不是“还有几个变量指向它”,而是:

从 GC Roots 出发,是否还存在一条引用路径能够到达它。

常见根来源包括:

  • 活跃 Java 线程栈和寄存器中的对象引用;
  • JNI Handle;
  • 已加载类及类加载器相关结构持有的引用;
  • JVM 内部结构、同步监视器等持有的引用。

HotSpot 属于 精确式 GC(Precise/Exact GC)。JIT 会生成 OopMap,告诉 JVM 在安全点上哪些栈槽和寄存器保存的是对象引用,而不是把每个长得像地址的整数都当成指针。

flowchart TD
    R1[线程栈与寄存器] --> A
    R2[JNI Handles] --> B
    R3[类与类加载器相关根] --> C
    R4[JVM 内部根] --> D
    A[对象 A] --> E[对象 E]
    B[对象 B] --> E
    C[对象 C] --> F[对象 F]
    D[对象 D]
    X[对象 X] --> Y[对象 Y]
    Y --> X
    style X stroke-dasharray: 5 5
    style Y stroke-dasharray: 5 5

上图中的 X、Y 即使互相引用,只要无法从 GC Roots 到达,仍然可以被追踪式 GC 回收。这也是朴素引用计数无法单独解决的问题。

3.2 强、软、弱、虚引用

Java 的引用强度会影响对象进入引用处理阶段后的行为:

引用类型 典型语义 生产建议
强引用 普通对象引用,只要可达就保留 默认方式
软引用 内存压力下可清理 不要把它当成可预测缓存策略
弱引用 GC 发现仅弱可达时可清理 常用于映射、元数据关联
虚引用 无法通过引用取得对象,配合 ReferenceQueue 感知回收阶段 适合资源清理通知,但清理时机仍不保证

finalize() 已在 JDK 9 被弃用,并在 JDK 18 被标记为“弃用并准备移除”。关闭文件、Socket、数据库连接等外部资源,应使用 try-with-resources 或明确生命周期管理,而不是等待 GC 心情好时来打扫卫生。

四、《垃圾回收的算法与实现》中的算法地图

中村成洋、相川光的《垃圾回收的算法与实现》把 GC 拆成了多种基础算法:标记-清除、引用计数、复制、标记-压缩、保守式 GC、分代 GC、增量式 GC 和 RC Immix。

这些算法不是互斥的“八选一”。现代 JVM 收集器往往把其中几种组合起来。

flowchart TD
    A[GC 基础算法] --> B[追踪式]
    A --> C[引用计数]
    B --> D[标记-清除]
    B --> E[复制]
    B --> F[标记-压缩]
    B --> G[分代]
    B --> H[增量或并发]
    A --> I[保守式与精确式]
    C --> J[朴素 RC]
    C --> K[延迟 RC / 合并 RC]
    K --> L[RC Immix 等混合设计]

4.1 标记-清除:只回收,不移动

基本流程:

  1. 从根集合追踪并标记存活对象;
  2. 扫描堆,回收未标记对象;
  3. 把空闲块加入空闲链表或其他分配结构。

优点:

  • 不必复制所有存活对象;
  • 对存活对象很多的区域可能比全量搬迁划算。

缺点:

  • 会产生外部碎片;
  • 分配可能需要搜索合适大小的空闲块;
  • 堆越碎,大对象分配越困难。

CMS 的老年代回收核心就建立在并发标记-清除之上。

4.2 引用计数:引用归零立即回收

朴素引用计数为每个对象维护引用数量:

  • 新增引用:计数加一;
  • 删除引用:计数减一;
  • 计数归零:立即释放。

优点是回收及时、单次延迟可分散;缺点是每次引用更新都有成本,并且无法自行处理循环引用。

HotSpot 主流 GC 不把朴素引用计数作为堆回收主算法,但引用计数思想广泛存在于资源管理、对象生命周期管理和混合 GC 研究中。

4.3 复制算法:只搬活对象

把内存划分为来源空间和目标空间。回收时只复制存活对象,然后一次性重置来源空间。

优点:

  • 分配快;
  • 回收后天然紧凑;
  • 成本更接近存活对象数量,而不是垃圾数量。

缺点:

  • 需要目标空间;
  • 存活对象很多时,复制成本高;
  • 所有指向移动对象的引用都要被修正。

年轻代通常采用复制/疏散(Evacuation)思想,因为“多数对象朝生夕死”。

4.4 标记-压缩:标记后集中搬迁

标记-压缩会先确定存活对象,再把它们移动到连续区域。

优点是消除碎片;缺点是移动和更新引用成本高,而且传统实现往往需要较长 STW。

Serial Old、Parallel Old 的老年代回收都体现了标记-压缩思想。

4.5 保守式 GC 与精确式 GC

保守式 GC 无法完全区分“整数”和“对象指针”,会把看起来像地址的数据保守地视为引用。它可能产生“假存活”,并限制对象移动。

精确式 GC 能准确识别引用,因此更适合对象搬迁和压缩。HotSpot 借助 OopMap、对象布局和安全点信息实现精确追踪。

4.6 分代 GC:利用弱分代假说

弱分代假说可以概括为:

  • 大多数对象很快死亡;
  • 熬过多轮回收的对象,更可能继续存活;
  • 新对象频繁引用旧对象的比例通常低于同代引用。

因此可以把堆分成年轻代与老年代,年轻代高频回收,老年代低频回收。

flowchart LR
    A[Eden 新对象] -->|Young GC 存活| B[Survivor]
    B -->|继续存活| C[另一 Survivor]
    C -->|达到晋升条件| D[Old]
    A -->|大对象或特殊策略| D
    D -->|Old / Mixed / Full GC| E[回收或整理]

需要注意:

  • “对象到 15 岁一定晋升”是过度简化;
  • Survivor 容量、动态年龄判定、担保机制和收集器策略都会影响晋升;
  • G1、ZGC、Shenandoah 的“代”并不等于 JDK 8 传统连续年轻代/老年代布局。

4.7 增量式与并发式 GC

这几个词经常被混用:

概念 含义
串行 Serial GC 工作主要由一个线程执行
并行 Parallel 多个 GC 线程同时工作,但业务线程通常暂停
并发 Concurrent GC 线程与业务线程在同一时间段共同运行
增量 Incremental 把 GC 工作切成多个小片段,穿插执行;不保证一定与业务线程并发

并发 GC 最大的困难是:GC 正在遍历对象图时,业务线程还在修改对象图。

4.8 三色标记与屏障

追踪过程常用三色抽象:

  • 白色:尚未发现;
  • 灰色:对象已发现,但其子引用尚未全部扫描;
  • 黑色:对象及其子引用已扫描完成。

如果业务线程让黑对象突然指向一个白对象,而 GC 又没有记录这次变化,白对象就可能被错误回收。

sequenceDiagram
    participant GC as GC 线程
    participant M as 业务线程 Mutator
    participant A as 黑对象 A
    participant C as 白对象 C
    GC->>A: 已扫描 A,标黑
    M->>A: 新增引用 A -> C
    Note over M,C: 若没有屏障,C 可能保持白色
    M->>GC: 写屏障记录引用变化
    GC->>C: 重新纳入标记队列

常见解决思路:

  • 增量更新(Incremental Update):记录新增引用,让新目标对象重新进入扫描;
  • SATB(Snapshot-At-The-Beginning):保留并发标记开始时的对象图快照语义,记录被覆盖的旧引用;
  • 读/加载屏障(Load Barrier):对象被读取时检查并修复引用状态;
  • 写/存储屏障(Store Barrier):对象引用写入时记录跨代关系或标记变化。

屏障不是操作系统内存屏障的同义词。它是 JVM/JIT 在引用读写路径上插入的额外逻辑。

4.9 RC Immix:理解“混合算法”的好例子

Immix 将堆组织成 Block 和 Line,在标记区域回收基础上,结合局部搬迁缓解碎片。RC Immix 再把引用计数与追踪式回收结合起来:

  • 引用计数负责更及时地识别大量死亡对象;
  • 追踪解决循环引用;
  • Immix 的块/行组织改善空间利用率和局部性;
  • 必要时只搬迁部分对象,而不是每轮全量压缩。

HotSpot 并没有直接提供一个叫“RC Immix GC”的选项,但它提醒我们:现代 GC 的进步通常来自组合,而不是发明一个孤立的新名字。

五、STW、安全点与安全区域

5.1 为什么 GC 仍然需要暂停

即使是 ZGC、Shenandoah 这类并发收集器,也不是“零暂停”。GC 仍需在某些阶段取得一致状态,例如:

  • 枚举或处理部分根;
  • 完成并发阶段切换;
  • 处理必须原子完成的全局状态;
  • 对线程进行重定位握手或安全点同步。

5.2 STW 不等于 GC

JVM 进入安全点还可能因为:

  • 反优化;
  • 类重定义;
  • 某些诊断命令;
  • Heap Dump;
  • 其他 VM Operation。

所以看到“应用停顿”,不能只看 GC 日志,还应看 safepoint 日志、线程状态、CPU 调度和容器限流。

5.3 “暂停很短”不等于接口一定快

接口延迟还会受到以下因素影响:

  • GC 并发线程抢占 CPU;
  • 内存带宽被对象复制占满;
  • 容器 CPU 被 CFS throttling;
  • Swap 或缺页;
  • 锁竞争、IO、数据库和下游服务;
  • GC 后缓存局部性变化。

因此 GC 优化必须用端到端业务 SLO 验证,不能只盯 Pause Young 12ms 自我感动。

六、JDK 8 的完整收集器版图

JDK 8 是很多存量系统的基准版本。Server-Class 机器通常默认选择 Parallel Collector,但实际默认值仍应以具体发行版、更新版本、容器识别和启动日志为准。

6.1 Serial + Serial Old

启动参数:

1
-XX:+UseSerialGC

结构:

  • 年轻代:Serial,复制算法,单 GC 线程,STW;
  • 老年代:Serial Old,标记-压缩,单 GC 线程,STW。

适合:

  • 小堆;
  • 单核或 CPU 很少;
  • 命令行工具、构建工具、小型任务;
  • 启动和内存占用优先于尾延迟。

不适合:大型、多线程、延迟敏感服务。

6.2 Parallel Scavenge + Parallel Old

启动参数:

1
-XX:+UseParallelGC

结构:

  • 年轻代:Parallel Scavenge,并行复制;
  • 老年代:Parallel Old,并行标记-压缩;
  • 回收阶段主要是 STW。

它又叫 Throughput Collector,核心目标是让多核机器尽快完成回收,减少 GC 总开销。

适合:

  • 离线计算;
  • 批处理;
  • 报表生成;
  • 对单次暂停不敏感,但希望整体吞吐高的任务。

关键风险:

  • 大堆 Full GC 可能很长;
  • Young GC 频率高时,虽然单次不长,但总 GC CPU 可能很高;
  • 晋升失败、老年代占用过高会把系统推向频繁 Full GC。

6.3 ParNew + CMS

启动参数:

1
-XX:+UseConcMarkSweepGC

在 JDK 8 中,CMS 通常负责老年代,年轻代由 ParNew 并行收集。

CMS 的典型阶段:

flowchart LR
    A[Initial Mark STW] --> B[Concurrent Mark]
    B --> C[Concurrent Preclean]
    C --> D[Remark STW]
    D --> E[Concurrent Sweep]
    E --> F[Concurrent Reset]

优点:

  • 大部分标记和清扫与业务线程并发;
  • 相比传统整堆压缩,老年代停顿通常更短。

关键问题:

  1. 浮动垃圾:并发清扫期间新产生的垃圾通常留到下一轮;
  2. 碎片:CMS 主要使用标记-清除,不做常规全量压缩;
  3. 并发模式失败:回收速度跟不上分配/晋升速度时,可能退化到更重的 STW 回收;
  4. CPU 竞争:并发线程会和业务争 CPU;
  5. 参数复杂:启动阈值过晚容易失败,过早又消耗吞吐。

CMS 在 JDK 9 被弃用,JDK 14 被移除。迁移到 JDK 17 时不能继续使用。

6.4 G1

启动参数:

1
-XX:+UseG1GC

G1 把堆划分为等大小 Region。每个 Region 在某个阶段可以承担 Eden、Survivor、Old 或 Humongous 角色,代边界不再是两块固定连续区域。

flowchart TB
    subgraph Heap[G1 Region 化堆]
      R1[Eden]
      R2[Eden]
      R3[Survivor]
      R4[Old]
      R5[Old]
      R6[Humongous Start]
      R7[Humongous Continue]
      R8[Free]
    end

G1 的关键部件:

  • Card Table:把堆切成更细粒度的卡页,记录可能发生引用变化的区域;
  • Remembered Set(RSet):记录其他 Region 中哪些卡页可能指向当前 Region;
  • SATB:并发标记期间维护开始时快照语义;
  • Collection Set(CSet):本次暂停需要疏散的 Region 集合;
  • Evacuation:把存活对象复制到新 Region,回收整个旧 Region。

典型周期:

flowchart LR
    A[Young GC STW] --> B{达到并发标记启动条件}
    B -->|否| A
    B -->|是| C[Concurrent Start / Initial Mark]
    C --> D[Concurrent Mark]
    D --> E[Remark STW]
    E --> F[Cleanup]
    F --> G[Mixed GC 若干次]
    G --> A

G1 的优势:

  • 通过 Region 和疏散减少长期碎片;
  • 能把老年代回收拆分到多轮 Mixed GC;
  • 使用停顿预测模型选择 CSet;
  • 兼顾吞吐和停顿,是现代 HotSpot 的通用默认选择。

G1 的风险:

  • Evacuation Failure / To-space Exhausted;
  • Humongous 对象过多;
  • 并发标记启动太晚,来不及回收;
  • RSet 与屏障带来额外 CPU、内存开销;
  • MaxGCPauseMillis 是目标,不是硬实时承诺。

特别注意:JDK 8 的 G1 Full GC 回退实现曾是单线程的;JDK 10 通过 JEP 307 增加了并行 Full GC。因此“都是 G1”,JDK 8 与 JDK 17 的极端退化表现也可能差很多。

6.5 JDK 8 收集器关系图

flowchart TD
    A[JDK 8 HotSpot] --> B[UseSerialGC]
    A --> C[UseParallelGC]
    A --> D[UseConcMarkSweepGC]
    A --> E[UseG1GC]
    B --> B1[Serial Young]
    B --> B2[Serial Old]
    C --> C1[Parallel Scavenge]
    C --> C2[Parallel Old]
    D --> D1[ParNew]
    D --> D2[CMS]
    D2 -.失败或压缩回退.-> B2
    E --> E1[G1 Region / Young / Mixed / Full]

七、JDK 17:现代生产基线

JDK 17 中,CMS 已不存在。G1 是 Server-Class 环境的默认收集器,也是多数普通服务的第一选择。

7.1 Serial GC

仍然可用:

1
-XX:+UseSerialGC

它没有因为“古老”就变得无用。在小容器、小堆、短生命周期命令行程序中,复杂并发 GC 的固定成本可能不划算。

7.2 Parallel GC

仍然可用:

1
-XX:+UseParallelGC

如果业务是吞吐优先,Parallel GC 依然可能打败 G1。不要把“默认换成 G1”误读成“Parallel 已过时”。

7.3 G1

显式启用:

1
-XX:+UseG1GC

JDK 17 的 G1 相比 JDK 8 已经历多年改进,包括并行 Full GC、并发线程调度、Region 回收和预测模型等方面的增强。JDK 8 迁移到 JDK 17 时,哪怕参数完全不改,GC 行为也不能按旧经验机械推断。

7.4 ZGC:JDK 17 中仍是非分代设计

启用:

1
-XX:+UseZGC

JDK 17 的 ZGC 是生产特性,但仍是单代/非分代 ZGC。

核心思路:

  • Region/Page 化堆;
  • 并发标记;
  • 并发转移;
  • 染色引用/指针元数据;
  • 加载屏障与引用自愈;
  • 极短的根处理和阶段切换暂停。
flowchart LR
    A[短暂停:处理根] --> B[并发标记]
    B --> C[短暂停:阶段切换]
    C --> D[并发转移准备]
    D --> E[并发转移]
    E --> F[加载屏障发现旧引用]
    F --> G[查转发表并自愈]

不要死记视频文字稿中“高 4 位分别是什么”的具体位布局。ZGC 的引用元数据布局与平台、压缩引用策略、JDK 版本和实现演进有关。应该记住的是:

ZGC 把一部分 GC 状态编码到引用及配套元数据中,并通过屏障在访问路径上完成检查、重定位和修复。

ZGC 的主要问题不是长 STW,而是:

  • 并发回收需要足够 CPU;
  • 需要足够堆余量,让业务在线程继续分配时 GC 能追上;
  • 复制和屏障会消耗内存带宽与吞吐;
  • 堆太紧时可能出现 Allocation Stall。

7.5 Shenandoah

常见启用方式:

1
-XX:+UseShenandoahGC

Shenandoah 与 ZGC 的共同目标是把对象转移/压缩也尽量放到并发阶段。它采用 Region 化堆、并发标记、并发疏散、引用更新与加载引用屏障等机制。

典型风险:

  • GC 追不上分配速率;
  • 进入 Degenerated GC;
  • 进一步退化到 Full GC;
  • 并发屏障和复制带来吞吐成本。

注意:Shenandoah 是否包含在你的 JDK 中与发行版、平台有关。不能只看 java -version 写着 OpenJDK 就默认一定存在,应直接用目标发行版验证。

7.6 Epsilon:不回收的 GC

启用:

1
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC

Epsilon 只负责分配,不回收。堆用完后直接 OOM。

适用场景:

  • 短生命周期任务,进程结束即释放全部内存;
  • 测量“没有 GC 干扰时”的理论上限;
  • 验证对象分配量和内存预算;
  • 极端低抖动但内存边界明确的特殊任务。

它不是“最快生产 GC”的万能答案。让垃圾一直堆着,最后一次性被 OOM 清场,这种策略和把房间垃圾都塞床底没有本质区别。

八、JDK 21:Generational ZGC 加入

JDK 21 的核心 GC 变化是 JEP 439:Generational ZGC。

启用方式:

1
-XX:+UseZGC -XX:+ZGenerational

8.1 为什么 ZGC 也要分代

非分代 ZGC 每轮需要面对整个堆的对象图。分代后:

  • 新对象进入年轻代;
  • 年轻代更频繁回收;
  • 老对象进入老年代;
  • 老年代低频处理;
  • 通过存储屏障记录老到新的跨代引用。
flowchart LR
    A[新对象] --> Y[Young Generation]
    Y -->|大多死亡| F[快速回收]
    Y -->|长期存活| O[Old Generation]
    O -->|低频回收| R[Old Collection]
    O -->|引用年轻对象| B[Store Barrier / Remembered Info]
    B --> Y

Generational ZGC 不是简单把传统 Eden/Survivor/Old 原样搬进 ZGC,而是在 ZGC 并发转移、染色引用和屏障体系上加入代际管理。

8.2 JDK 21 与虚拟线程

虚拟线程不会自动让 GC 变轻:

  • 更高并发可能提高瞬时对象分配速率;
  • 请求上下文、缓冲区和异步链路可能扩大 Live Set;
  • 虚拟线程栈块位于堆中,会参与 GC;
  • 大量平台线程减少后,Native Thread Stack 压力可能下降,但堆压力可能变化。

迁移到虚拟线程时,应重新测量分配率、存活集和尾延迟,而不是沿用平台线程时代的堆参数。

九、从 JDK 21 到 JDK 25:GC 演进补充

9.1 JDK 22:G1 Region Pinning

JEP 423 改进了 G1 在 JNI Critical Region 等对象被固定(Pinned)场景下的处理,让 G1 能在存在固定 Region 时继续回收其他 Region,而不是轻易让整个 GC 机制失去行动能力。

9.2 JDK 23:Generational ZGC 成为 ZGC 默认模式

JEP 474 把 Generational ZGC 设为 UseZGC 的默认模式,同时弃用非分代模式。

9.3 JDK 24:非分代 ZGC 被移除

JEP 490 移除了非分代 ZGC,并废弃/移除 ZGenerational 选项。

因此 JDK 24、25 中:

1
-XX:+UseZGC

就已经是分代 ZGC,不要再照抄 JDK 21 参数加 -XX:+ZGenerational

JDK 24 的 JEP 475 还把 G1 屏障展开推迟到编译过程更后期,目标是减少 JIT 生成屏障代码的开销并改善吞吐。

9.4 JDK 25:G1、ZGC、Shenandoah 的关键状态

JDK 25 的几个重要变化:

  1. JEP 523:G1 在所有环境中成为默认收集器,而不再只依赖传统 Server-Class 判断;
  2. JEP 521:Generational Shenandoah 从实验特性升级为产品特性;
  3. ZGC:只保留分代模式;
  4. JEP 519:Compact Object Headers 成为产品特性,可通过 -XX:+UseCompactObjectHeaders 开启。

Generational Shenandoah 的启用示例:

1
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational

JDK 25 中它是产品特性,但尚不是 Shenandoah 的默认模式;后续版本才继续推进“分代模式默认化”。

Compact Object Headers 不是一个 GC,但会减少对象头大小,从而影响:

  • 分配速率折算后的字节量;
  • Live Set;
  • 缓存局部性;
  • 同一堆大小能容纳的对象数量;
  • GC 需要扫描和复制的数据量。

它仍需用真实对象模型和业务负载验证,不能只因为“每个对象少几个字节”就直接上线。

十、四个版本的收集器总表

“可用”以 HotSpot/OpenJDK 主线为准;Shenandoah 等特性的实际可用性依赖 JDK 发行版和平台。

收集器 JDK 8 JDK 17 JDK 21 JDK 25 主要目标
Serial / Serial Old 可用 可用 可用 可用 小堆、低固定开销
Parallel Scavenge / Parallel Old 可用,Server 常见默认 可用 可用 可用 吞吐量
ParNew + CMS 可用 已移除 已移除 已移除 JDK 8 低停顿旧方案
G1 可用 默认主力 默认主力 所有环境默认 平衡吞吐、停顿和内存
ZGC 不可用 可用,非分代 可用,分代需显式开启 可用,仅分代 极低 GC 停顿、大堆
Shenandoah 主线 JDK 8 无 部分发行版可用 部分发行版可用,非分代 分代模式为产品特性 低停顿、并发疏散
Epsilon 不可用 实验特性 实验特性 实验特性 不回收、基准与特殊任务

10.1 版本演进图

flowchart LR
    J8[JDK 8] -->|Parallel 常见默认| P[Parallel]
    J8 --> CMS[CMS 可用]
    J8 --> G18[G1 可用但较早期]
    J9[JDK 9] -->|默认切换| G1[G1]
    J14[JDK 14] -->|移除| CMSX[CMS]
    J15[JDK 15] --> ZP[ZGC / Shenandoah 生产化]
    J17[JDK 17 LTS] --> G117[G1 主力 + 非分代 ZGC]
    J21[JDK 21 LTS] --> GZ[Generational ZGC 可选]
    J23[JDK 23] --> GZD[Generational ZGC 默认]
    J24[JDK 24] --> NZX[移除非分代 ZGC]
    J25[JDK 25 LTS] --> G125[G1 全环境默认]
    J25 --> GS[Generational Shenandoah 产品化]

十一、如何选 GC:先看业务,不要看名字的新旧

flowchart TD
    A[开始选型] --> B{任务是否短命且内存上限明确}
    B -->|是| C{是否需要回收}
    C -->|不需要| D[Epsilon 仅限特殊场景]
    C -->|需要| E[Serial 或默认 G1]
    B -->|否| F{是否以吞吐量为第一目标}
    F -->|是| G[Parallel GC 与 G1 对比压测]
    F -->|否| H{是否有严格尾延迟目标}
    H -->|否| I[优先默认 G1]
    H -->|是| J{能否提供额外 CPU 和堆余量}
    J -->|是| K[ZGC / Shenandoah / G1 实测]
    J -->|否| L[先减少分配和 Live Set,再谈换 GC]

11.1 推荐起点

  • 普通 Spring Boot 服务:先用当前 JDK 默认 G1;
  • JDK 8 老系统:Parallel 吞吐稳定就不必为了“先进”强换;CMS 系统应规划升级;
  • 离线批处理:Parallel GC 值得作为主要候选;
  • 延迟敏感、大堆服务:在 JDK 21/25 上评估 Generational ZGC;
  • 需要低停顿且发行版支持:评估 Shenandoah;
  • 小堆、短任务:Serial 可能更简单有效;
  • 测试无 GC 上限:Epsilon。

11.2 ZGC 和 Shenandoah 不等于“天然更快”

它们优化的是停顿,不是无条件提高 QPS。若服务:

  • CPU 已经打满;
  • 堆余量很小;
  • 内存带宽紧张;
  • 对象分配率极高;
  • 延迟主要来自数据库或锁竞争;

换低停顿 GC 可能只会让 GC 日志更漂亮,而业务吞吐变差。

十二、GC 调优的方法论

12.1 第一步:定义业务目标

至少明确:

  • P50、P95、P99、P999 延迟;
  • 请求超时阈值;
  • 每秒吞吐量;
  • 单实例 CPU 与内存预算;
  • 启动时间;
  • 可接受的最大停顿;
  • 容器是否允许突发 CPU;
  • 扩缩容和故障转移时间。

只说“GC 要快”无法调优。你要的是 QPS、尾延迟,还是更低机器成本?三者经常互相打架。

12.2 第二步:建立基线

固定以下变量:

  • JDK 发行版、完整版本号和构建号;
  • GC 类型和 JVM 参数;
  • CPU 核数、NUMA、内存、Huge Page;
  • 容器 Request/Limit;
  • 流量模型和数据规模;
  • 预热时间;
  • 测试持续时间;
  • 上游、下游和数据库版本。

同一配置至少跑多轮,观察平均值和尾部,而不是挑一张最好看的图。

12.3 第三步:先看五个核心量

1. Allocation Rate

单位时间分配了多少字节。高分配率会导致年轻代频繁回收,也会压缩并发 GC 的追赶窗口。

2. Live Set

完成一次有代表性的老年代/全堆回收后,仍然存活的数据规模。Xmx 至少要容纳 Live Set,还必须保留业务继续分配和 GC 搬迁所需的余量。

3. Promotion Rate

单位时间有多少对象进入老年代。Promotion Rate 高,可能意味着:

  • Survivor 太小;
  • 中生命周期对象过多;
  • 缓存/批处理对象跨越多个 Young GC;
  • 年轻代设置或自适应策略不合适。

4. Reclaim Rate

GC 单位时间能回收多少内存。如果长期小于 Allocation Rate,系统最终一定会 Allocation Stall、Full GC 或 OOM。

5. Post-GC Baseline

每轮回收后的最低占用是否持续上涨。持续上涨往往比“某次 GC 很长”更危险,可能指向:

  • 无界缓存;
  • 队列堆积;
  • ClassLoader 泄漏;
  • ThreadLocal 泄漏;
  • 监听器未注销;
  • 大量长期连接或 Session;
  • 真正的内存泄漏。

12.4 第四步:先改代码和数据结构,再拧 JVM 旋钮

调优优先级建议:

  1. 修复泄漏和无界结构;
  2. 降低对象分配率;
  3. 缩短对象生命周期;
  4. 避免无意义的大对象和中间集合;
  5. 确定合理堆大小与进程总内存;
  6. 选择合适 GC;
  7. 最后才调整具体 GC 参数。

典型代码优化包括:

  • 流式处理替代一次性加载全部数据;
  • 避免在热点循环中创建临时集合和字符串;
  • 控制批次大小;
  • 复用缓冲区,但不要制造失控对象池;
  • 缓存设置容量和过期策略;
  • 避免把大对象挂到静态单例或 ThreadLocal;
  • 使用 Primitive Collection 或更紧凑的数据结构时做实际验证。

12.5 第五步:一次只改一个主要变量

比如对比 G1 和 ZGC 时,不要同时:

  • 换 JDK;
  • 改堆大小;
  • 改线程池;
  • 改数据库连接池;
  • 改业务代码;
  • 改容器 CPU Limit。

否则得到的不是实验,是一锅性能玄学麻辣烫。

十三、堆大小不是越大越好

13.1 Xmx 必须覆盖什么

对于并发 GC,最大堆至少要覆盖:

1
2
3
4
5
Live Set
+ 并发回收期间的新分配
+ 对象疏散/复制空间
+ 碎片或 Region 预留
+ 流量突发余量

堆太小:GC 追不上,频繁回收或分配停顿。

堆太大:

  • 故障时 Heap Dump 巨大;
  • 缓存可能无限膨胀;
  • Full GC 或退化路径更重;
  • 容器密度下降;
  • 问题被大堆延迟暴露。

13.2 Xms = Xmx 不是绝对真理

优点:

  • 避免运行时扩容抖动;
  • 行为更稳定;
  • 适合固定容量、延迟敏感服务。

缺点:

  • 更高内存承诺;
  • 降低容器密度;
  • 配合 AlwaysPreTouch 会增加启动时间和初始 RSS;
  • 对弹性负载可能浪费内存。

13.3 Java 堆不等于进程内存

进程总内存还包括:

  • Metaspace;
  • Code Cache;
  • 线程栈;
  • Direct Buffer;
  • JNI/本地库;
  • GC 自身数据结构;
  • JIT、符号表、类加载器元数据;
  • mmap 文件和共享库。

因此在 4 GB 容器中设置 -Xmx4g,通常是在邀请 OOM Killer 来参加团建。

十四、日志与诊断命令

14.1 JDK 8 GC 日志模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java \
-Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintTenuringDistribution \
-XX:+PrintGCApplicationStoppedTime \
-Xloggc:/var/log/app/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=10 \
-XX:GCLogFileSize=100M \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heap.hprof \
-jar app.jar

14.2 JDK 17/21/25 统一日志模板

1
2
3
4
5
6
7
8
java \
-Xms4g -Xmx4g \
-XX:+UseG1GC \
'-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M' \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heap.hprof \
-XX:ErrorFile=/var/log/app/hs_err_pid%p.log \
-jar app.jar

14.3 JDK 21 Generational ZGC 模板

1
2
3
4
5
6
java \
-Xms8g -Xmx8g \
-XX:+UseZGC \
-XX:+ZGenerational \
'-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M' \
-jar app.jar

14.4 JDK 25 ZGC 模板

1
2
3
4
5
java \
-Xms8g -Xmx8g \
-XX:+UseZGC \
'-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=100M' \
-jar app.jar

JDK 25 不要再添加 -XX:+ZGenerational

14.5 常用在线诊断命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 查看真实 JVM 版本和发行版
java -version

# 启动一个最小 JVM,查看默认选择的关键参数(JDK 8/部分版本)
java -XX:+PrintCommandLineFlags -version

# 查看 JVM 最终参数
jcmd <pid> VM.flags

# 查看堆概要
jcmd <pid> GC.heap_info

# 查看类实例直方图,可能有一定影响
jcmd <pid> GC.class_histogram

# 启动 10 分钟 JFR
jcmd <pid> JFR.start \
name=gc-analysis \
settings=profile \
duration=10m \
filename=/tmp/gc-analysis.jfr

# 已启用 NMT 时查看原生内存
jcmd <pid> VM.native_memory summary

# 快速查看各代利用率
jstat -gcutil <pid> 1000

Heap Dump、Live Histogram 等操作可能引发明显停顿或 IO 冲击。生产执行前要评估磁盘空间、实例冗余和流量摘除方案。

十五、各收集器常见故障信号

15.1 Parallel GC

重点看:

  • Young GC 是否过于频繁;
  • Full GC 停顿;
  • Promotion Failure;
  • Full GC 后老年代占用是否仍高;
  • user + sysreal 的异常关系,排查 CPU 限流、Swap、宿主机争抢。

15.2 CMS

重点看:

  • concurrent mode failure
  • promotion failed
  • 老年代碎片;
  • CMS 周期是否启动过晚;
  • Remark 停顿;
  • 并发阶段 CPU 抢占。

15.3 G1

重点看:

  • to-space exhausted
  • Evacuation Failure;
  • Humongous Region 数量;
  • Concurrent Start 是否太晚;
  • Mixed GC 是否回收效果差;
  • Full GC;
  • RSet Update/Scan 时间;
  • Object Copy 时间;
  • Safepoint 同步时间是否远大于实际 GC 工作时间。

15.4 ZGC

重点看:

  • Allocation Stall;
  • GC 周期是否跟不上分配;
  • 堆余量是否不足;
  • 并发 GC CPU;
  • 内存带宽;
  • SoftMaxHeapSize 是否与 Xmx 配合合理;
  • 应用延迟是否被 CPU 竞争而非 STW 影响。

15.5 Shenandoah

重点看:

  • Degenerated GC;
  • Full GC;
  • Evacuation Reserve;
  • 并发标记/疏散是否追不上;
  • 分配尖峰;
  • 发行版特定实现和参数差异。

十六、迁移建议

16.1 JDK 8 CMS 迁移到 JDK 17

推荐顺序:

  1. 先在 JDK 8 上建立 CMS 基线;
  2. 升级到 JDK 17,先使用默认 G1;
  3. 不要照搬 CMS 参数;
  4. 对比吞吐、P99、Full GC、CPU、RSS;
  5. 若 G1 尾延迟仍不满足,再评估 ZGC 或支持 Shenandoah 的发行版。

CMS 参数如 CMSInitiatingOccupancyFractionUseCMSInitiatingOccupancyOnly 在 G1 中没有一一对应关系。迁移是重新建立模型,不是参数翻译题。

16.2 JDK 8 Parallel 迁移到 JDK 17

不要默认换成 G1 就一定更快:

  • 批处理任务可继续使用 Parallel;
  • 在线服务可以比较默认 G1;
  • 若旧系统依赖很长但很少的 Full GC 换取高吞吐,G1 可能降低停顿但增加 GC CPU;
  • 最终以任务完成时间和业务延迟为准。

16.3 JDK 17 ZGC 迁移到 JDK 21/25

  • JDK 17:非分代 ZGC;
  • JDK 21:用 -XX:+ZGenerational 显式启用分代;
  • JDK 23:分代成为默认;
  • JDK 24/25:非分代模式移除,删除旧参数。

迁移后应重新测量年轻代大小自适应、晋升行为、并发线程和堆余量,不要只看停顿是否仍低。

十七、常见误区纠正

17.1 “对象没有引用就是垃圾”

更准确的说法是:对象从 GC Roots 不可达。互相循环引用的对象也可以被追踪式 GC 回收。

17.2 “Minor GC 只扫描年轻代”

年轻代回收不能忽略老年代到年轻代的引用,因此需要 Card Table、RSet 或其他跨代引用记录。它不必扫描整个老年代,但也不是完全与老年代无关。

17.3 “Major GC 就是老年代 GC,Full GC 就是整堆 GC”

Major GC 在不同收集器、工具和文章中的含义并不统一。分析时应以 GC 日志中的具体事件和收集器阶段为准,不要只凭名词下结论。

17.4 “G1 会严格保证 200ms”

-XX:MaxGCPauseMillis 是软目标。对象复制量、根集合、RSet、引用处理、系统调度和硬件状态都可能让实际停顿超出目标。

17.5 “ZGC 永远小于 1ms”

官方描述强调 GC 暂停目标很低且与堆大小弱相关,但:

  • 不同 JDK、平台、根集合规模和系统负载会变化;
  • 应用端到端延迟可能远高于 GC pause;
  • Allocation Stall、CPU 抢占和内存带宽仍会影响业务。

不要把营销式数字当 SLA。

17.6 “换 GC 可以解决内存泄漏”

GC 只能回收不可达对象。只要泄漏对象仍被静态集合、缓存、线程、监听器或 ClassLoader 可达,换任何 GC 都不会回收。

17.7 “堆越大,GC 越少,所以越好”

堆大可能降低频率,但也会提高内存成本、故障分析成本和退化路径代价。真正要看的是 Live Set、分配率、回收率和业务 SLO。

十八、一个可落地的生产检查清单

上线前

  • 确认 JDK 发行版、版本和目标平台;
  • 确认实际 GC,而不是猜默认值;
  • 开启 GC 与 Safepoint 日志;
  • 设置 OOM Heap Dump 与错误日志目录;
  • 预留足够磁盘;
  • 明确容器内存中堆与非堆预算;
  • 做至少一轮持续压测和流量尖峰测试;
  • 观察 P99/P999,而不是只看平均值。

运行中

  • Allocation Rate;
  • Live Set;
  • Promotion Rate;
  • Post-GC Baseline;
  • GC CPU 和暂停分布;
  • Full/Degenerated/Allocation Stall;
  • Humongous、大数组和大字符串;
  • Metaspace、Direct Memory、线程栈和 RSS;
  • 容器 CPU throttling、Swap、缺页和节点争抢。

出问题时

  1. 先判断是堆、非堆还是系统资源;
  2. 再判断是分配率、存活集还是回收能力;
  3. 确认是否有 Full GC、退化回收或 Allocation Stall;
  4. 用 JFR 找分配热点;
  5. 用 Histogram/Heap Dump 找存活对象来源;
  6. 修代码和容量模型;
  7. 最后再换 GC 或调参数。

十九、最终总结

把 Java GC 的演进压缩成一句话:

从“停下业务、扫描整堆、一次性清理”,逐步演化为“按代和 Region 管理、并发追踪、并发搬迁,并通过屏障修正不断变化的对象图”。

从算法角度看:

  • Serial 是简单的串行复制与压缩;
  • Parallel 用多 GC 线程换吞吐;
  • CMS 用并发标记-清除换更短老年代停顿,却承担碎片和失败退化;
  • G1 用 Region、RSet、SATB 和疏散,在吞吐与停顿之间取得通用平衡;
  • ZGC 用染色引用、屏障和并发转移追求极低停顿,并在 JDK 21 以后走向分代;
  • Shenandoah 同样把疏散与引用更新并发化,并在 JDK 25 把分代模式产品化;
  • Epsilon 则提醒我们:不回收也是一种有边界的实验工具。

真正的 GC 调优不是收集器排名,而是:

1
2
3
4
5
6
7
业务 SLO
→ 分配率与 Live Set
→ 回收能力与系统资源
→ 选择收集器
→ 日志与 JFR 验证
→ 小步实验
→ 以端到端结果决定

默认 G1 是一个很好的起点,但不是信仰;ZGC 和 Shenandoah 是低延迟工具,但不是免费午餐;Parallel 并没有过时,Serial 也不是废物。能把工作负载、算法代价和版本差异对应起来,才算真正理解 GC。

参考资料

  1. B 站:一小时速通所有 Java GC
  2. 程序猿有风:Java GC 全系列一小时速通教程
  3. 图灵社区:《垃圾回收的算法与实现》
  4. Oracle JDK 8 Garbage Collection Tuning Guide
  5. Oracle JDK 17 Garbage Collection Tuning Guide
  6. Oracle JDK 21 Garbage Collection Tuning Guide
  7. Oracle JDK 25 Garbage Collection Tuning Guide
  8. JEP 248:Make G1 the Default Garbage Collector
  9. JEP 307:Parallel Full GC for G1
  10. JEP 318:Epsilon
  11. JEP 333:ZGC Experimental
  12. JEP 363:Remove CMS
  13. JEP 377:ZGC Production
  14. JEP 379:Shenandoah Production
  15. JEP 423:Region Pinning for G1
  16. JEP 439:Generational ZGC
  17. JEP 474:ZGC Generational Mode by Default
  18. JEP 475:Late Barrier Expansion for G1
  19. JEP 490:Remove Non-Generational ZGC
  20. JEP 519:Compact Object Headers
  21. JEP 521:Generational Shenandoah
  22. JEP 523:Make G1 the Default Garbage Collector in All Environments
  23. OpenJDK Shenandoah Project

Java GC 全景指南:从垃圾回收算法到 JDK 8、17、21、25
https://allendericdalexander.github.io/2026/07/26/java/jvm/java-gc-algorithms/
作者
AtLuoFu
发布于
2026年7月26日
许可协议