跳转至

Java GC:从入门到入土

Java / JVM · 2026 年 9 月 24 日 · 含交互实验与可运行代码

对象还在被引用,为什么可能漏标?对象搬走了,程序手里的旧地址怎么办?把这两个问题看清,CMS、G1、ZGC 和 Shenandoah 就能串成一条线。

本文讨论 HotSpot 的垃圾收集器。先纠正一个容易被旧资料带偏的事实:Shenandoah 在 JDK 15 已经转为生产可用,分代 Shenandoah 在 JDK 25 也已成为正式特性。功能正式发布、默认启用、某个发行版是否包含,是三个独立问题。JEP 379、JEP 521

阅读时先抓住两件事:GC 要知道哪些对象需要保留,还要把其他对象占用的空间变成可以继续分配的内存。 复杂度主要来自应用与 GC 同时工作:应用持续改引用、分配对象、读写字段,GC 必须在这些变化里维持正确性。

交互模型与真实日志

文中的交互图是机制模型,省略实现中的过滤条件、批处理和部分阶段。图里的地址与时间预算均为示意;文末另有明确标注环境的真实运行日志。

从“谁还活着”开始

可达性决定保留,引用个数不够用

从一组 GC Roots 出发,沿强引用能够到达的对象,需要被保留。线程栈中仍然活跃的引用、静态字段关联的引用、JNI 持有的引用等,可以成为这张图的入口。这里先只讨论普通强引用;软引用、弱引用等有额外的处理规则。

下面 B、C 相互引用,D、E 也相互引用。试着切断两个 Root 的入口,观察哪些对象还可达。

引用成环,也需要入口

实验 1 / 6

静态结论:初始状态中 A、B、C 可达;D、E 相互引用但没有 Root 入口,均不可达。启用 JavaScript 可以切换入口。

对象互相引用,不足以证明它们活着。一组对象整体失去 Root 的入口后,即使内部仍有环,也可以被追踪式 GC 识别为垃圾。反过来,一个业务上已经没用的对象,只要仍被长期存活的集合引用,就可能一直得不到回收。

“可达”解决的是保留条件,“业务是否还需要”则需要程序自己表达清楚。GC 无法猜出某个缓存条目已经失去业务价值。

找到垃圾之后,内存怎样腾出来

假设这是一段堆空间:

回收前       [ A 活 ][ 垃圾 ][ B 活 ][ 垃圾 ][ 垃圾 ][ C 活 ]
原地清扫后   [ A 活 ][ 空闲 ][ B 活 ][ 空闲 ][ 空闲 ][ C 活 ]
搬迁整理后   [ A 活 ][ B 活 ][ C 活 ][       连续空闲       ]

清扫 释放垃圾所在的块,存活对象保持原位;复制或整理 移动存活对象,把空闲空间集中起来。两种路线各有成本:清扫需要管理分散的空闲块;搬迁需要复制对象,并处理所有可能受地址变化影响的引用。

“先标记,再回收”是方便理解的逻辑分解。具体算法可以把工作合在一起,例如典型年轻代复制回收会在遍历中发现并搬迁存活对象,不必先完成一轮独立的全堆标记。

分代与分区,是两个维度

分代 利用对象寿命的统计规律:许多对象很快死亡,已经活了很久的对象往往还会继续存活。频繁处理年轻代、较少处理老年代,可以避免反复扫描大量长寿对象。

分区 把堆组织成多个区域。一个区域承担什么代的角色、是否参与本轮回收,可以按收集器的设计决定。

G1 同时使用分代和 Region。早期 ZGC、Shenandoah 虽然使用区域组织内存,主线实现却没有年轻代与老年代的划分。后来加入分代,是对对象寿命规律的进一步利用。

为什么要暂停,为什么又想并发

暂停应用线程后,GC 面对的是相对稳定的对象图,搬迁对象时也不用担心应用恰好修改旧副本。Serial、Parallel 的主要回收工作可以在这种条件下完成。

这里需要分清:

