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。
应用执行:
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,都采用了这个方向的机制。
下面是概念伪代码:
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 回收,年轻代复制仍需要暂停。
先用四个阶段抓住正常周期:
- 初始标记,STW:建立标记起点。
- 并发标记:应用继续执行,GC 遍历存活对象图。
- 重新标记,STW:处理并发期间的变化,完成标记。
- 并发清扫:原地释放垃圾占用的空间。
实际日志还可能有 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 后续可以承担新的角色。
回收某个普通 Region 时,把仍然存活的对象疏散到目标 Region,原 Region 就可以整体重新利用。年轻对象按年龄等条件进入 Survivor 或老年代,老年代对象迁入其他老年代空间。特殊对象还有额外规则。
这把“整个老年代一起处理”的问题,转换成“本次挑哪些 Region”。
Garbage First 的收益怎么算¶
两个 Region 都是 10 MiB:R1 只有 1 MiB 活对象,R2 有 9 MiB 活对象。把 R1 腾空,需要复制的对象少,净回收空间大;R2 则相反。
真实的选择还要考虑引用扫描、复制速度、暂停预算以及进度要求。下面的模型用一个明确、简化的公式帮助观察选择过程:
试两件事:先把预算拉到 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 回收年轻代,并加入选中的部分老年代 Region。Mixed GC 不等于 Full GC。
两类常规疏散暂停都会移动对象。G1 的并发标记提供信息,而常规疏散——复制对象、处理相关引用——仍然发生在 STW 中。并发周期还有 Remark、Cleanup 等阶段,期间也可能穿插年轻代回收。
“G1 并发整理整个堆”会把两种不同阶段混在一起。更准确的理解是:并发收集老年代存活信息,在后续暂停中分批疏散对象。
只回收一部分,外面的引用怎样找¶
假设本轮只回收 Young Region,另一个 Old Region 中却有:
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。单纯复制内存,还没有完成回收协议。
至少有两个风险:应用解引用过时地址;应用在复制期间修改旧副本,导致更新丢失。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 完整日志
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 所在目录运行:
找 Concurrent Mark Cycle、Pause Young (Normal) 与 Pause Young (Mixed)。并发标记和后续疏散暂停可以在日志中分别看到。
JDK 21 需要显式启用分代。找 Pause Mark Start、Concurrent Mark、Concurrent Relocate;Y:、O: 标识年轻代、老年代阶段。
已实际观察到的日志¶
附带日志来自 Windows x64 / Liberica OpenJDK 21.0.9+11 / 128 MiB 固定堆,三个进程均运行完成。下面是 ZGC 中一个年轻代阶段的真实摘录,省略了时间前缀和部分中间行:
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 处理。
继续深挖时,先读这些原始资料¶
- Oracle:CMS 收集器:四阶段、并发失败、浮动垃圾。
- Oracle:G1 的组织与运行:回收集合、SATB、混合回收、疏散失败与大对象。
- JEP 439:Generational ZGC:从非分代到分代时,加载屏障、存储屏障和记忆集怎样重新分工。
- JEP 376:Concurrent Thread-Stack Processing:为什么“减少暂停中的工作”还涉及根和线程栈。
- Shenandoah 项目文档:阶段、模式、pacing 与退化路径。
- Shenandoah 开发者的屏障说明:对象引用加载怎样与并发处理配合。
- JEP 404、521、535:分代 Shenandoah 的实验、正式和计划默认三个节点。
- JEP 522、523:G1 的同步优化与默认范围演变。
读源码之前,先能回答每个结构承担的任务:这条屏障在记录哪种变化,这个集合帮助定位哪些引用,这次暂停到底完成什么工作。名称可以查,因果关系需要自己建立。