概述
前文《G1 GC 深度:Region、SATB 与 Mixed GC》揭示了 G1 如何通过 SATB 并发标记、RSet 跨 Region 引用维护和 Mixed GC 的 CSet 选择实现可预测的百毫秒级停顿。但 G1 的 Mixed GC 仍然有一个关键的 STW 阶段——复制对象(Evacuation)必须暂停应用线程。当堆超过 32GB 或延迟要求 <10ms,百毫秒级停顿仍然不够。ZGC 和 Shenandoah 的出现正是为了消除复制阶段的 STW——ZGC 通过染色指针的 Load Barrier 实现对象读取时“自愈”,Shenandoah 通过 Brooks 指针的转发机制实现对象访问自动路由到新地址。两者都实现了“回收阶段与应用线程完全并发”,将停顿压至亚毫秒级。
ZGC(Z Garbage Collector)是一款面向超大堆与超低延迟场景、通过染色指针(Colored Pointers)和读屏障(Load Barrier)实现并发重映射的垃圾收集器。它利用 64 位指针中 42 位存储地址、4 位存储 GC 元数据,借助多映射内存(Multi-Mapping)将同一物理内存映射为多个虚拟视图,使得指针颜色直接决定访问视图。ZGC 仅需极短的根扫描 STW(<0.1ms),回收阶段完全并发,最大停顿与堆大小解耦。它从 JDK 11 实验性引入,JDK 15 生产可用,JDK 21 演进为分代式 ZGC,将最大停顿稳定压至亚毫秒级。
Shenandoah 是 Red Hat 主导的另一款低延迟收集器,通过 Brooks 指针(对象头前的转发指针)实现并发重定位。每个对象头部额外存放一个指针,正常情况下指向自身;当 GC 移动对象时,旧对象的 Brooks 指针更新为新地址,应用线程通过该指针自动转发到新对象,从而实现对象复制与应用线程并发。Shenandoah 不依赖染色指针和多映射,可运行在 ARM 等非 x86 平台,JDK 12 起作为生产特性包含在 OpenJDK 构建中。
两种收集器的设计哲学可凝练为三点,这三点贯穿本文所有算法模块:
染色/转发指针——让指针自己说话:ZGC 将 GC 状态编码进 64 位指针高位,Shenandoah 则在对象头前插入转发指针。两者都消灭了传统 GC 中为维护 GC 元数据而设置的额外数据结构和屏障。ZGC 的染色指针天然支持多视图并发访问,Shenandoah 的 Brooks 指针让对象移动透明化。
读屏障自愈——消除回收阶段的 STW:ZGC 在每次读引用时插入 Load Barrier,检查指针颜色,若不匹配当前 GC 阶段则自动修正为新地址(“自愈”)。Shenandoah 则通过解引用 Brooks 指针完成透明转发。两者都将回收期间最耗时的引用更新工作从 STW 中剥离,交由应用线程和 GC 线程并发协作完成。
与堆大小解耦的停顿:因为并发阶段承担了绝大部分工作,STW 仅发生在根扫描和状态切换等必须同步的时刻,停顿不再随堆大小或存活对象量增长。这在 G1 中是无法实现的:G1 的 Mixed GC 复制对象时,停顿随存活对象量线性增加。
ZGC 与 Shenandoah 对 G1 延迟瓶颈的系统性解决,体现在以下五项技术组合:
G1 瓶颈 瓶颈本质 ZGC/Shenandoah 技术方案 解决效果 Mixed GC 复制 STW 复制对象时必须暂停应用线程以保证引用一致性 ZGC:染色指针 + Load Barrier,并发重映射时引用自愈;Shenandoah:Brooks 指针,并发移动时自动转发 回收阶段完全并发,STW <0.1ms RSet 内存与扫描开销 需要 post-write barrier 维护跨 Region 引用记录,Coarsening 后扫描昂贵 ZGC:染色指针自带 GC 状态,无需 RSet,无写屏障;Shenandoah:全堆并发引用更新,不依赖 RSet 消除 RSet 维护开销,根扫描无需解析跨区引用 吞吐量受双重写屏障影响 G1 需要 pre-write barrier (SATB) + post-write barrier (RSet) ZGC:仅 Load Barrier,写操作无屏障;Shenandoah:Brooks 指针 + 少量写屏障用于引用更新 屏障总开销大幅降低,但读屏障频次高 巨型对象回收延迟 大对象不移动,仅在 Cleanup 或 Full GC 回收 ZGC:并发重映射支持移动大 Page,回收及时;Shenandoah:并发拷贝同样支持大对象 短命大对象可及时回收,消除 G1 的 Humongous 痛点 停顿与堆大小耦合 G1 停顿随存活对象量和 Region 数增长 并发重映射 + 极短根扫描,STW 仅与根集合大小相关,与堆大小和活对象量解耦 16TB 堆停顿仍 <0.1ms
这五项技术组合,使 ZGC 和 Shenandoah 在延迟可控性上实现对 G1 的代际超越,将停顿从百毫秒级压至亚毫秒级。理解这两种收集器,本质上是在理解它们如何以读屏障替代写屏障和 RSet,以并发重映射消灭回收 STW。ZGC 的 Load Barrier 插入在读操作中,读的频率远高于写,因此吞吐量通常低于 G1(5-15% 损失);但这一代价换来了任何堆大小下均稳定的亚毫秒停顿。这种“以吞吐换极限延迟”的权衡,是本文展开深度分析时贯穿始终的暗线。
核心要点:
- ZGC 染色指针:42 位地址 + 4 位 metadata(Marked0/Marked1/Remapped/Finalizable)+ 18 位保留,零对象头开销
- Load Barrier 自愈:读引用时检查指针颜色,若不匹配则从旧地址的转发指针修正为新地址并写回引用字段
- ZGC 阶段:初始标记(STW <0.1ms)→ 并发标记 → 并发重映射(回收,无 STW)→ 最终标记(STW <0.1ms)
- Shenandoah Brooks 指针:对象头前额外 8 字节转发指针,并发重定位时旧对象指向新对象
- JDK 演进:ZGC JDK 15 生产可用,JDK 21 分代 ZGC 停顿 <0.1ms;Shenandoah JDK 12 生产可用,跨平台
文章组织架构:
flowchart TD
A["第一章: 全景序章<br/>ZGC 与 Shenandoah 并发回收生命周期"] --> B["第二章: ZGC 染色指针<br/>位布局、多映射与 metadata 编码"]
B --> C["第三章: Load Barrier 自愈机制<br/>读屏障插入、自愈逻辑与 JIT 优化"]
C --> D["第四章: ZGC GC 阶段<br/>标记与并发重映射深度剖析"]
D --> E["第五章: Shenandoah Brooks 指针<br/>转发指针结构与并发重定位"]
E --> F["第六章: 三维对比<br/>ZGC vs Shenandoah vs G1"]
F --> G["第七章: JDK 版本演进<br/>从实验性到分代 ZGC"]
G --> H["第八章: 工程实战<br/>场景选择与参数调优"]
classDef ch1 fill:#d4e2f0,stroke:#3a6b92,stroke-width:1.5px,color:#1e3a5f
classDef ch2 fill:#d0e8e0,stroke:#2c7a5e,stroke-width:1.5px,color:#1e4a3a
classDef ch3 fill:#e0d8f0,stroke:#7a6aaa,stroke-width:1.5px,color:#3a2a6a
classDef ch4 fill:#f2e6d8,stroke:#c0844a,stroke-width:1.5px,color:#6a3a1a
classDef ch5 fill:#f5e0da,stroke:#b56a6a,stroke-width:1.5px,color:#6a2a2a
classDef ch6 fill:#cce2ef,stroke:#4a6e8a,stroke-width:1.5px,color:#1e4a6a
classDef ch7 fill:#e8e0d5,stroke:#a68a6c,stroke-width:1.5px,color:#4a3b2c
classDef ch8 fill:#d6d8db,stroke:#5a6a7a,stroke-width:1.5px,color:#2a3a4a
class A ch1
class B ch2
class C ch3
class D ch4
class E ch5
class F ch6
class G ch7
class H ch8
分层说明:第一章建立 ZGC 和 Shenandoah 全生命周期的宏观认知;第二、三、四章是全文核心——ZGC 的染色指针、Load Barrier 和并发重映射;第五章引入 Shenandoah 的 Brooks 指针方案;第六章做三种收集器的全面对比;第七、八章回归版本演进与工程选型。关键结论:ZGC 通过染色指针和 Load Barrier 将对象复制与应用线程完全并发化,免去 RSet 和写屏障;Shenandoah 通过 Brooks 指针实现类似并发重定位。JDK 21 的分代 ZGC 已将最大停顿压至 <0.1ms,成为超大堆低延迟场景的首选收集器。
第一章 全景序章:ZGC 与 Shenandoah 并发回收生命周期
带实际例子的流程:juejin.cn/spost/76473…
ZGC 和 Shenandoah 的垃圾收集行为可抽象为一条闭环链路:从对象分配开始,经历并发标记、并发移动/重映射,到空间回收。两者虽实现机制不同(染色指针 vs Brooks 指针),但顶层流程高度相似。下示流程图以 ZGC 为主线,覆盖了其全生命周期中的关键决策节点与执行阶段,各节点均对应特定的数据结构和算法机制。
flowchart TD
Start(["应用线程分配对象"]) --> SmallObj["普通对象: 在 Page 内分配<br/>优先使用 TLAB"]
SmallObj --> PageFull{"当前 Page 空间不足?"}
PageFull -- 否 --> Return1(["快速分配完成"])
PageFull -- 是 --> NewPage["从空闲列表分配新 Page<br/>设置为当前分配 Page"]
NewPage --> SmallObj
Start --> LargeObj{"大对象(>2MB)?"}
LargeObj -- 是 --> LargePage["分配大 Page<br/>绕过 TLAB,直接使用堆空间"]
LargePage --> ReturnLarge(["大对象分配完成"])
SmallObj --> AllocRate["对象分配速率累积"]
LargeObj --> AllocRate
AllocRate --> TrigCheck{"堆占用率触发 GC<br/>或分配速率 > 回收速率?"}
TrigCheck -- 否 --> WaitAlloc["继续分配, 等待触发"]
WaitAlloc --> PageFull
TrigCheck -- 是 --> StartCycle["启动 GC 周期"]
StartCycle --> MarkStart["阶段1: Mark Start - STW<br/>标记 GC Roots 直接可达<br/>设置视图切换"]
MarkStart --> ConcurrentMark["阶段2: 并发标记<br/>遍历对象图, 染色指针标记<br/>应用线程通过 Load Barrier 正常执行"]
ConcurrentMark --> MarkEnd["阶段3: Mark End - STW<br/>处理剩余标记, 识别存活对象"]
MarkEnd --> SelectReloc["阶段4: 选择重映射集合<br/>并发: 选择待回收 Page"]
SelectReloc --> RelocStart["阶段5: Relocate Start - STW<br/>准备并发重映射环境"]
RelocStart --> ConcurrentReloc["阶段6: 并发重映射<br/>GC 线程: 复制存活对象到新 Page<br/>应用线程: Load Barrier 自愈"]
ConcurrentReloc --> CycleEnd(["GC 周期结束, 空间回收"])
classDef startEnd fill:#fef3c7,stroke:#d97706,color:#92400e
classDef decision fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95
classDef process fill:#f1f5f9,stroke:#334155,color:#1e293b
class Start,Return1,ReturnLarge,CycleEnd startEnd
class PageFull,LargeObj,TrigCheck decision
class SmallObj,NewPage,LargePage,AllocRate,WaitAlloc,StartCycle,MarkStart,ConcurrentMark,MarkEnd,SelectReloc,RelocStart,ConcurrentReloc process
一个笔记式的流程图:
flowchart TD
Start(["ZGC 周期开始"]) --> Condition{"垃圾积累<br/>达到阈值?"}
Condition -- "否" --> Wait["等待,应用继续运行"]
Wait --> Condition
Condition -- "是" --> InitMark
subgraph STW1 ["第一次 STW:初始标记 (Initial Mark)"]
InitMark["暂停所有应用线程"] --> ScanRoots["扫描 GC Roots<br/>(栈、寄存器、全局变量、JNI)"]
ScanRoots --> ColorRoots["将 Roots 指向的指针颜色<br/>从 Remapped 改为 M0"]
ColorRoots --> PushStack["将指向的对象压入标记栈"]
PushStack --> ResumeApp1["恢复应用线程"]
end
subgraph ConcurrentMark ["并发标记 (Concurrent Mark)"]
direction TB
MarkLoop{"标记栈非空?"}
GCThreadMark["GC 线程:从栈弹出对象"] --> ScanFields["扫描对象的所有引用字段"]
ScanFields --> ForEachField{"对每个字段指针 p"}
ForEachField -- "p 颜色已为 M0" --> NextField["跳过"]
ForEachField -- "p 颜色不是 M0" --> TryCASMark["通过 CAS 将 p 颜色改为 M0"]
TryCASMark -- "CAS 成功" --> PushMarkStack["将 p 指向的对象压入标记栈"]
TryCASMark -- "CAS 失败<br/>(已被其他线程标记)" --> NextField
PushMarkStack --> NextField
NextField --> MoreFields{"还有字段?"}
MoreFields -- "是" --> ForEachField
MoreFields -- "否" --> MarkLoop
MarkLoop -- "栈空" --> MarkDone["标记完成"]
AppHelp["应用线程:执行读屏障"] -.-> ReadBarrierCheck["每次从堆加载引用 ptr"]
ReadBarrierCheck --> ColorCheck{"ptr 颜色 == M0?"}
ColorCheck -- "是" --> ReturnPtr["直接返回 ptr"]
ColorCheck -- "否" --> TryMark["尝试 CAS 将 ptr 颜色改为 M0"]
TryMark -- "成功" --> PushAndReturn["将对象压入标记栈<br/>返回带 M0 颜色的 ptr"]
TryMark -- "失败" --> ReturnAfterFail["返回已被其他线程<br/>更新后的 ptr"]
end
MarkDone --> FinalMark
subgraph STW2 ["第二次 STW:最终标记 (Final Mark)"]
PauseApp2["暂停所有应用线程"] --> HandleRemaining["处理并发期间遗留的引用<br/>(如 JNI 弱引用、非堆引用)"]
HandleRemaining --> ComputeLive["统计各 ZPage 存活对象信息"]
ComputeLive --> SelectRelocSet["选出需要转移的 ZPage<br/>(存活率低的页)"]
SelectRelocSet --> ResumeApp2["恢复应用线程"]
end
SelectRelocSet --> PrepareReloc
subgraph PrepareReloc ["并发转移准备 (Concurrent Prepare)"]
AllocNewPages["为选中的 ZPage 分配新 ZPage"] --> InitForwardTable["初始化转发表 (Forwarding Table)<br/>空哈希表"]
end
InitForwardTable --> ConcurrentReloc
subgraph ConcurrentReloc ["并发转移 (Concurrent Relocation)"]
direction TB
RelocLoop{"还有存活对象未转移?"}
GCRelocThread["GC 线程:复制对象"] --> CopyObj["将对象从旧地址 old<br/>复制到新地址 new"]
CopyObj --> InsertMapping["转发表插入映射: old -> new"]
InsertMapping --> MarkObjRemapped["旧对象保留,新对象就绪"]
MarkObjRemapped --> RelocLoop
AppSelfHeal["应用线程:读屏障自愈"] -.-> LoadPtr["从堆加载引用 ptr"]
LoadPtr --> CheckRemapColor{"ptr 颜色 == Remapped?"}
CheckRemapColor -- "是" --> DirectReturn["直接返回 ptr"]
CheckRemapColor -- "否" --> QueryForward["查询转发表: <br/>forwardTable.get(ptr)"]
QueryForward -- "找到映射 new" --> CASUpdate["通过 CAS 更新引用位置<br/>将 ptr 改为 new"]
CASUpdate -- "成功" --> SetRemappedColor["设置 new 的颜色为 Remapped<br/>并返回 new"]
CASUpdate -- "失败" --> RetryLoad["重试或返回其他线程已更新的值"]
QueryForward -- "未找到映射" --> NoMove["对象未被移动,<br/>将 ptr 颜色改为 Remapped 并返回"]
end
RelocLoop -- "所有对象转移完成" --> StartRemap
subgraph ConcurrentRemap ["并发重映射 (Concurrent Remap)"]
BackgroundScan["后台线程扫描堆中剩余旧指针"] --> FixPtr["对每个旧指针执行自愈<br/>(同读屏障逻辑)"]
FixPtr --> ReleaseForwardEntry["释放转发表中对应条目"]
ReleaseForwardEntry --> CheckOldPage{"旧 ZPage 上<br/>所有对象映射已清除?"}
CheckOldPage -- "是" --> FreeOldPage["释放旧 ZPage 物理内存"]
CheckOldPage -- "否" --> ContinueScan["继续扫描"]
FreeOldPage --> ContinueScan
ContinueScan --> AllDone{"所有旧页释放?"}
AllDone -- "否" --> BackgroundScan
AllDone -- "是" --> DestroyForwardTable["销毁转发表"]
end
DestroyForwardTable --> End(["GC 周期结束,<br/>所有指针颜色均为 Remapped"])
classDef stwSub fill:#fef3c7,stroke:#d97706,color:#92400e;
classDef concurrentSub fill:#dbeafe,stroke:#2563eb,color:#1e3a8a;
classDef processNode fill:#f1f5f9,stroke:#334155,color:#1e293b;
classDef decisionNode fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95;
classDef startEndNode fill:#fef3c7,stroke:#d97706,color:#92400e;
class STW1,STW2 stwSub;
class ConcurrentMark,PrepareReloc,ConcurrentReloc,ConcurrentRemap concurrentSub;
class Start,End startEndNode;
class Condition,MarkLoop,ForEachField,MoreFields,ColorCheck,RelocLoop,CheckRemapColor,CheckOldPage,AllDone decisionNode;
class InitMark,ScanRoots,ColorRoots,PushStack,ResumeApp1,GCThreadMark,ScanFields,TryCASMark,PushMarkStack,NextField,MarkDone,AppHelp,ReadBarrierCheck,ReturnPtr,TryMark,PushAndReturn,ReturnAfterFail,PauseApp2,HandleRemaining,ComputeLive,SelectRelocSet,ResumeApp2,AllocNewPages,InitForwardTable,GCRelocThread,CopyObj,InsertMapping,MarkObjRemapped,AppSelfHeal,LoadPtr,DirectReturn,QueryForward,CASUpdate,SetRemappedColor,RetryLoad,NoMove,BackgroundScan,FixPtr,ReleaseForwardEntry,FreeOldPage,ContinueScan,DestroyForwardTable,Wait,FinalMark,StartRemap processNode;
1.1 分配阶段:Page 管理与 TLAB
ZGC 将堆划分为称为 Page 的基本单元。在 JDK 11-17 的单代 ZGC 中,Page 大小固定为 2MB(小 Page)和 N×2MB 的大 Page。JDK 21 的分代 ZGC 为了优化年轻代回收效率,引入了 2MB 和 4MB 等多种 Page 大小。普通对象分配在小 Page 内,使用类似 G1 的 TLAB 机制:每个 Java 线程在 Page 中预先申请一块 TLAB,采用指针碰撞进行无锁分配;TLAB 耗尽时向全局空闲列表申请新的 Page。TLAB 内部通过 top 指针移动实现极速分配。
大对象(超过 2MB)分配在专门的大 Page,绕过 TLAB 直接在堆上分配。ZGC 对大对象的划分远比 G1 的 50% Region 阈值宽松(G1 中,一个对象超过 Region 大小一半即判定为 Humongous),且大对象也参与并发重映射——这意味着在重映射阶段,如果大对象所在的 Page 被选中回收,GC 线程会为其分配新的连续 Page 空间并进行复制。这一特性彻底消除了 G1 中短命巨型对象长时间占用空间、回收滞后的痛点。
Shenandoah 使用 Region 作为管理单元(默认 256KB),同样支持 TLAB。Shenandoah 的 Region 大小固定为 256KB,但在 JDK 17 中引入了可配置的 Region 大小(-XX:ShenandoahRegionSize)。大对象可以跨多个连续的 Region 分配,通过对象头信息标记跨 Region 范围。和 ZGC 一样,Shenandoah 的大对象也参与并发拷贝,回收及时。
1.2 GC 触发与周期启动
ZGC 采用基于堆占用率和分配速率的自适应触发策略。核心参数 -XX:ZAllocationSpikeTolerance(默认 2.0)调节触发灵敏度:该值定义了一个“分配尖峰容忍因子”,具体算法为——当堆剩余空间低于 (当前分配速率 × 预测GC耗时 × ZAllocationSpikeTolerance) 时,ZGC 触发新一轮 GC。值越大,GC 越能容忍分配尖峰而延迟启动,从而减少 GC 频率,但可能因内存紧张引发 Allocation Stall(短暂 STW 直到回收完成)。值越小,GC 越早启动,降低内存压力但增加 GC 频次。
Shenandoah 的触发由 -XX:ShenandoahGCHeuristics 控制,提供多种启发式策略:
- adaptive:默认值,根据历史 GC 数据和分配速率自适应决定触发时机。
- static:基于静态阈值(堆占用率)触发,类似 G1 的 IHOP。
- aggressive:尽可能早地触发 GC,以极低停顿为目标,但吞吐量牺牲较大。
- compact:侧重于碎片整理,在碎片化严重时主动触发。
1.3 并发标记与重映射
ZGC 的 GC 周期由六个阶段组成,仅 Mark Start、Mark End、Relocate Start 三次极短 STW,总停顿 <0.1ms。并发标记阶段,应用线程和标记线程并发执行,标记线程通过 SATB 算法(注意 ZGC 也使用 SATB!)维持并发标记的正确性,而指针染色则直接体现标记结果。并发重映射阶段,GC 线程复制存活对象到新 Page,旧 Page 的所有引用地址将被指向的旧对象填充为转发指针(forwarding pointer)。应用线程在此期间读取对象时,Load Barrier 检查指针颜色,若发现指向旧地址且颜色为“待重映射”状态,则读取旧地址处的 forwarding pointer,将引用修正为新地址并写回引用字段——这就是“自愈”。
Shenandoah 的周期类似:并发标记后,进入并发复制阶段,GC 线程拷贝对象,将旧对象的 Brooks 指针从指向自身更新为指向新对象;应用线程在每次访问对象时都通过 Brooks 指针间接获取真实地址,因此对象移动对其透明。之后 Shenandoah 还多出一个并发引用更新阶段,将堆中所有指向旧对象的引用更新为新地址。
1.4 空间回收与闭环
并发重映射/重定位完成后,旧 Page/Region 被整体清空并归还空闲列表。ZGC 通过切换“好颜色”(例如从 Marked0 切换到 Marked1)实现逻辑上的空间翻转:上一轮 GC 重映射后的新 Page 具有新的标记颜色,而旧颜色对应的虚拟视图可以被回收。Shenandoah 则直接通过 Brooks 指针的更新完成新旧空间的交接。两种收集器的整个过程不需要全局停顿,构成流畅的分配—标记—重映射/重定位—回收闭环。
第二章 ZGC 染色指针:64 位指针的位布局与 metadata 编码
ZGC 的革命性在于它彻底抛弃了传统 GC 在对象头或外部数据结构中记录 GC 状态的方案,转而利用 64 位指针的闲置位来编码 GC 元数据。在 x86-64 架构下,虚拟地址实际上只使用了低 48 位(Linux 用户空间通常受限于 48 位),高 16 位需要是符号扩展位。ZGC 巧妙地“借用”了这些高位的某些位,同时通过多映射确保硬件 MMU 不会误解这些位。
2.1 染色指针的位划分
在 OpenJDK 源码 zGlobals.hpp 中,染色指针的布局通过一系列常量定义:
// zGlobals.hpp (简化)
const uintptr_t ZPointerAddressBits = 42;
const uintptr_t ZPointerMetadataBits = 4;
const uintptr_t ZPointerReservedBits = 18;
const uintptr_t ZPointerMarked0 = (uintptr_t)1 << 42; // metadata 位
const uintptr_t ZPointerMarked1 = (uintptr_t)1 << 43;
const uintptr_t ZPointerRemapped = (uintptr_t)1 << 44;
const uintptr_t ZPointerFinalizable = (uintptr_t)1 << 45;
一个 64 位指针被划分为:
- 地址位 [0, 41]:提供 42 位地址空间,最大可寻址 4TB 堆。注意,这 42 位在计算物理地址时会被 MMU 正确解释,因为 ZGC 使用了多映射,实际物理地址是通过虚拟地址范围基址 + offset 计算的。
- Metadata 位 [42, 45]:共 4 位,分别编码
Marked0、Marked1、Remapped、Finalizable四种状态。这些位在 ZGC 内部表示指针的“颜色”。一个指针在任意时刻只能有一个元数据位被置1。 - 保留位 [46, 63]:共 18 位,当前未使用。在 JDK 13 支持 16TB 堆的模式下,地址位数扩展到 44 位,元数据位数压缩为 2 位(仅保留 Marked 和 Remapped,通过交替使用两套视图来实现双标记位),保留位相应减少。
这 4 个 metadata 位并非同时生效,而是两两组合表示 GC 阶段的对象视图。ZGC 的 GC 周期使用两个标记位交替(Marked0/Marked1)与 Remapped 位联动。具体来说:
- 在一次 GC 周期中,全局维护一个当前“好颜色”状态。在标记阶段,“好颜色”可能是 Marked0(如果上一轮用的是 Marked1),所有新分配的对象的指针会被染上 Marked0 色。并发标记线程遍历对象图时,将可达对象的指针也染为 Marked0。标记结束时,所有 Marked0 色的指针指向的对象是存活的。
- 进入重映射阶段后,GC 线程选择一部分 Marked0 色对象所在的 Page 进行回收。它将这些对象复制到新 Page,新对象指针颜色染为 Remapped(表示已移动且可用)。旧 Page 中的旧对象空间被替换为 forwarding pointer,指向新地址,同时旧地址处的指针也会携带 Remapped 位信息,或者通过其他机制标记为“已移动”。应用线程访问对象时,Load Barrier 看到指针颜色为 Marked0(旧颜色),就知道该指针需要修正,然后找到新地址。
- 下一次 GC 周期则使用另一个标记位(Marked1),如此交替,避免颜色冲突。
这种设计使得指针本身携带了完整的 GC 生命周期状态,GC 线程和应用线程通过检查指针的几个 bit 即可协调工作,完全不需要额外的对象头标记字(G1 需要 Mark Word 记录 age、锁、forwarding pointer 等)。
2.2 多映射内存:同一个物理页的三个虚拟视图
染色指针的高位在硬件寻址时会被屏蔽,因此这些颜色位并不影响实际的物理地址计算。但 ZGC 利用操作系统的多映射(mmap 的 MAP_SHARED 或文件映射)技术,将同一块物理内存映射到多个虚拟地址范围,每个虚拟地址范围对应一种颜色。这意味着同一个物理页在 Marked0 视图、Marked1 视图和 Remapped 视图中同时可见。
具体实现上,ZGC 在初始化时分配堆空间,然后调用 mmap 为 Marked0、Marked1、Remapped 三个虚拟地址区域建立映射,全部指向相同的物理页。这三个虚拟地址基址在 ZGC 内部保存,通过指针高位掩码判断指针属于哪个视图,并提取 offset,然后加上当前所需视图的基址即可得到正确虚拟地址。
当应用线程持有一个带 Remapped 颜色的指针时,它实际上是在访问 Remapped_base + offset 的虚拟地址。如果对象已经被移动,ZGC 会在 Remapped 视图的旧地址位置写入 forwarding pointer。同理,在标记阶段,标记线程通过 Marked0 视图访问对象,应用线程可能通过 Remapped 视图访问已移动的对象。所有视图对物理内存的访问是缓存一致的,因为它们是同一个物理地址的不同映射。
flowchart LR
subgraph Pointer[64-bit Pointer]
Addr[42-bit Address]
Meta[4-bit Metadata: M0,M1,Remapped,Final]
Rsvd[18-bit Reserved]
end
Addr --> VirtAddr[Virtual Address]
Meta --> ViewSelector{Color View}
ViewSelector -->|Remapped bit set| ViewR[Remapped View]
ViewSelector -->|Marked0 bit set| ViewM0[Marked0 View]
ViewSelector -->|Marked1 bit set| ViewM1[Marked1 View]
ViewR --- PhysMem[Physical Memory Page]
ViewM0 --- PhysMem
ViewM1 --- PhysMem
a) 主旨概括:该图展示一个 64 位指针如何划分为 42 位地址、4 位 GC 元数据和 18 位保留位,以及四种元数据如何将同一物理内存映射到不同虚拟视图。
b) 逐元素分解:左侧指针结构由三部分横向排列;元数据位通过“颜色视图选择器”将虚拟地址路由到 Remapped、Marked0、Marked1 三个视图之一,每个视图都映射到同一块物理内存,只是虚拟地址基址不同。
c) 设计原理映射:ZGC 把 GC 状态从对象头剥离,编码进指针本身,免去 RSet 的维护;多映射让硬件自动完成地址转换,屏障代码只需检查颜色位并可能修正指针,而无需查表。
d) 工程联系与关键结论:染色指针实现了零额外对象头开销的 GC 元数据记录,同时多映射内存消除了重定位时的物理拷贝和地址解析开销,是 ZGC 实现亚毫秒停顿的物理基石。这也意味着 ZGC 必须运行在 64 位系统且有 MMU 支持多映射的 OS 上(如 Linux/x86_64)。由于多映射消耗额外的虚拟地址空间,ZGC 实际可用的堆大小受限于虚拟地址空间布局(例如 Linux 默认用户空间 47 位),这也是 ZGC 最大支持 16TB 堆的原因。
第三章 Load Barrier 自愈机制:读指针时自动修正旧地址
G1 依赖写屏障来维护 RSet,每次引用更新都要执行一段 barrier 代码,影响吞吐量。ZGC 反其道而行:它没有写屏障,但在每次从堆读取引用时插入 Load Barrier(读屏障)。这是基于一个洞察:并发移动对象后,应用线程只要在真正使用该引用时能得到正确的地址即可。
3.1 Load Barrier 的插入时机与自愈逻辑
在 JIT 编译层面,每当字节码指令需要从堆上加载一个对象引用(如 getfield、aaload、ldc 等),C2 编译器会在这些指令之后立即插入一个屏障调用。这个屏障是一个轻量级的运行时检查,在 fast path 中只包含一条位测试指令和一条条件跳转指令,如果颜色匹配当前“好颜色”,则几乎零开销。
屏障的伪代码逻辑如下,来自 zBarrierSet.cpp 中的 ZBarrier::load_barrier_on_oop_field:
oop ZBarrier::load_barrier_on_oop_field(volatile oop* p) {
oop obj = *p; // 原始读
// 如果指针颜色是 good(即与当前 GC 阶段的预期颜色一致),直接返回
if (is_good(obj)) {
return obj;
}
// 否则进入 slow path:自愈
return heal(p);
}
is_good(obj) 的实现是:(obj & ZPointerGoodMask) == ZPointerGoodColor。这里的 ZPointerGoodMask 和 ZPointerGoodColor 在 GC 周期不同阶段会动态改变。例如,在重映射阶段结束后,好颜色是 Remapped;在并发标记阶段,好颜色可能是 Marked0。新分配的对象在创建时就直接被赋予当前好颜色,因此 Load Barrier 对它们无作用。
heal(p) 的过程是自愈的核心。当发现 *p 指针颜色不对时,说明 *p 指向的对象可能已经被移动(旧地址)。ZGC 会:
- 从旧地址读取 forwarding pointer(存储在旧地址对象头或特定偏移处,在 ZGC 中 forwarding pointer 存储在旧对象的 Mark Word 区域,因为此时旧对象已经“死亡”,其头信息可以安全复用)。
- 将 forwarding pointer 计算得到新地址,并将其颜色修正为当前好颜色。
- 使用 CAS 操作将新指针写回
*p(自愈写入),这样下次读取相同引用时便直接命中好颜色,无需再次进入 slow path。 - 返回修正后的新指针。
这种“读取时修正,一次修正永久有效”的模式保证了并发重映射期间,应用线程不会被长时间阻塞,引用的修正被自然地分散到每次访问中。
3.2 JIT 优化与性能
Load Barrier 性能的关键在于 JIT 的优化。C2 编译器采用了多种技术降低屏障开销:
- 屏障提升(Barrier Hoisting):如果一段代码中多次读取同一个对象的同一个引用字段,编译器可以只插入一次屏障,将结果缓存在寄存器中,消除重复检查。
- 循环展开中的屏障聚合:在循环中,如果读取的是不变引用,屏障可以被提升到循环外部。
- 颜色检查内联:fast path 完全内联到调用点,仅需 3-4 条 x86 指令:
mov读取指针,test指令检查颜色位,jz条件跳转。分支预测器会因为大部分情况下颜色匹配而高度准确。 - 逃逸分析辅助:对于不逃逸线程的对象,其引用不会在 GC 期间被移动,JIT 可以消除屏障。
根据实际基准测试,ZGC 的 Load Barrier 带来的吞吐量损失通常在 5% 到 15% 之间,取决于应用的对象访问模式和指针加载频率。相比 G1 的双重写屏障(pre- + post-),ZGC 的读屏障在写密集场景下具有优势,因为写操作无屏障开销。
flowchart TD
A(["应用线程读取引用"]) --> B{"指针元数据位==当前好颜色?"}
B -- "Yes" --> C["直接使用指针,无额外开销"]
B -- "No" --> D["进入自愈路径"]
D --> E["从旧地址读取转发指针"]
E --> F["计算新地址并染上好颜色"]
F --> G["CAS 新指针写回引用字段"]
G --> H["返回新指针,继续执行"]
C --> I(["解引用对象"])
H --> I
classDef startEnd fill:#fef3c7,stroke:#d97706,color:#92400e;
classDef decision fill:#ede9fe,stroke:#8b5cf6,color:#4c1d95;
classDef process fill:#f1f5f9,stroke:#334155,color:#1e293b;
class A,I startEnd;
class B decision;
class C,D,E,F,G,H process;
a) 主旨概括:该流程图描述应用线程读取对象引用时,Load Barrier 如何检查指针颜色,并在不匹配时自动将引用更新为新地址(自愈),最终返回正确指针。
b) 逐元素分解:第一个菱形判断元数据位;No 分支进入自愈,先读取旧地址的转发信息,计算新地址,然后将新引用 CAS 写回字段,最后返回新指针;Yes 分支快速通过。
c) 设计原理映射:自愈机制将引用修正的负担延迟到了访问时刻,并且一次修正永久有效,避免了全局的 STW 引用更新。无需写屏障,因为对象移动时应用线程可能还在使用旧引用,但读屏障保证每次解引用前都拿到正确地址。
d) 工程联系与关键结论:Load Barrier 的自愈是 ZGC 消除重映射阶段 STW 的核心保障。它的性能开销主要集中在读操作,但由于读操作频繁,JIT 大量应用了颜色检查的内联优化、批量处理和分支预测,使得实际吞吐量损失控制在可接受范围(通常 <5-15%)。生产环境中,如果业务代码有大量循环读取对象引用,C2 的屏障提升会大幅减少实际屏障调用次数。
3.3 为什么 ZGC 不需要写屏障和 RSet
G1 的写屏障核心任务是维护 RSet:记录老年代对象对新生代的跨 Region 引用,以便在 Minor/Mixed GC 时能快速找到 GC Roots。ZGC 不区分新生代和老年代(单代 ZGC),并且并发重映射会扫描所有对象并移动存活对象,不需要 RSet。对象移动时,旧地址被指向的旧对象被替换为转发指针,指向新地址。应用线程通过 Load Barrier 就能找到新位置。染色指针自己就是“跨区域引用”的标记——任何带旧颜色的指针都会被读屏障拦截并修正。因此,ZGC 完全不需要写屏障来维护任何额外的引用记录。
在 JDK 21 的分代 ZGC 中,情况略有变化:由于引入了年轻代和老年代,需要快速定位跨代引用。分代 ZGC 采用了类似 G1 的 SATB 并发标记和写屏障来记录跨代引用,但这里的写屏障是轻量级的卡表标记,仅用于年轻代收集时的根扫描,而并发重映射阶段仍然依赖 Load Barrier 进行引用自愈,且跨代引用扫描仅在 young GC 的 STW 中发生,频率极低。因此,分代 ZGC 的整体屏障开销依然低于 G1。
第四章 ZGC GC 阶段:标记与并发重映射
ZGC 的一个 GC 周期可细分为六个阶段,其中三个阶段是短暂的 STW,其余全部与应用线程并发执行。理解这些阶段及其在染色指针视图切换中的作用,是理解 ZGC 停顿极短的关键。
4.1 阶段详解
阶段 1: Mark Start(STW)
- 目的:扫描所有 GC Roots(线程栈、JNI 句柄、类静态变量等),标记这些根直接指向的对象。
- 动作:将 GC Roots 引用到的对象指针染上当前标记颜色(例如 Marked0),并将这些对象加入标记栈。此阶段需要暂停所有应用线程,但只需扫描根集合,与堆大小无关,耗时通常 <0.1ms。
- 多映射视角:此时“好颜色”切换为 Marked0,应用线程读取任何对象引用时,Load Barrier 检查颜色为 Marked0 即通过。
阶段 2: Concurrent Mark(并发)
- 目的:遍历整个对象图,将所有存活对象染色。
- 动作:多个并发标记线程从标记栈中弹出对象,递归扫描其引用字段。当发现引用指向的对象未被染色时,将其染色并压入栈。应用线程在赋值引用时,ZGC 使用 SATB pre-write barrier 维持并发标记正确性(ZGC 也有 SATB!),将旧值记录到 SATB 队列,并发标记线程处理这些旧值,确保快照一致性。注意 ZGC 的 SATB 实现与 G1 类似,也是 pre-write barrier,但因为 ZGC 没有 RSet,这个 barrier 仅用于并发标记,且仅在并发标记活跃期间生效,开销比 G1 的双写屏障小得多。
- 并发性:应用线程继续运行,读屏障保证读取已染色对象不受影响。
阶段 3: Mark End(STW)
- 目的:处理并发标记阶段残留的 SATB 队列,完成标记。
- 动作:暂停所有应用线程,清空所有 SATB 队列,处理其中记录的对象,完成存活对象集合的最终确认。此阶段还处理软引用、弱引用等。耗时通常 <0.1ms,受 SATB 队列积压程度影响。
阶段 4: Concurrent Select Relocation Set(并发)
- 目的:选择需要回收的 Page 集合(Relocation Set)。
- 动作:并发线程根据标记结果,统计每个 Page 的存活对象数量和垃圾量,选择垃圾占比高的 Page 作为回收目标。类似于 G1 的候选 CSet 选择,但完全并发,不 STW。
阶段 5: Relocate Start(STW)
- 目的:为并发重映射做最终准备。
- 动作:扫描所有 GC Roots,将这些根引用的对象指针修正为 Remapped 颜色(指向新地址),或更新指向即将被移动的旧地址的根引用。此阶段本质上是在重映射前确保根集合的一致性。耗时 <0.1ms。
阶段 6: Concurrent Relocate(并发)
- 目的:将选中 Page 中的存活对象复制到新 Page,并更新所有引用。
- 动作:GC 线程分配新 Page,将存活对象逐字节拷贝到新 Page,并在旧地址位置写入 forwarding pointer。同时,应用线程继续运行,通过 Load Barrier 访问任何对象时,如果发现指针指向旧地址(颜色为 Marked0),则进入自愈流程:读取 forwarding pointer,获得新地址,并更新引用。最终,所有引用通过自愈或 GC 线程主动更新,全部指向新地址。旧 Page 随后被回收。
sequenceDiagram
participant App as Application Threads
participant GC as GC Threads
Note over App,GC: === Mark Start (STW <0.1ms) ===
App-->>GC: 暂停
GC->>GC: 扫描 GC Roots (线程栈等)
Note over App,GC: === Concurrent Mark ===
App->>App: 运行,插入 Load Barrier + SATB pre-write barrier
GC->>GC: 遍历对象图,染色 Marked0
Note over App,GC: === Mark End (STW <0.1ms) ===
App-->>GC: 暂停
GC->>GC: 处理剩余 SATB 队列,确定活对象
Note over App,GC: === Concurrent Select Relocation Set ===
GC->>GC: 选择待回收的 Page 集合
Note over App,GC: === Relocate Start (STW <0.1ms) ===
App-->>GC: 暂停
GC->>GC: 修正根引用,准备并发重映射
Note over App,GC: === Concurrent Relocate ===
par 并发
App->>App: 运行,Load Barrier 自愈
GC->>GC: 复制对象到新 Page,更新转发指针
end
Note over App,GC: 周期结束,下次 GC 循环开始
a) 主旨概括:该时序图展现了 ZGC 一个完整 GC 周期中的主要阶段,突出并发阶段(标记、重映射)与三个极短 STW 停顿的交替关系。
b) 逐元素分解:顺序为 Mark Start(STW)→ Concurrent Mark → Mark End(STW)→ Concurrent Select Relocation Set → Relocate Start(STW)→ Concurrent Relocate。并发重映射期间,应用线程通过 Load Barrier 自愈,GC 线程复制对象。
c) 设计原理映射:STW 仅用于根扫描、标记终结和重定位初始化,这些操作的理论复杂度与堆中活对象数量或根数量相关,但优化后极快。真正耗时的对象图遍历和对象复制均并发完成,停顿不受堆大小影响。
d) 工程联系与关键结论:在 JDK 21 中,这些 STW 总时长中位数已压至 0.05ms 以内,最大停顿 <0.1ms,实现了真正意义上的亚毫秒 GC 停顿。对于堆大小 16GB–16TB 的生产系统,暂停时间几乎不再是瓶颈。
4.2 G1 vs ZGC:停顿来源的本质差异
G1 的 Mixed GC 在 Evacuation 阶段(即复制存活对象到新 Region)是全程 STW 的,因为移动对象的同时必须修正所有指向它们的引用,G1 没有染色指针或读屏障来自动处理并发访问。ZGC 通过 Load Barrier 将引用修正与对象复制解耦并并发化。下图对比了两种收集器的停顿来源。
flowchart TD
subgraph G1_Pause [G1 Mixed GC Pause]
direction TB
G1_Init[Initial Mark STW] --> G1_Conc[Concurrent Mark]
G1_Conc --> G1_Evac[Evacuation STW: Copy Objects + Update Refs]
G1_Evac --> G1_Clean[Cleanup STW]
end
subgraph ZGC_Pause [ZGC Cycle]
direction TB
Z_Init[Mark Start STW] --> Z_CM[Concurrent Mark]
Z_CM --> Z_End[Mark End STW]
Z_End --> Z_RelStart[Reloc Start STW]
Z_RelStart --> Z_ConcReloc[Concurrent Relocate: Copy + Heal via LB]
end
G1_Evac -..-> |"<100ms typical"| NoteG1[Copy phase STW]
Z_ConcReloc -..-> |"<0.1ms total pause"| NoteZ[Copy phase concurrent]
a) 主旨概括:对比图指出 G1 Mixed GC 的对象复制(Evacuation)是 STW 的,而 ZGC 的对象复制(Relocate)完全并发,两者停顿根源一针见血。
b) 逐元素分解:左侧 G1 序列显示 Evacuation 阶段独占 STW;右侧 ZGC 序列显示 Relocate 阶段为并发,STW 仅出现在 Mark Start/End 和 Reloc Start,时间极短。
c) 设计原理映射:G1 无染色指针,不能容忍应用线程在复制期间持有旧引用;ZGC 用染色指针和读屏障,允许应用线程临时持有旧引用并由屏障自动修正。
d) 工程联系与关键结论:堆越大,G1 Evacuation 复制时间越长,停顿随活对象量线性增加;ZGC 停顿始终保持在亚毫秒,与堆大小解耦,这是低延迟系统的质变优势。
第五章 Shenandoah 的 Brooks 指针与并发重定位
Shenandoah 同样追求并发回收,但走了一条不同的技术路线:它在每个对象的对象头前额外放置一个转发指针,称为 Brooks 指针。这个指针正常情况下指向对象自身,但当 GC 移动对象时,旧对象的 Brooks 指针会更新为新地址,变成转发指针。所有对对象的访问都必须通过 Brooks 指针间接进行。
5.1 Brooks 指针布局与并发重定位
在 Shenandoah 中,对象的布局如下:
[ Brooks Pointer (8 bytes) | Mark Word | Klass Pointer | Field1 | Field2 | ... ]
其中 Brooks Pointer 存储在对象起始地址,因此对象引用实际上指向的是 Brooks Pointer,而对象的真正数据区从偏移 +8 字节开始。应用线程读取对象字段时,生成的机器码首先加载 Brooks Pointer(即 [obj_ref]),得到对象的实际数据起始地址,然后再基于该地址访问各个字段。这增加了一次间接寻址,但使得对象移动对应用线程透明。
并发重定位的过程:
- GC 线程选择需要回收的 Region,并为存活对象在新 Region 中分配空间,拷贝对象内容(包括 Brooks 指针区域,新对象的 Brooks 指针指向自身数据区即
new_addr + 8)。 - GC 线程使用 CAS 操作将旧对象的 Brooks 指针从指向自身更新为新对象的数据区地址(即
new_addr + 8)。 - 如果 CAS 成功,说明该对象的转发由本 GC 线程完成;如果失败,说明其他线程已经完成转发,直接使用新值。
- 此后,任何访问旧对象的应用线程通过加载旧对象的 Brooks 指针,获得新地址,从而透明地访问到新对象。
- 在并发拷贝之后,Shenandoah 还有一个并发引用更新阶段:GC 线程和 Java 线程协作遍历堆中的所有引用,将指向旧对象的引用更新为新地址。这通过写屏障或专门的并发更新线程完成。与 ZGC 的 Load Barrier 自愈不同,Shenandoah 是主动更新所有引用,而不是在读取时被动修正。但读取时的 Brooks 指针间接寻址保证了在更新完成前,应用线程也能正确访问对象。
flowchart LR
subgraph Before[Before Evacuation]
Old[Old Object: BrooksPtr -> self] -->|"对象字段..."| OldData[Field1, Field2...]
App1[App Thread] -->|reads via BrooksPtr| OldData
end
subgraph After[During Concurrent Evacuation]
Old2[Old Object: BrooksPtr now forwards] -->|points to| NewData[New Object Data]
App2[App Thread] -->|reads via BrooksPtr| NewData
GC[GC Thread] -->|updates refs| NewData
end
Old -.-> Old2
a) 主旨概括:该图对比对象移动前后 Brooks 指针的状态变化:移动前指向自身数据,移动后指向新拷贝的数据区,应用线程无需暂停即可通过转发指针访问新对象。
b) 逐元素分解:左侧框旧对象 Brooks 指针自指,应用线程通过它访问字段;右侧框 GC 线程改变了旧对象的 Brooks 指针,使其指向新对象数据区,应用线程再访问时会自动重定向。GC 线程后续还会主动更新其他引用。
c) 设计原理映射:Brooks 指针实质上是一个对象级的转发指针,与 JVM 传统的 Mark Word 中的 forwarding pointer 类似,但独立存放并强制所有访问经过它。这允许并发拷贝和并发引用更新,但增加了每次对象访问的一次间接寻址开销。Shenandoah 的并发引用更新类似于一个分布式引用修正任务,在后台逐渐完成,不需要读屏障自愈。
d) 工程联系与关键结论:Brooks 指针不需要染色指针和多映射,因此 Shenandoah 可以运行在 ARM 等 64 位非 x86 平台,平台兼容性更好。缺点是每个对象额外消耗 8 字节内存,并且字段访问多一层间接跳转,可能对 CPU 流水线不友好。在内存受限或对象极小的应用中,内存膨胀可能成为关注点。
5.2 ZGC Load Barrier 与 Shenandoah Brooks 指针的对比
- 内存开销:ZGC 无对象头增加,零内存开销;Shenandoah 每个对象多 8 字节,小对象密集型应用内存膨胀明显。
- 访问开销:ZGC 的 Load Barrier 在正常路径(颜色匹配)几乎只有一次位测试,开销较小;Shenandoah 需要额外解引用一次 Brooks 指针,每次字段访问多一次 Load,可能影响流水线。
- 平台要求:ZGC 依赖 64 位指针高位和多映射,限 Linux/x86_64 等;Shenandoah 对硬件和 OS 要求更低,可运行在 ARM、macOS 等。
- 设计复杂度:ZGC 需要维护多个虚拟地址视图和复杂的 GC 视图切换逻辑;Shenandoah 的 Brooks 指针更为直观,但需要处理转发指针的 CAS 竞争和所有引用更新,以及并发引用更新阶段的正确性和性能。
第六章 ZGC vs Shenandoah vs G1:多维对比
从停顿模型、屏障开销、吞吐量到适用场景,三种收集器有着清晰的定位差异。
| 维度 | G1 | ZGC | Shenandoah |
|---|---|---|---|
| 停顿目标 | 百毫秒级(目标 ~10-100ms) | 亚毫秒级(<1ms, JDK21 <0.1ms) | 亚毫秒级(通常 <1ms) |
| 停顿来源 | Mixed GC 复制对象 STW | 仅根扫描等极短 STW | 类似,并发复制,但引用更新可能造成微小停顿 |
| 屏障类型 | 写屏障(维护 RSet)+ pre-write barrier (SATB) | 读屏障(Load Barrier)+ SATB pre-write barrier(仅并发标记期间) | Brooks 指针间接访问 + 写屏障(并发引用更新) |
| 并发模型 | 并发标记,STW 回收 | 并发标记 + 并发重映射 | 并发标记 + 并发复制 + 并发引用更新 |
| 堆结构 | Region(分代) | Page(不分代/分代 ZGC) | Region(不分代) |
| 内存开销 | RSet 占用(约 1-5%) | 染色指针位 + 多映射虚拟地址消耗(无对象头开销) | Brooks 指针(8 字节/对象) |
| 吞吐量 | 较高 | 通常低于 G1(5-15% 损失),分代 ZGC 接近 G1 | 接近 ZGC,略低,受 Brooks 指针间接寻址影响 |
| 适用堆大小 | 4GB–32GB | 16GB–16TB | 4GB–更大 |
| 平台限制 | 全平台 | Linux/x86_64 等支持多映射的 OS | 全平台(不含 Oracle JDK) |
| 巨型对象处理 | Humongous Region,回收延迟 | 并发移动大 Page,回收及时 | 并发拷贝跨 Region 大对象,回收及时 |
三种收集器的设计哲学决定了选择。G1 仍是吞吐量和延迟折中的优秀方案;ZGC 是极限延迟和超大堆的利器;Shenandoah 是平台无关的低延迟备选。值得注意的是,分代 ZGC 的出现在吞吐量上极大缩小了与 G1 的差距,使得 ZGC 在未来有可能成为通用默认收集器。
第七章 JDK 版本演进与生产可用性
ZGC 演进路线:
- JDK 11(2018):实验性引入,需
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC,堆上限 4TB,停顿目标 <10ms。此时 ZGC 比较基础,存在一些稳定性问题。 - JDK 13(2019):支持最大堆从 4TB 扩展至 16TB(通过压缩元数据位至 2 位,地址位扩展至 44 位);引入
-XX:ZAllocationSpikeTolerance参数;开始支持将未使用的堆内存归还操作系统(-XX:ZUncommitDelay)。 - JDK 15(2020):正式生产可用,移除实验性标记,无需解锁选项即可使用
-XX:+UseZGC。此时 ZGC 已在众多大型系统中验证,停顿稳定在亚毫秒。 - JDK 16(2021):实现并发线程栈扫描(Concurrent Thread Stack Scanning),这是 JEP 376。此前 ZGC 的 Mark Start 和 Relocate Start 需要扫描所有 Java 线程的栈帧来找到 GC Roots,是 STW 的主要耗时来源。JDK 16 将这一工作改为并发执行:在安全点之后,并发标记线程与各 Java 线程协作,由 Java 线程自己遍历栈并报告根引用,然后并发标记线程处理。这使得根扫描的 STW 降至几乎为零。但引入并发栈扫描也带来额外复杂性(例如处理栈上引用更新)。
- JDK 17(2021):稳定性增强,修复多个并发 bug,并且 ZGC 开始支持
-XX:+UseTransparentHugePages等大页优化。 - JDK 21(2023):分代 ZGC(Generational ZGC) 成为正式特性(JEP 439),同时单代 ZGC 依然保留。分代 ZGC 将堆分为年轻代和老年代,年轻代采用频繁的 Minor GC(并发标记 + 并发重映射),老年代采用低频的 Major GC,两者均依赖染色指针和 Load Barrier。分代 ZGC 大幅提升了吞吐量,在 SPECjbb2015 等基准测试中与 G1 持平甚至反超,同时最大停顿仍维持在 <0.1ms。这是 ZGC 里程碑式的进步,使得超低延迟不再以严重牺牲吞吐量为代价。
Shenandoah 演进路线:
- JDK 12(2019):作为生产特性引入(仅 OpenJDK 构建提供,Oracle JDK 不包含),停顿在毫秒级。一开始就支持并发栈扫描。
- JDK 13-16:持续优化停顿控制和并发引用更新算法,增加被动回收、自适应启发式等。
- JDK 17(2021):增强停顿控制,增加
adaptive、compact等启发式策略,支持将未使用内存归还操作系统。 - 未来:Shenandoah 持续作为 OpenJDK 的组成部分维护,主要优势在于跨平台低延迟和较简单的实现模型,与 ZGC 形成互补。
关键转折:JDK 21 分代 ZGC 的推出标志着 ZGC 在吞吐量和低延迟之间找到了更好的平衡,使其有望在未来取代 G1 成为默认收集器。而 Shenandoah 则因其平台无关性,在非 x86 生态中拥有独特地位。
第八章 参数调优与工程实战
本章系统梳理 ZGC 和 Shenandoah 的核心调优参数、适用场景、监控方法和故障排查思路。参数调优围绕停顿目标与吞吐量损失的平衡展开。
8.1 适用场景选择
ZGC 首选:
- 超大堆(≥32GB),延迟 SLO <10ms(如金融交易、实时风控、在线广告、高频交易)。
- JDK 17+ 且部署在 Linux/x86_64 环境(生产首选 JDK 21 分代 ZGC)。
- 对停顿极度敏感,无法容忍 G1 Mixed GC 带来的百毫秒级停顿。
Shenandoah 首选:
- 需要亚毫秒停顿,但平台不支持 ZGC(如 ARM 服务器、macOS 开发环境、Windows 无多映射支持)。
- 采用非 Oracle JDK 的发行版(Red Hat 等提供 Shenandoah 构建)。
- 堆大小可在 4GB 以上,对象模型中等(Brooks 指针内存开销可接受)。
G1 仍优选:
- 堆 4–32GB,延迟目标 20–100ms,对吞吐量敏感的一般企业应用。
- 平台无关性要求高(需要同时支持多种 OS 和 CPU 架构)。
- 对 GC 调优有经验积累,G1 的调优空间更大。
8.2 ZGC 核心参数详解
下表汇总 ZGC 的关键参数:
| 参数 | 默认值 | 控制目标 | 调优原则与诊断信号 |
|---|---|---|---|
-XX:+UseZGC | 关闭 | 启用 ZGC | JDK 15+ 直接使用,无需解锁选项。JDK 21 可额外加 -XX:+ZGenerational 启用分代。 |
-XX:ZAllocationSpikeTolerance | 2.0 | 分配尖峰容忍因子,决定 GC 触发时机 | 增大(如 3.0–5.0):减少 GC 频率,适用于分配速率平稳、可容忍偶尔 Allocation Stall 的场景。减小(如 1.0–1.5):内存压力大、频繁 Allocation Stall 时降低以提前触发 GC。诊断信号:GC 日志中出现 Allocation Stall,说明内存耗尽,应降低此值或增大堆。 |
-XX:ConcGCThreads | 自动(约为核数的 1/4) | 并发 GC 线程数 | 增大可加快并发标记和重映射,减少 Mark End 积压。但过多线程抢占应用 CPU。Remark/Mark End 耗时长时增大。建议不超过 CPU 核数的 1/3。 |
-XX:ParallelGCThreads | 自动 | STW 阶段并行线程数 | ZGC 的 STW 极短,通常默认值足够。在极端多核系统可适当增大以加速根扫描。 |
-XX:ZUncommitDelay | 300 秒 | 未使用内存交还 OS 前的延迟 | 堆使用波动大的应用可缩短(如 60 秒),以更快释放内存给其他进程。注意频繁 uncommit/commit 有开销。 |
-XX:+ZGenerational | JDK 21+ 默认 false | 启用分代 ZGC | 推荐 JDK 21+ 生产环境开启,显著提升吞吐量。分代 ZGC 增加了一些新参数如 -XX:ZYoungCollectionInterval 等,但一般默认即可。 |
-XX:SoftMaxHeapSize | 无 | ZGC 特有:软性堆上限,超过后 ZGC 更积极回收 | 当应用需要共享物理内存时(如容器),设置软上限,ZGC 会尽量将堆控制在软上限以下,仅在无法满足时才允许临时超出。 |
-Xmx -Xms | 必须相等 | 堆大小 | ZGC 强烈建议 -Xms 与 -Xmx 相同,避免运行时堆扩展带来的停顿。同时 ZGC 的地址空间预留需要固定范围。 |
ZAllocationSpikeTolerance 深入
该参数直接控制 ZGC 的触发算法。ZGC 内部维护一个“分配速率”的统计,并预测当前 GC 周期完成所需时间。公式大致为:
触发条件 = (堆剩余空间 < (当前分配速率 × 预测GC耗时 × ZAllocationSpikeTolerance))
当这个条件成立时,ZGC 启动新一轮 GC。增大因子值,使得在分配速率尖峰时,ZGC 不会立即触发,避免过于频繁的 GC;但如果尖峰持续过久,剩余空间耗尽,就会发生 Allocation Stall——此时 ZGC 会强制暂停所有线程,等待回收完成,这个停顿可能达到毫秒级,破坏了亚毫秒承诺。因此,在内存充裕且负载平稳时,可适当调大该值提升吞吐;在负载波动剧烈或内存紧张时,应调小甚至设为 1.0 使 ZGC 尽可能提前工作。
8.3 Shenandoah 核心参数详解
| 参数 | 默认值 | 控制目标 | 调优原则与诊断信号 |
|---|---|---|---|
-XX:+UseShenandoahGC | 关闭 | 启用 Shenandoah | JDK 12+ 可用,无需解锁选项。 |
-XX:ShenandoahGCHeuristics | adaptive | 启发式策略 | adaptive 适应绝大多数场景;static 类似 G1 固定 IHOP,需配合 -XX:ShenandoahInitFreeThreshold;aggressive 极度追求低停顿,吞吐低;compact 重视碎片整理。停顿不稳定时可尝试切换策略。 |
-XX:ShenandoahAllocationThreshold | 自动 | 触发 GC 的堆占用阈值(用于 static 策略) | 配合 static 启发式,类似 G1 的 IHOP。降低值提前 GC,升高值延迟 GC。 |
-XX:ShenandoahRegionSize | 256KB | Region 大小 | 影响内部碎片和回收粒度。对象大小分布广的应用可考虑增大(如 1MB)以减少跨 Region 对象的分配开销。 |
-XX:ConcGCThreads | 自动 | 并发 GC 线程数 | 类似 ZGC,调整并发复制速度。并发引用更新慢时增大。 |
-XX:ShenandoahUncommitDelay | 300 秒 | 未使用内存交还 OS 的延迟 | 同 ZGC。 |
-XX:ShenandoahPacing | 开启 | 分配节奏控制 | Shenandoah 在 GC 期间会故意为应用线程的分配引入微小延迟(backoff),以防止分配速率超过回收速率导致堆满。通常保持开启。 |
8.4 监控与日志分析
ZGC 日志:
启用 -Xlog:gc*:file=gc.log:time,uptime,level,tags。典型正常日志片段:
[0.120s][info][gc] GC(0) Garbage Collection (Proactive)
[0.121s][info][gc] GC(0) Pause Mark Start 0.032ms
[0.152s][info][gc] GC(0) Concurrent Mark 30.587ms
[0.153s][info][gc] GC(0) Pause Mark End 0.021ms
[0.155s][info][gc] GC(0) Concurrent Select Relocation Set 1.723ms
[0.156s][info][gc] GC(0) Pause Relocate Start 0.018ms
[0.178s][info][gc] GC(0) Concurrent Relocate 21.845ms
关注点:
Pause Mark Start/End,Pause Relocate Start应全部 <0.1ms。- 若
Pause Mark End变长,检查是否 SATB 队列积压,考虑增加并发线程。 - 若出现
Allocation Stall,说明堆内存耗尽,需调整ZAllocationSpikeTolerance或增大堆。
Shenandoah 日志:
[0.200s][info][gc] GC(0) Pause Init Mark 0.045ms
[0.250s][info][gc] GC(0) Concurrent marking 48.123ms
[0.251s][info][gc] GC(0) Pause Final Mark 0.034ms
[0.260s][info][gc] GC(0) Concurrent evacuation 12.456ms
[0.270s][info][gc] GC(0) Concurrent update references 5.678ms
关注所有 Pause 阶段耗时。若 Final Mark 或 Init Update Refs 停顿变长,考虑调整启发式策略。
8.5 调优闭环流程
- 设定基线:
-Xms与-Xmx相同,开启 GC 日志,选定收集器。 - 监控停顿:确认所有 STW 阶段 <0.1ms(ZGC)或 <1ms(Shenandoah)。
- 吞吐量对比:与 G1 对比业务吞吐量,若损失 >15%,尝试分代 ZGC 或 Shenandoah 的
compact策略。 - 排查 Allocation Stall:若出现,降低分配尖峰容忍度或增大堆。
- 并发线程调优:若 Mark End 或并发引用更新阶段耗时占比过高,适当增加
ConcGCThreads。 - 启发式策略调整(Shenandoah):根据应用行为尝试不同 heuristics。
- 迭代收敛:每次只改动一个参数,压测观察效果。
关键原则:ZGC 和 Shenandoah 的调优空间远小于 G1,因为它们的设计初衷就是“开箱即用”。多数场景默认参数已足够,人工干预应集中在异常场景。
面试高频专题
以下面试题为独立模块,每题按四段式结构撰写完整解答。
1. ZGC 的染色指针是什么?64 位指针如何划分地址位和 metadata 位?
① 一句话回答:ZGC 的染色指针是将 64 位虚拟地址的高位部分用作 GC 元数据存储,42 位地址、4 位元数据、18 位保留,实现零对象头开销的 GC 状态记录。
② 详细解释:ZGC 将 64 位指针划分为 42 位地址(支持 4TB 堆)、4 位元数据(Marked0, Marked1, Remapped, Finalizable)和 18 位保留位。元数据位编码了对象在当前 GC 周期中的状态:标记色表示对象已被标记存活,Remapped 表示指针已指向新地址。通过操作系统的多映射,同一物理内存被映射到 Marked0、Marked1、Remapped 三个虚拟地址视图,指针颜色决定 CPU 访问哪个视图。这使 ZGC 无需对象头记录 GC 状态,也无需 RSet 维护跨区引用,因为指针自身携带了所有必要信息。
③ 多角度追问:
- 若使用 48 位虚拟地址,哪些位可用? 答:Linux 用户空间通常使用 47 位(高位为0),ZGC 标准模式借用第 42-45 位作为元数据位,这些位在硬件地址转换前会被屏蔽,不影响物理地址计算。
- 多映射如何保持缓存一致性? 答:不同虚拟地址映射到同一物理页,CPU 缓存通过物理地址保持一致,因此多个视图访问同一物理位置是缓存友好的,不会出现数据不一致。
- 堆大于 4TB 时如何扩展? 答:JDK 13+ 通过将元数据位压缩至 2 位(仅 Marked 和 Remapped,交替使用两个标记视图),地址位扩展至 44 位,支持 16TB 堆。
④ 加分回答:染色指针的本质是将 GC 元数据嵌入到指针本身的空闲位中,这是一种“指针压缩的逆操作”。传统 Compressed OOP 通过压缩 64 位指针到 32 位来节省空间,而 ZGC 则利用了 64 位指针的冗余位来编码信息。这种设计不仅消除了对象头 GC 标记位,还使得 ZGC 的 Load Barrier 只需要一次位测试,而不需要访问对象头,极大地降低了屏障开销。在实际硬件上,由于 MMU 自动处理高位屏蔽,染色指针的地址计算几乎没有额外延迟,这也使得 ZGC 在极限延迟场景下表现优异。
2. ZGC 的 Load Barrier 是如何实现“自愈”的?为什么 ZGC 不需要写屏障?
① 一句话回答:Load Barrier 在每次读取引用时检查指针颜色是否匹配当前 GC 阶段,若不匹配则从旧地址读取转发指针并更新引用字段,之后该位置不再需要修正;ZGC 无需写屏障是因为染色指针自带 GC 状态,无需维护 RSet。
② 详细解释:JIT 在对象引用加载(如 getfield)后插入 barrier。fast path 通过位测试检查颜色,匹配则直接通过。不匹配时进入 slow path:从旧地址读取 forwarding pointer(存储在旧对象的 Mark Word),得到新地址并修正颜色,然后以 CAS 写回原引用字段,实现“自愈”。此后同一位置再次访问即为好指针。由于所有引用都可被读屏障修正,ZGC 不需要写屏障来记录跨区引用——染色指针自身标志了对象的移动状态,读屏障充当了隐式的引用修正器。
③ 多角度追问:
- 自愈的并发安全性如何保证? 答:使用原子 CAS 更新引用字段,多个线程并发自愈时只有一个成功,其他线程会看到更新后的好颜色,从而避免重复修正。
- 能否将 Load Barrier 优化为仅在 GC 期间启用? 答:理论上可以,但需要动态切换 JIT 代码,这会引入额外复杂性和去优化开销。ZGC 选择始终开启,但通过 JIT 优化将 fast path 开销降至极低。
- 大量自愈是否会引起性能抖动? 答:在重映射阶段早期,自愈可能频繁发生,但随着更多引用被修正,自愈率迅速下降。JIT 的屏障提升进一步减少了实际屏障调用次数,通常不会引起明显抖动。
④ 加分回答:ZGC 的 Load Barrier 设计借鉴了 Azul Systems 的 C4 收集器的“读屏障自愈”思想,但 C4 需要专门的硬件指令支持,而 ZGC 完全在软件层面实现,并通过染色指针避免了额外的对象头标记。C2 编译器对 Load Barrier 的优化是 JVM 中最精妙的部分之一:屏障提升(barrier hoisting)可以将循环中的重复屏障提取到循环前,极大降低开销。此外,Load Barrier 的 slow path 采用模板解释器和 C2 运行时 stubs 实现,避免手写汇编,提高了可移植性和维护性。
3. ZGC 的 GC 阶段有哪些?哪些阶段需要 STW?并发重映射阶段为什么不需要 STW?
① 一句话回答:ZGC 周期包括 Mark Start (STW) → Concurrent Mark → Mark End (STW) → Concurrent Select Relocation Set → Relocate Start (STW) → Concurrent Relocate;并发重映射不需要 STW 因为对象复制和引用更新都通过 Load Barrier 与应用线程并发执行。
② 详细解释:六个阶段中仅 Mark Start、Mark End、Relocate Start 需要 STW。Mark Start 扫描 GC Roots 并染色;Mark End 处理 SATB 队列完成标记;Relocate Start 修正根引用以指向新地址。并发重映射期间,GC 线程复制对象并在旧地址设置转发指针;应用线程通过 Load Barrier 自愈修正引用,双方并发进行,无需暂停。停顿全部在亚毫秒级,与堆大小无关。
③ 多角度追问:
- 并发重映射中如何避免对象多次复制? 答:GC 线程在移动对象时,通过 CAS 在旧地址原子地设置 forwarding pointer。如果应用线程或另一个 GC 线程也尝试移动,会发现 forwarding pointer 已存在,从而放弃移动,直接采用已有新地址。
- GC 线程和应用线程竞争转发指针时如何处理? 答:应用线程在自愈时读取旧地址转发指针,如果转发指针尚未设置(GC 线程还未完成拷贝),应用线程会自旋等待一小段时间,或者帮助完成拷贝(可选优化),之后获取新地址。
- ZGC 重映射阶段是否会产生浮动垃圾? 答:是的,因为 ZGC 目前是单代回收(分代 ZGC 前),并发标记结束后到重映射开始前新死亡的对象不会被本次 GC 回收,成为浮动垃圾,留待下次 GC 处理。
④ 加分回答:ZGC 的并发重映射借鉴了操作系统的“进程迁移”思想:将正在运行的程序(对象)从一台机器(旧 Page)迁移到另一台(新 Page),而访问者的引用(指针)通过染色和自愈自动更新。这种设计的精妙之处在于,它允许 GC 线程和应用线程以完全无锁的方式在大多数时间并行工作,只有转发指针的 CAS 操作需要原子性,而 CAS 失败只意味着其他线程已完成工作,直接采用即可。这种乐观并发控制机制是实现亚毫秒停顿的重要基础。
4. Shenandoah 的 Brooks 指针是什么?它如何实现并发重定位?
① 一句话回答:Brooks 指针是 Shenandoah 在每个对象头前额外保留的一个指针,正常情况下指向对象自身数据区,对象移动时更新为新地址,应用线程通过它间接访问对象,实现并发重定位。
② 详细解释:对象布局为 [BrooksPtr][MarkWord][KlassPtr]...,所有字段访问必须先加载 BrooksPtr 得到真实数据起始地址。GC 拷贝对象后,用 CAS 将旧对象的 BrooksPtr 改为新对象数据区地址。此后应用线程访问旧对象时,通过 BrooksPtr 自动转发到新对象。Shenandoah 后续还有一个并发引用更新阶段,将堆中所有指向旧对象的引用更新为新地址,更新完成后旧 Region 可回收。
③ 多角度追问:
- Brooks 指针的 CAS 竞争如何处理? 答:CAS 失败意味着其他线程已经移动了对象,直接采用新值即可。转发指针的 CAS 设计保证了对象只被移动一次。
- 为何不直接用对象头的 Mark Word 作为转发指针? 答:Mark Word 需要存储锁、GC 年龄、Hash 等信息,与转发指针状态机冲突。独立的 Brooks 指针将转发功能与对象状态解耦,简化了实现,但付出了 8 字节内存代价。
- Shenandoah 如何保证 Brooks 指针更新对应用线程的可见性? 答:Brooks 指针是 volatile 语义(通过内存屏障指令),应用线程总能读取到最新值,不会看到过时的转发指针。
④ 加分回答:Brooks 指针的名字来源于 Rodney A. Brooks,他在 1984 年的一篇关于“并发复制垃圾收集”的论文中提出了这一机制。虽然概念简单,但在工业级 JVM 中实现需要处理许多细节:例如 Brooks 指针与对象锁的交互(锁状态存储在 Mark Word,而 Brooks 指针在 Mark Word 之前);Brooks 指针与对象哈希的交互(对象哈希也可能缓存在对象头);以及 JIT 编译器中如何高效生成间接寻址代码。Shenandoah 在 C2 编译器中使用了特殊的“Shenandoah 屏障”节点,在每次对象字段访问时自动插入 Brooks 指针解引用,并通过 GVN 优化消除冗余加载。
5. ZGC 和 G1 在停顿来源上有什么本质区别?为什么 ZGC 能实现亚毫秒级停顿?
① 一句话回答:G1 停顿来源于 Evacuation 阶段的对象复制和引用更新必须 STW,而 ZGC 通过并发重映射和 Load Barrier 消除了这一 STW,停顿仅剩极短的根处理和状态切换。
② 详细解释:G1 的 Mixed GC 复制存活对象时,必须暂停应用线程以保证引用一致性,因为 G1 没有机制在并发复制时处理应用线程持有的旧引用。ZGC 的染色指针使指针自带状态,Load Barrier 在每次访问时自动修正旧引用,因此复制阶段可以完全并发。ZGC 的 STW 仅发生在必须同步的根扫描和状态切换,这些工作与堆大小和活对象量无关,只与根集合大小(线程数、栈深度)相关。JDK 16 并发栈扫描后,根扫描的绝大部分工作也并发化了,进一步缩短 STW。
③ 多角度追问:
- ZGC 是否可能在根扫描时遇到长时间停顿? 答:在 JDK 16 之前,大量线程和深栈确实会导致 Mark Start 停顿变长。JDK 16 并发栈扫描消除了这一问题,每个线程自己遍历栈并向 GC 线程报告根引用,GC 线程并发处理,不再有集中的 STW。
- 如果 ZGC 的并发重映射线程跟不上分配速度会怎样? 答:堆剩余空间耗尽时,ZGC 会触发 Allocation Stall——强制暂停应用线程,等待回收出足够空间。这通常是堆过小或分配尖峰容忍度设置不当。
- ZGC 在分配新对象时需要屏障吗? 答:不需要。新对象在分配时就被赋予当前好颜色,Load Barrier 检查直接通过,无额外开销。
④ 加分回答:G1 和 ZGC 的停顿差异本质上是“STW 复制 vs 并发复制”的范式区别。G1 采用的是传统的“暂停-复制-更新”模型,停顿与活对象量成正比,虽然通过 Region 化和 CSet 选择减少了单次复制的对象量,但无法突破 O(活对象) 的瓶颈。ZGC 的“并发复制-读屏障自愈”模型则将停顿复杂度降为 O(GC Roots),这个集合在 JDK 16 之后又通过并发栈扫描进一步压缩,最终实现了与堆大小完全解耦的亚毫秒停顿。这种设计使得 ZGC 甚至可以管理 16TB 级别的堆而停顿不变,而 G1 在 32GB 以上时 Evacuation 停顿就已显著。
6. ZGC 为什么需要多映射内存?多映射如何支持染色指针的 GC 状态切换?
① 一句话回答:多映射内存将同一物理页映射到多个虚拟地址范围,每个范围对应一种指针颜色,使得 CPU 可以直接通过指针的颜色位索引到正确视图,无需额外地址翻译,实现零成本状态视图切换。
② 详细解释:ZGC 在初始化时通过 mmap 为 Marked0、Marked1、Remapped 三个视图创建虚拟地址映射,全部指向相同的物理页。当指针携带某个元数据位时,其地址落入对应视图的虚拟地址区间,MMU 直接将其转换为物理地址。GC 状态切换只需改变全局“好颜色”变量,应用线程通过 Load Barrier 的位测试即可感知。多映射确保了旧颜色视图仍然可访问(例如旧地址的 forwarding pointer 在 Remapped 视图可读),不会出现非法访问。
③ 多角度追问:
- 多映射是否消耗额外的物理内存? 答:不消耗额外物理内存,但消耗额外的虚拟地址空间和页表条目。Linux 内核需要维护三份页表映射,可能会增加 TLB miss 概率。ZGC 通过使用大页(huge pages)来降低 TLB 压力。
- 页表切换开销多大? 答:没有传统意义上的“页表切换”。视图切换仅是改变一个全局变量,后续 Load Barrier 根据新的 good mask 进行判断,开销为寄存器中的掩码比较,几纳秒完成。
- 如果操作系统不支持多映射,能否模拟? 答:理论上可通过软件查表来模拟,但性能会大幅下降,失去染色指针的优势。这也就是 ZGC 绑定了多映射支持的原因。
④ 加分回答:多映射内存是 ZGC 实现零开销视图切换的关键。它巧妙地将 GC 元数据的语义与硬件 MMU 的虚拟地址转换结合起来,避免了传统 GC 中“在对象头写标记位”或“在全局表中查询状态”的额外访存。这种设计可以看作是对虚拟内存机制的一种创造性应用,其灵感可能部分来源于操作系统的内存映射文件技术和写时复制机制。工程上,多映射要求操作系统允许同一个文件描述符或共享内存区域被映射多次,Linux 完美支持这一点,但其他 OS(如早期 Windows)不一定支持,这限制了 ZGC 的平台可移植性。
7. Shenandoah 的 Brooks 指针和 ZGC 的 Load Barrier 在对象访问性能上各有什么优劣?
① 一句话回答:ZGC Load Barrier 在快速路径只有一次位测试,但慢路径需自愈,适用于读操作密集场景;Shenandoah Brooks 指针每次访问都多一次间接寻址,延迟稍高,但慢路径更直接。
② 详细解释:ZGC 的正常路径:mov reg, [ref_field];test reg, good_mask;jz good;非常快,仅需一个位测试和一个分支。Shenandoah 的每次对象字段访问都需先 mov base, [obj_ref](读取 Brooks 指针),然后 mov data, [base+offset],多一次访存,对 CPU 流水线和缓存可能不友好。但在对象移动场景,ZGC 需进入 slow path 自愈(可能包含 CAS 和内存屏障),Shenandoah 只需通过 Brooks 指针间接寻址,无需修正引用字段本身,慢路径开销更小。
③ 多角度追问:
- 两种方式对 CPU 缓存的影响如何? 答:ZGC 正常路径无额外访存,缓存友好;Shenandoah 的 Brooks 指针解引用增加了数据依赖,可能引起流水线停顿。但 ZGC 的自愈写回操作会修改引用字段,可能导致缓存行无效(MESI 状态切换),在并发激烈时有一定开销。
- 在写密集场景下哪种更优? 答:ZGC 的写操作完全无屏障,而 Shenandoah 需要写屏障跟踪引用更新。因此写密集场景 ZGC 具有明显优势。
- ZGC 分代版本是否会改变开销模型? 答:分代 ZGC 引入了 young GC,由于年轻代回收频繁,Load Barrier 的触发频率增加,但 JIT 优化(屏障提升)可以缓解。整体吞吐量已接近 G1。
④ 加分回答:这两种方案代表了低延迟 GC 的两大流派:ZGC 的“懒修正”策略将引用修正推迟到读取时,并利用指针本身携带状态,使得正常路径极为轻量,但修正时可能引起局部停顿和缓存竞争。Shenandoah 的“主动转发”策略通过额外的间接层将对象移动对访问透明化,但每次访问都付出间接寻址的固定代价。在微架构层面,ZGC 方案对分支预测器更友好(颜色匹配时跳转不触发),而 Shenandoah 方案的数据依赖链可能限制 CPU 的乱序执行能力。选择哪种,本质上取决于应用的访问模式:读多写少的应用可能偏好 ZGC,而存在大量长生命周期对象频繁移动的场景可能更适合 Shenandoah。
8. ZGC 的适用场景是什么?什么情况下不应该选择 ZGC?
① 一句话回答:ZGC 适用于超大堆(16GB–16TB)、延迟要求 <10ms 的服务;不适用于小堆(<4GB)、高吞吐量要求且可容忍百毫秒停顿的场景,以及非 Linux/x86_64 平台。
② 详细解释:ZGC 的设计目标就是极限低延迟,它的停顿与堆大小无关,因此堆越大,ZGC 相比 G1 的优势越明显。但 ZGC 的吞吐量通常比 G1 低 5-15%(分代 ZGC 已大幅缩小差距),在小堆上,G1 的停顿可能已在可接受范围内,而 ZGC 的吞吐量损失显得不划算。此外 ZGC 需要多映射内存和 64 位平台,在 Windows 或 ARM 上不可用。容器化微服务如果堆在 1-2GB,使用 Serial 或 Parallel 可能就足够了。
③ 多角度追问:
- 在 8GB 堆下 ZGC 和 G1 的停顿各是多少? 答:8GB 堆时 ZGC 停顿也能在 0.5ms 左右,但 G1 可能也只需 10-20ms,若 SLO 允许 20ms,G1 的吞吐量更高,更合适。
- 容器环境使用 ZGC 需要注意什么? 答:需要正确设置容器 CPU limits,否则
ConcGCThreads可能自动计算错误。添加-XX:+UseContainerSupport并确保 JDK 版本支持容器感知。 - 如果没有多映射支持的 WSL 环境能否使用 ZGC? 答:WSL2 基于 Linux 内核,提供了多映射支持,可以启用 ZGC,但性能可能不如原生 Linux,且需要较新版本。
④ 加分回答:ZGC 的“不应该选择”场景还可以扩展到对 GC 调优有强需求的团队。ZGC 的“开箱即用”哲学意味着可调优参数较少,如果遇到停顿或吞吐量问题,调优空间远不如 G1。例如 G1 可以通过调整 MaxGCPauseMillis、G1MixedGCCountTarget 等多个参数精细控制行为,而 ZGC 主要只能调整并发线程数和分配尖峰容忍度。因此,在一些对 GC 行为需要高度定制的遗留系统中,迁移到 ZGC 可能面临不可控风险,需要充分测试。另外,使用了 JNI 并且频繁访问 Java 对象的 Native 代码,在 ZGC 下需要正确处理染色指针(JNI 句柄已由 ZGC 特别处理),但自定义的本机内存操作可能无法感知染色,需要额外谨慎。
9. JDK 版本中 ZGC 经历了哪些关键演进?JDK 21 的 ZGC 与 JDK 11 有哪些变化?
① 一句话回答:从 JDK 11 实验性、最大 4TB 堆、停顿 ~10ms,到 JDK 15 生产可用,JDK 16 并发栈扫描,JDK 21 分代 ZGC、停顿 <0.1ms、吞吐量大幅提升。
② 详细解释:JDK 11 ZGC 停顿目标 <10ms,堆上限 4TB;JDK 13 扩展堆至 16TB;JDK 15 生产可用;JDK 16 实现并发线程栈扫描,消除了主要 STW 来源;JDK 21 引入分代回收,年轻代频繁 Minor GC,老年代 Major GC,两者均用染色指针和 Load Barrier,停顿不变但吞吐量大幅提升,接近 G1。
③ 多角度追问:
- 分代 ZGC 如何维护跨代引用? 答:引入了类似 G1 的 SATB 写屏障和卡表,用于年轻代 GC 时定位老年代对年轻代的引用,但只在年轻代收集时使用,对整体吞吐影响很小。
- JDK 21 是否移除单代 ZGC? 答:没有,单代 ZGC 仍然保留,通过
-XX:+UseZGC启用,分代需额外-XX:+ZGenerational。 - 未来 ZGC 会取代 G1 成为默认 GC 吗? 答:随着分代 ZGC 的成熟,它在吞吐量和延迟上都极具竞争力,很可能在未来的 JDK 版本中成为默认收集器,特别是对于大堆服务器应用。
④ 加分回答:ZGC 的演进速度在 JDK 11-21 这十年间堪称 GC 发展史上的奇迹。从最初需要解锁选项的试验品,到 JDK 21 成为可与 G1 全面竞争的生产级分代收集器,背后是 HotSpot 团队对并发算法、操作系统接口和编译器优化的深度整合。并发栈扫描的引入特别值得一提:它需要解决“如何在并发标记期间安全地遍历正在运行的线程的栈”这一难题,最终采用了安全点合作(handshake)机制,每个 Java 线程在安全点自我遍历栈并将根引用发布到线程本地结构,然后并发标记线程处理。这种设计不仅提升了 GC 性能,也为未来 JVM 的轻量级线程模型(Virtual Threads)提供了低开销栈扫描的基础。
10. 如何在实际项目中启用 ZGC 或 Shenandoah?有哪些核心调优参数?
① 一句话回答:添加 -XX:+UseZGC 或 -XX:+UseShenandoahGC 启用,核心调优包括并发线程数、分配尖峰容忍度、以及选择合适的启发式策略。
② 详细解释:对于 ZGC,主要设置 ConcGCThreads(建议核数/4),-XX:ZAllocationSpikeTolerance(默认 2.0,可适当调大减少 GC 频率);通过 -Xlog:gc* 监控停顿。Shenandoah 用 ShenandoahGCHeuristics=adaptive 自适应,调整 ShenandoahAllocationThreshold 和并发线程数。务必测试生产负载下的 GC 行为。
③ 多角度追问:
- 如何确定分配尖峰容忍度的最佳值? 答:通过监控 GC 日志中是否出现 Allocation Stall。如果无 Stall 且 GC 频率偏高,可增大该值;反之减小。
- ZGC 在 Kubernetes 中如何正确感知内存限制? 答:使用
-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0让 JVM 感知容器的内存限制,而不是宿主机内存。 - 使用分代 ZGC 需要额外参数吗? 答:JDK 21 中,
-XX:+UseZGC -XX:+ZGenerational即可启用,其他参数默认即可工作良好。
④ 加分回答:实际部署时,还应注意操作系统层面的优化。ZGC 使用多映射,大量虚拟地址映射可能导致 /proc/sys/vm/max_map_count 限制不足(默认 65530),需调高此值,否则 JVM 可能因 mmap 失败而崩溃。建议设置为 vm.max_map_count=262144。此外,ZGC 对 Transparent Huge Pages (THP) 的支持也很好,开启 THP 可以减少 TLB miss,提升大堆性能。在容器化环境中,还要注意 ZGC 的并行线程数计算可能因为宿主机的 CPU 核心数而偏高,可通过 -XX:ActiveProcessorCount 显式设置容器内的 CPU 核数。
11. (故障排查题)线上服务升级到 JDK 17 并启用 ZGC,堆大小 32GB,初期运行正常,但高峰期偶尔出现停顿超过 10ms 的“ZGC 暂停”。GC 日志显示“Pause Mark Start”和“Pause Relocate Start”阶段的耗时高于预期。请分析:(a) 哪些因素可能导致 ZGC 初始标记或最终标记的 STW 时间变长?(b) 如何通过 -XX:ZAllocationSpikeTolerance 等参数调优 ZGC 的内存分配速度与 GC 回收速度的匹配?(c) 画出 ZGC 各阶段耗时分布的时序图;(d) 如果调优后仍无法满足延迟要求,是否应该考虑升级硬件或迁移到 Shenandoah?
① 一句话回答:根扫描 STW 变长通常与大量线程、复杂线程栈或 NUMA 远端访问有关;调整 ZAllocationSpikeTolerance 和并发线程数可缓解;极端情况下可升级 JDK 21 分代 ZGC 或考虑 Shenandoah。
② 详细解释:
(a) Mark Start 和 Relocate Start 在 JDK 17 中仍然是 STW 扫描所有线程栈。可能因素包括:
- 服务线程数超过 500 甚至 1000,每个线程栈需要扫描,累加时间变长。
- 线程栈深度大(递归调用、深层框架),栈帧多,扫描耗时。
- NUMA 环境下,跨 NUMA 节点访问线程栈内存延迟更高。
- 安全点(safepoint)同步耗时:部分线程在 JNI 调用、长时间执行无安全点检查的代码(如计数循环)而迟迟无法进入安全点,导致 STW 被拖长。
- 堆内存紧张,ZGC 尝试回收时可能触发更频繁的 GC,加重累积效应。
(b) -XX:ZAllocationSpikeTolerance 控制 GC 触发的激进程度。该值设为 2.0 意味着 ZGC 在堆剩余空间小于(2 × 分配速率 × 预期 GC 耗时)时才触发。如果高峰期间分配速率波动大,可降低该值(如 1.2),使 ZGC 更早开始 GC,避免内存耗尽导致紧急回收时的额外开销。同时检查 -XX:ConcGCThreads,适当增加并发线程数以加快并发标记和重映射,减少 SATB 队列积压,从而缩短 Mark End 和后续 STW。还需确保 ZGC 的 -XX:ZUncommit 未开启或延迟足够长,避免频繁归还内存的开销。
(c) 异常耗时分布时序图:
gantt
title ZGC Phase Duration Distribution (Abnormal)
dateFormat X
axisFormat %s
section STW
Pause Mark Start :a1, 0, 5ms
Pause Mark End :a2, 10, 3ms
Pause Relocate Start:a3, 20, 4ms
section Concurrent
Concurrent Mark :b1, 5, 15ms
Concurrent Select :b2, 15, 2ms
Concurrent Relocate :b3, 24, 20ms
正常情况下 Mark Start 应 <0.1ms,这里达到 5ms,明确指示根扫描瓶颈。
(d) 如果 JDK 17 上调优后仍超过 10ms,首先强烈推荐升级到 JDK 21 分代 ZGC。JDK 16 之后并发栈扫描已大幅改善根扫描停顿,JDK 21 的分代优化也降低了单次回收负载,停顿会更稳定。其次,可以考虑增加更多物理内存以降低 GC 频率(但不会减少单次停顿的根扫描时间)。若平台限制无法升级 JDK,Shenandoah 是另一个选择,因为 Shenandoah 在 JDK 12 就已经实现了并发栈扫描,其 STW 通常比同期的 ZGC 更稳定(尤其在多线程场景),但迁移需重新评估内存开销(Brooks 指针)和吞吐量。
③ 多角度追问:
- 如何使用
jstack或 perf 工具定位引起长时间 safepoint 的线程? 答:添加-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=200,当 safepoint 超过 200ms 时,JVM 会打印所有未到达安全点的线程栈,帮助定位“元凶”。使用perf top观察 ZGC 相关函数的 CPU 消耗。 - ZGC 的
ZAllocationSpikeTolerance与最大堆大小如何配合? 答:堆越大,相同分配速率下剩余空间更多,可以允许更大的 SpikeTolerance。反之,堆紧张时降低该值。 - 如果 Allocation Stall 发生,日志特征是什么? 答:GC 日志中会出现
Allocation Stall字样,通常紧跟Pause Mark Start,表明堆已满,被迫暂停所有线程等待回收。
④ 加分回答:该故障排查的深层经验是:亚毫秒停顿的达成不仅依赖 GC 算法,还需要全栈协作。线程栈扫描的瓶颈往往不是 GC 的问题,而是应用架构的问题——大量线程或深层栈。此时除了调整 GC 参数,更重要的是在架构层面优化:减少线程数(使用异步非阻塞或 Virtual Threads)、减少长调用链。JDK 21 的 Virtual Threads 由于栈是堆外存储且轻量,对 ZGC 的栈扫描也有正面影响。此外,NUMA 感知调优(如使用 numactl 将 JVM 绑定到特定 NUMA 节点)也能降低跨节点访问延迟。