并行
多个 GC 线程一起工作。Parallel GC 可以一边使用多个核心,一边让应用处于 STW。
并发
GC 与应用线程在同一时期推进。应用获得了运行机会,同时也与 GC 竞争 CPU、内存带宽和堆空间。
STW,Stop The World
相关 Java 应用线程在安全点协议下暂停。它不表示操作系统或整台机器停止运行。

应用停下来的次数、每次停多久、GC 总共消耗多少 CPU,是不同指标。一轮 GC 可以运行很久,但其中只有几个短暂停顿;也可以很快完成,却把主要工作集中在一次较长暂停中。

降低暂停的两条路线

降低暂停的两种重要办法:减少一次暂停里必须做的工作,或者把工作移到应用仍然运行的阶段。G1 重点使用前一种办法处理搬迁,ZGC、Shenandoah 进一步使用后一种。

选择收集器,最终是在暂停、吞吐、内存占用与并发开销之间做取舍。名字更晚出现,不能单独证明它更适合当前负载。

并发标记:两行代码怎样让活对象漏掉

三色是遍历状态

为了描述进度,把对象分成三种状态:

状态 含义
白 还没有被发现
灰 已被发现,但引用还没有扫描完
黑 已被发现,引用也已扫描完

在图不变化的情况下,GC 不断处理灰对象,把它们指向的白对象找出来,最后把自己变黑。当没有待处理对象时,剩下的白对象就是这次遍历没有到达的对象。

这些颜色是算法描述,不要求每个对象里真的存在一个颜色字段。

最短的漏标反例

假设 A 已经是黑色,B 还是灰色,C 是白色。A 暂时没有指向 C;B 有一条 B → C。

应用执行:

漏标反例:先建立新引用,再删除旧引用
a.ref = b.ref;  // 建立 A → C
b.ref = null;   // 删除 B → C

C 仍然可达,因为 A 引用它。但 GC 随后扫描 B 时,已经看不到 C;A 的字段又已经扫描过。如果没有额外记录,C 就可能保持白色,最终被误当作垃圾。

先用“不记录变化”走完四步,再保持最后一步切换 IU 与 SATB,比较它们分别保存了什么。

同一个 C,两种补救方式

实验 2 / 6

静态结论:新增黑对象到白对象的引用,同时切断灰对象到该白对象的路径,会造成漏标风险。IU 关注新增关系,SATB 记录被覆盖的旧引用。

增量更新 IU 从新增引用关系入手。上面的 A → C 建立后,留下需要补查的信息,使 GC 能够发现 C。CMS 可以沿这个思路理解;实际实现通过写屏障、脏卡记录和重新扫描等机制完成,不能把图里的队列当成源代码原样实现。

SATB,Snapshot At The Beginning 从被删除的旧引用入手。在 B → C 断开之前记录 C,让标记开始时可达的对象仍能被发现。G1、Shenandoah 的常规 SATB 模式,以及分代 ZGC,都采用了这个方向的机制。

下面是概念伪代码:

SATB 写屏障的概念伪代码
Object oldValue = holder.ref;

if (concurrentMarkingIsActive) {
    recordForMarking(oldValue);  // 保存即将被覆盖的旧引用
}

holder.ref = newValue;

真实实现还要判断对象和当前阶段、写入线程本地缓冲区、批量处理记录,并遵守并发协议。应用源码中的一次引用赋值,最终可以被 JVM 扩展成“赋值 + GC 所需的少量协作代码”。这就是这里讨论的 GC 写屏障。

GC 屏障与 volatile 相关的内存排序屏障属于不同层面的概念;实现 GC 协议时,可能同时需要内存顺序保证。

SATB 的“快照”保守在哪里

SATB 维护“标记开始时可达的对象都能被发现”的效果,不需要复制整个堆。如果某个对象在标记开始时活着,后来失去所有引用,本轮仍可能保留它,下一轮再回收。这是为并发正确性付出的保守成本。

标记期间新分配的对象也需要明确的存活处理规则,不能因为它们不在初始快照中就随意回收。具体规则由收集器实现决定。

先理解“引用变化需要留下证据”,再看写屏障、SATB 队列和重新标记,这些名词就都有了工作对象。G1 标记说明、分代 ZGC 的 SATB 设计

CMS:并发标记之后,原地清扫

CMS 的全称是 Concurrent Mark Sweep。它主要负责老年代;经典组合中,年轻代由 ParNew 回收,年轻代复制仍需要暂停。

先用四个阶段抓住正常周期:

  1. 初始标记,STW:建立标记起点。
  2. 并发标记:应用继续执行,GC 遍历存活对象图。
  3. 重新标记,STW:处理并发期间的变化,完成标记。
  4. 并发清扫:原地释放垃圾占用的空间。

实际日志还可能有 preclean 等阶段,它们不改变这条主线。CMS 官方说明

下面把阶段顺序与应用线程的状态放在一起。行高相同只是排版,各阶段的真实耗时并不相同;G1 的并发周期与后续 Mixed GC 也被合并展示。

GC 在工作,应用一定停着吗?

实验 3 / 6

静态结论:CMS 并发标记与清扫,G1 并发标记但常规疏散暂停应用,ZGC 与 Shenandoah 还可以并发搬迁。它们都保留短暂 STW 阶段。

主要阶段示意,不是完整日志模板,也不按实际耗时比例绘制。版本、回收类型和异常路径会改变细节。

三个问题,都能从设计推出来

碎片
正常 CMS 周期不整理老年代。假设有四块各 1 MiB 的空闲块,一次分配需要连续 3 MiB,仅凭“空闲总量有 4 MiB”无法证明分配能够成功。这里讨论的是相应堆分配器所需的连续空间,不要混同操作系统物理页必须连续。
浮动垃圾
已经被标记为存活的对象,可能在并发过程中死亡。GC 通常不会为了立即精确回收它而推翻已经完成的工作,部分垃圾因此留到下一轮。
回收追不上
应用在并发回收期间还会分配、晋升对象。CMS 必须提前启动,并为回收完成前的增长留出余量;老年代空间耗尽而并发周期还没有完成,就可能出现 concurrent mode failure,进入停顿式处理。

并发没有消除工作量。CMS 用更多后台协调换取较短的正常暂停,也承担碎片管理和回收时机预测的压力。它在 JDK 9 被弃用,JDK 14 从主线移除;学习它的价值,是看清后面几种设计分别补了哪一块。JEP 291、JEP 363

G1:把搬迁工作拆成可选择的 Region

Region 让局部回收成为组织方式

G1 把堆划分成多个等大的 Region。年轻代、老年代依然存在,但分别由一组 Region 组成,位置可以分散;空闲 Region 后续可以承担新的角色。

[ Eden ][ Old  ][ Free ][ Survivor ]
[ Old  ][ Eden ][ Old  ][ Free     ]

回收某个普通 Region 时,把仍然存活的对象疏散到目标 Region,原 Region 就可以整体重新利用。年轻对象按年龄等条件进入 Survivor 或老年代,老年代对象迁入其他老年代空间。特殊对象还有额外规则。

这把“整个老年代一起处理”的问题,转换成“本次挑哪些 Region”。

Garbage First 的收益怎么算

两个 Region 都是 10 MiB:R1 只有 1 MiB 活对象,R2 有 9 MiB 活对象。把 R1 腾空,需要复制的对象少,净回收空间大;R2 则相反。

真实的选择还要考虑引用扫描、复制速度、暂停预算以及进度要求。下面的模型用一个明确、简化的公式帮助观察选择过程:

每个 Region 大小:10 MiB
必须处理的年轻代工作:5 ms
老年代 Region 的预计成本:1 ms 扫描成本 + 存活百分数 × 0.06 ms
优先级:预计净回收空间 ÷ 预计成本

试两件事:先把预算拉到 4 ms,再把 R2 的存活率拉到 0%。观察目标无法满足和优先级变化的原因。

给定预算,哪些 Region 值得搬?

实验 4 / 6

静态结论:11 ms 的示意预算中,5 ms 用于年轻代,R1 和 R3 共用 4.1 ms,预计净回收 16.5 MiB。剩余预算不足以再加入 R2。

这是教学用的收益/成本模型,数值为人工设定;不包含真实 G1 的最低回收进度、可选集合等完整启发式。

因此,-XX:MaxGCPauseMillis=50 给出的是优化目标。G1 会尝试调整工作量以接近目标,但一次复制或扫描的实际代价无法完全提前确定;过小的目标也不能消除必须完成的工作。G1 回收集合与暂停控制

并发标记完成,还没有把老年代垃圾全部收走

G1 正常运行时会发生 Young GC。当需要准备老年代回收时,会启动并发标记周期,获得老年代 Region 的存活信息,再在后续 Mixed GC 中逐批处理合适的老年代 Region。

Young GC → 启动并发标记 → 得到老年代存活信息
                                  ↓
                    Mixed GC → Mixed GC → …

Young GC 主要回收年轻代;Mixed GC 回收年轻代,并加入选中的部分老年代 Region。Mixed GC 不等于 Full GC。

两类常规疏散暂停都会移动对象。G1 的并发标记提供信息,而常规疏散——复制对象、处理相关引用——仍然发生在 STW 中。并发周期还有 Remark、Cleanup 等阶段,期间也可能穿插年轻代回收。

“G1 并发整理整个堆”会把两种不同阶段混在一起。更准确的理解是:并发收集老年代存活信息,在后续暂停中分批疏散对象。

只回收一部分,外面的引用怎样找

假设本轮只回收 Young Region,另一个 Old Region 中却有:

oldObject.child = youngObject;

GC 必须找到这条外部入口,否则可能误回收 youngObject;每次遍历整个老年代寻找入口,又会抵消局部回收的收益。

Remembered Set,记忆集 记录相关跨 Region 引用的信息,使 GC 能定位“回收集合外面有哪些地方可能引用了这里”。写屏障通过卡表等机制留下引用变化记录,再由后续处理完善这些信息。卡表把地址范围压缩成较粗粒度的记录,不是每次赋值都维护一张完整对象关系表。

同一次引用写入,可以服务于两类不同的任务:

机制 它回答的问题
SATB 记录 并发标记时删掉了一条边,怎样保证原先可达的对象不会漏掉?
卡表、记忆集相关记录 只回收这一部分时,怎样找到外部指向这里的引用?

理解任务差异,比把两个名词都背成“写屏障的作用”更重要。JDK 26 的 G1 双卡表优化,主要减少应用写屏障与后台处理之间的同步开销,保留了 G1 的整体收集方式。JEP 522

G1 仍然会遇到难题

疏散需要目标空间。目标空间不足,或对象暂时不能移动时,可能发生 evacuation failure;回收无法腾出足够空间时,还可能进入 Full GC。JDK 10 已把 G1 的 Full GC 改为并行实现,但它仍然是需要关注的长暂停路径。JEP 307

大对象也有单独路径。大小超过 Region 一半的对象按 Humongous 对象处理,分配到连续的 Region 序列;尾部浪费与连续区域需求都会影响空间使用。把 Region 搬空,有助于控制碎片,但不能由此推导出“G1 永远不存在碎片或连续空间问题”。

再进一步:对象搬动时,应用还要读写它

假设程序手中有 p → A@100,GC 把 A 搬到了地址 900。单纯复制内存,还没有完成回收协议。

应用手中:p → A@100
GC 搬迁: A@100 → A@900
下一步:  应用准备读取或修改 A 的字段

至少有两个风险:应用解引用过时地址;应用在复制期间修改旧副本,导致更新丢失。Java 程序还要求对象身份保持一致,不能因为搬迁就把同一个对象当成两个不同对象。

G1 可以在暂停中完成必要处理。ZGC、Shenandoah 通过屏障和搬迁协议,让应用继续执行,并把访问引导到正确位置。

对象搬走了,旧引用怎么办?

实验 5 / 6

静态结论:G1 在暂停中疏散对象并修复引用;ZGC 与 Shenandoah 使用屏障和转发信息支持并发搬迁。应用访问仍须遵守各自的并发协议。

100、900 是符号地址。演示省略竞争搬迁、失败重试和根处理细节;各收集器仍有短暂 STW 阶段。

ZGC:引用携带状态,访问时按需处理

ZGC 的两个代表性机制是 染色指针 与 加载屏障。

染色指针在引用中同时编码对象地址和部分 GC 元数据。加载屏障是 JVM 在加载对象引用的位置插入的代码:检查引用的状态,必要时处理重定位,得到可安全使用的对象地址。

下面只表达职责,不对应真实的 Java API:

加载屏障的概念伪代码
Reference ref = loadReference(slot);

if (needsRelocationHandling(ref)) {
    ref = resolveToCurrentLocation(ref);
    tryRepairReferenceSlot(slot, ref);
}

useObject(ref);

大多数访问走简短的快速路径;确实需要额外工作时,才进入慢速路径。引用处理之后可以更新相关状态或修复引用槽,减少后续重复工作。后台 GC 同样会处理引用,不能理解成“永远等应用碰到才修”。

染色指针的“颜色”是实现层面的状态位,前面三色标记的白、灰、黑是图遍历状态;两者不能机械地一一对应。不同代际的 ZGC 引用布局也有变化,早期的位数、地址布局和多重映射图示不适用于所有版本。JEP 333、JEP 439

分代 ZGC 为什么又加入存储屏障

非分代 ZGC 每轮都要面对整个对象集合。把寿命差异考虑进来后,年轻对象可以更频繁地被回收,老对象则较少处理,从而减少重复扫描和后台 CPU 开销。

JDK 21 引入的分代 ZGC 不只增加了“年轻代”“老年代”两个标签,也重新分配了屏障的职责:

  • 加载屏障 主要处理引用地址,包括解码引用和处理过时地址。
  • 存储屏障 维护跨代引用记录,并承担 SATB 标记所需的旧引用记录等工作。

年轻代单独回收时,需要访问老年代中可能指向年轻代的引用字段。分代 ZGC 使用更精确的字段位置记录与双缓冲记忆集等设计,使两代可以相对独立地推进。分代 ZGC 设计

因此,当前讨论 ZGC 时必须说清版本和模式。把早期非分代 ZGC 的“主要依靠加载屏障”直接套到分代 ZGC,会漏掉重要机制。

ZGC 仍然有 Mark Start、Mark End、Relocate Start 等短暂停顿。JDK 16 把线程栈处理移向并发,也是继续减少暂停内工作量的一步。JEP 376

Shenandoah:并发疏散,再并发更新引用

Shenandoah 的常规主线可以概括为:

并发标记 → 并发疏散活对象 → 并发更新引用

对象搬迁后,转发信息将旧位置关联到新位置。Load Reference Barrier,加载引用屏障 在应用加载相关引用时解析到正确对象;并发更新引用阶段再遍历并修正引用,配合最终的短暂停顿完成收尾。

旧材料常画出 Brooks Pointer,用一个间接指针帮助理解转发关系。这个思路有解释价值,但屏障方案和对象布局已经演变;早期“每个对象额外多一个指针”的布局描述,不宜直接当作当前所有版本的固定结论。后来的实现使用加载引用屏障,普通对象的转发信息也可以放在旧副本的标记字中。开发者对实现演变的说明、Shenandoah 引用屏障说明

Shenandoah 的 SATB 模式同样需要写屏障保证并发标记,分代模式还需要跟踪跨代引用。把它与 ZGC 区分成“一个用写屏障、一个用读屏障”,会遮住两者真实的工作分工。

分代 Shenandoah 在 JDK 24 作为实验功能进入主线,JDK 25 转为正式功能。它仍保留区域化的并发回收路线,并能使用压缩对象指针;是否更适合某个应用,需要结合实际堆规模、对象布局和负载测量。JEP 404、JEP 521

低暂停,也要给并发工作留出资源

可以用一个粗略的容量关系思考压力:

回收完成前的空间需求
≈ 必须保留的对象 + 回收期间的新分配 + 搬迁和碎片等余量

这不是统一的 JVM 配置公式。不同收集器能否原地重定位、什么时候释放区域、怎样限制分配,都会改变所需余量。但共同约束很直接:应用持续消耗空间,GC 必须及时让空间重新可用。

ZGC 空间紧张时,分配线程可能等待;Shenandoah 可以通过 pacing 调节分配速度,必要时进入退化回收或 Full GC。暂停指标很漂亮,也不能单独证明应用没有遭遇分配等待、CPU 竞争或其他安全点延迟。

“低暂停”描述收集器的重要目标,不能当作任何资源条件下的响应时间保证。ZGC 堆容量说明、Shenandoah 项目文档

JDK 演变:正式、默认与分代分别看

下面按 OpenJDK 主线查看。厂商可能回移植功能,也可能在构建时不包含某个收集器;这类差异需要看具体发行版。选择 JDK 15、21、25、27,分别观察状态变化。

把版本放回句子里

实验 6 / 6

静态结论:JDK 15 的 ZGC、Shenandoah 已生产可用;JDK 21 引入分代 ZGC;JDK 25 的分代 Shenandoah 正式可用;JDK 27 将 G1 的默认范围扩展到所有环境。

节点 改变了什么 原始依据
JDK 9 G1 成为服务器配置默认;CMS 弃用 248、291
JDK 10 G1 的 Full GC 并行化 307
JDK 11 / 12 ZGC / Shenandoah 以实验功能进入主线 333、189
JDK 14 / 15 CMS 移除;随后 ZGC、Shenandoah 转为生产可用 363、377、379
JDK 16 ZGC 并发处理线程栈 376
JDK 21 引入分代 ZGC,显式启用 439
JDK 23 选择 ZGC 后,默认使用分代模式 474
JDK 24 移除非分代 ZGC;引入实验性分代 Shenandoah 490、404
JDK 25 分代 Shenandoah 正式可用,默认仍是非分代 521
JDK 26 G1 用双卡表等改变减少同步开销 522
JDK 27 G1 在所有环境默认启用 523
JDK 28,计划 Shenandoah 默认改为分代;截至本文核对日期为 Targeted,尚未发布 535

两处默认变化尤其容易误读:

  • “JDK 23 默认分代 ZGC”限定了 已经选用 ZGC 的前提,没有把整个 JVM 的默认收集器改成 ZGC。
  • “JDK 9 默认 G1”原先限定服务器配置;受限环境仍可能选择 Serial。JDK 27 才把 G1 的默认范围扩展到所有环境,同时保留显式选择 Serial 的能力。

亲手跑一次:从日志里认出这些阶段

配套程序不断分配短命数组,同时用一个固定大小的环形引用数组保留部分对象。它没有显式调用 System.gc(),回收由分配压力触发。

下载 GcLab.java G1 完整日志 ZGC 完整日志 Shenandoah 完整日志

GcLab.java 的分配核心
static final byte[][] retained = new byte[8192][];
static volatile byte[][] recent;

// 完整程序见下载文件。
for (int r = 0; r < 12000; r++) {
    byte[][] batch = new byte[16][];
    for (int j = 0; j < batch.length; j++) {
        batch[j] = new byte[8192];
        batch[j][0] = (byte) r;
    }
    retained[r % retained.length] = batch[0];
    recent = batch;
    if ((r & 31) == 0) Thread.sleep(1);
}

引用被发布到可达字段中,分配结果可以逃逸;环形数组逐步覆盖旧引用,避免把全部分配永久保留下来。这是观察负载,不是严谨的收集器性能基准。

JDK 21 的运行方式

在 GcLab.java 所在目录运行:

javac -encoding UTF-8 GcLab.java
java -XX:+UseG1GC -Xms128m -Xmx128m "-Xlog:gc*" GcLab

找 Concurrent Mark Cycle、Pause Young (Normal) 与 Pause Young (Mixed)。并发标记和后续疏散暂停可以在日志中分别看到。

java -XX:+UseZGC -XX:+ZGenerational -Xms128m -Xmx128m "-Xlog:gc*" GcLab

JDK 21 需要显式启用分代。找 Pause Mark Start、Concurrent Mark、Concurrent Relocate;Y:、O: 标识年轻代、老年代阶段。

java -XX:+UseShenandoahGC -Xms128m -Xmx128m "-Xlog:gc*" GcLab

JDK 构建必须包含 Shenandoah。这条 JDK 21 命令运行的是非分代模式,找 Concurrent evacuation 与 Concurrent update references。

已实际观察到的日志

附带日志来自 Windows x64 / Liberica OpenJDK 21.0.9+11 / 128 MiB 固定堆,三个进程均运行完成。下面是 ZGC 中一个年轻代阶段的真实摘录,省略了时间前缀和部分中间行:

zgc.log · GC(0) · Y
Pause Mark Start (Major)    0.017ms
Concurrent Mark            2.198ms
Pause Mark End             0.011ms
...
Pause Relocate Start        0.009ms
Concurrent Relocate        1.605ms

其中 1.605 ms 是并发重定位阶段耗时,不能解释成应用暂停了 1.605 ms;完整周期也不等于这些暂停的简单相加。后台工作、阶段切换、两代的调度都会影响日志的结构。

G1 的实际日志包含 Mixed GC,也出现过 Evacuation Failure。128 MiB 堆配合约 64 MiB 的保留数组比较紧,这恰好显示了“疏散需要空间”的约束。可以把各条命令的堆都增加到 256 MiB 再观察,但阶段出现频率也会随之变化。

Shenandoah 日志可以分别找到并发疏散与并发更新引用。比较这些阶段名称,有助于对应本文机制;比较某一次的毫秒数,无法得出哪个收集器更快。

新版参数不要照抄旧命令

版本与模式 收集器参数
JDK 21–22,分代 ZGC -XX:+UseZGC -XX:+ZGenerational
JDK 23,默认分代 ZGC -XX:+UseZGC
JDK 24 起,ZGC 只保留分代 -XX:+UseZGC,不再需要 ZGenerational
JDK 24,实验性分代 Shenandoah -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational
JDK 25–27,正式分代 Shenandoah -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational

文末的运行验证仅覆盖上述本机 JDK 21 场景;新版参数依据对应 JEP 核对,没有把它们写成这次机器上已实际运行的结果。

检查自己是否已经理解

下面五个问题不需要背完整阶段名。先用自己的话解释,再展开答案。

C 一直被 A 引用,为什么并发标记仍有漏标风险?

GC 遍历的是随时间变化的图。A 已经扫描完之后才新增到 C 的引用,B 尚未扫描时又丢失到 C 的引用;没有变化记录,就可能错过仍然可达的 C。可达性与 GC 当前是否已经发现它,是不同状态。

G1 标记完老年代,为什么还会继续发生多次 Mixed GC?

标记提供存活信息。后续 Mixed GC 按回收集合逐批疏散对象、释放 Region,把搬迁工作分摊到多次暂停里。

把 G1 暂停目标设为 1 ms,为什么不能保证所有暂停都小于 1 ms?

目标影响预测与选择,但不能消除必要工作,也不能精确预测本次引用扫描和复制成本。空间不足、疏散失败等情况还可能进入其他路径。

ZGC 的 Concurrent Relocate 持续 20 ms,是否说明应用停了 20 ms?

这个字段记录并发阶段耗时。应用可以同时运行;需要另外查看 Pause 阶段、分配等待、安全点与应用请求指标。

Shenandoah 已经生产可用,为什么有些新版 JDK 仍默认非分代?

收集器成熟度、某个模式的成熟度和默认模式分别演进。JDK 15 的 Shenandoah 已生产可用;JDK 25 的分代模式也正式可用,但 JDK 25–27 默认仍为非分代。默认切换由另一个面向 JDK 28 的 JEP 处理。

继续深挖时,先读这些原始资料

读源码之前,先能回答每个结构承担的任务:这条屏障在记录哪种变化,这个集合帮助定位哪些引用,这次暂停到底完成什么工作。名称可以查,因果关系需要自己建立。