G1\Shenandoah\ZGC
G1\Shenandoah\ZGC
总结
-
G1垃圾收集器初始标记(并行,短暂stop the world)、并发标记(并发)、最终标记(并行,短暂stop the world)、筛选回收(即复制对象)(并行,较长stop the world)
-
Shenandoah垃圾收集器初始标记(并行,短暂stop the world)、并发标记(并发)、最终标记(并行,短暂stop the world)、筛选回收(即复制对象)(并发)
- 并发回收整理:转发指针(旧引用访问复制后的对象)、连接矩阵(分析和更新引用))
-
ZGC垃圾收集器初始标记(并行,短暂stop the world)、并发标记(并发)、最终标记(并行,短暂stop the world)、筛选回收(即复制对象)(并发)
- 并发回收整理:重分配集(用来判定回收哪些Region)、读屏障(ZGC使用了读屏障来完成指针的“自愈”)、染色指针(旧引用访问复制后的对象)、内存多重映射(操作系统支持)、NUMA(ZGC会优先在自己的本地内存上分配对象))
| 阶段 | G1 | Shenandoah | ZGC |
|---|---|---|---|
| 初始标记 | 并行,短暂 STW | 并行,短暂 STW | 并行,短暂 STW |
| 并发标记 | 并发 | 并发 | 并发 |
| 最终标记 | 并行,短暂 STW | 并行,短暂 STW | 并行,短暂 STW |
| 筛选回收(复制对象) | 并行,较长 STW | 并发 | 并发 |
关键补充差异
虽然 Shenandoah 和 ZGC 都是并发复制,但实现机制不同:
Shenandoah 的并发复制
- 使用 读屏障 + 写屏障(JDK 8/11)/ 引用访问屏障(JDK 17+)
- 复制对象时,在旧对象头中写入转发指针
- 用户线程通过读屏障检测对象是否被移动,按需修正
- Brooks Pointer 技术:每个对象头保留一个指针字段,移动后指向新地址
ZGC 的并发复制
- 使用 染色指针 + 读屏障
- 颜色信息直接编码在 64 位指针的高 4 位(JDK 11-15)或高 2 位(JDK 16+ 指针压缩优化后调整)
- 不需要在对象头中存储转发指针(节省内存)
- 支持 多重映射(Multiple Mapping),同一物理地址映射到多个虚拟地址,便于颜色切换
为什么 G1 不把复制也变成并发?
| 原因 | 说明 |
|---|---|
| 实现复杂度 | 并发复制需要屏障技术,G1 设计时(JDK 7)优先级是平衡吞吐量和停顿 |
| Remembered Set 维护 | G1 的 RSet 本就复杂,并发复制会使跨 Region 引用维护变得极其困难 |
| 目标定位 | G1 目标是替代 CMS,提供可预测的停顿(200ms 级别),并非亚毫秒级 |
| 屏障开销 | 读屏障/写屏障会降低吞吐量,G1 选择了更轻量的 SATB 写屏障 |
一句话总结对比
- G1:只有标记并发,复制还是要 STW(所以停顿相对较长)
- Shenandoah:标记和复制都并发,但要额外维护转发指针
- ZGC:标记和复制都并发,用染色指针实现,不占对象头额外空间
选择建议
| 场景 | 推荐 | 原因 |
|---|---|---|
| 更在意物理内存占用 | ZGC | 不增加对象头,物理内存占用更低 |
| 追求最低停顿(<1ms) | ZGC | 停顿更稳定 |
| 吞吐量优先但需要低停顿 | ZGC | 略高吞吐量 |
| 无法忍受染色指针的复杂性 | Shenandoah | 实现更“传统”,调试和理解更简单 |
| JDK 11 且需要生产稳定 | Shenandoah | JDK 11 的 ZGC 还是实验特性,Shenandoah 已稳定 |
Shenandoah转发指针
转发指针是 Shenandoah GC 实现并发复制的核心技术。
简单说:它是一个存储在对象头中的8字节指针字段,用于在对象被移动后,指向该对象的新地址。
- JDK 21 及以后:转发指针被“藏”起来了(压缩或内嵌),但机制依然在。转发指针Brooks Pointer 嵌入到 Mark Word 的高位中,不再独立占用 8 字节。
对象搬家后,旧对象里留一张"新地址纸条",所有访问都通过这张纸条跳转到新家。
正常情况(对象未移动):
┌─────────────────┐
│ Mark Word │
├─────────────────┤
│ Class Pointer │
├─────────────────┤
│ Brooks Pointer │──────┐
├─────────────────┤ │ 指向自己
│ 实例字段... │ │ (self pointer)
└─────────────────┘◄─────┘
对象被移动后(并发复制):
┌─────────────────┐ ┌─────────────────┐
│ Mark Word │ │ Mark Word │
├─────────────────┤ ├─────────────────┤
│ Class Pointer │ │ Class Pointer │
├─────────────────┤ ├─────────────────┤
│ Brooks Pointer │────────┼→│ Brooks Pointer │──────┐
├─────────────────┤ ├─────────────────┤ │ 指向新对象
│ 旧对象(已空) │ │ 实例字段... │ │
└─────────────────┘ └─────────────────┘◄─────┘
用户线程持有一开始指向“旧对象”的指针,
但通过 Brooks Pointer 的间接寻址,最终访问到“新对象”。
- JDK 11 及以前:转发指针是显式的 8 字节,用 JOL 一眼就能看到。
- JDK 21 及以后:转发指针被“藏”起来了(压缩或内嵌),但机制依然在。为了观察,要么降级 JDK 版本,要么使用更底层的调试工具来捕捉对象被搬运后的瞬间内存变化。
- 转发指针Brooks Pointer 嵌入到 Mark Word 的高位中,不再独立占用 8 字节。
测试疑问
我用jdk11、jdk21测试下来,都没看到Brooks Pointer的占用,具体怎么才能看,目前真不知道。
// 无论用8、11、21版本的jdk打印都是这个。都没看到Brooks Pointer的占用。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 10 00 00 (00000000 00010000 00000000 00000000) (4096)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
ZGC染色指针
染色指针(Colored Pointer) 是 ZGC 的核心技术,通过在 指针本身的空闲位 中存储 GC 状态信息,让 GC 在不访问对象头的情况下就能知道对象的状态。
基本原理
在 64 位系统上,指针是 64 位(8 字节)的。但实际上我们并不需要全部 64 位来寻址(比如 4TB 堆只需要 42 位)。ZGC 利用高位空闲位来存储颜色信息。
JDK 11-15 的实现(64 位指针布局):
63-42位 (22位) | 41-18位 (24位) | 17-1位 (17位) | 0位
颜色位 实际地址(堆内偏移) 保留 finalizable位
指针本身的空闲位是指Student s = new Student(),s的这个地址的空闲位吗?:
s这个变量里存储的那个 64 位的内存地址值,它本身的一些高位(比如第 42-63 位)就是染色指针所说的“空闲位”。- 染色指针染的不是对象,而是指向对象的那个“指针变量”里存储的地址数值的高位空闲位。
堆内存上限
在 64 位系统上,指针是 64 位(8 字节)的。但实际上我们并不需要全部 64 位来寻址(比如 4TB 堆只需要 42 位)。 这个4TB 堆指的是jvm的堆内存还是系统内存?
指的是 JVM 的堆内存,不是系统物理内存。
| JDK 版本 | ZGC 最大堆 | 寻址所需位数 | 颜色位占用 |
|---|---|---|---|
| JDK 11-15 | 4TB | 42 位 | 22 位 |
| JDK 16+ | 16TB | 44 位 | 20 位(优化后) |
JDK 16 之后,通过压缩指针优化,将最大堆提升到 16TB(需要 44 位寻址)。
分代模式
无论是 Shenandoah 还是 ZGC,现在默认都已经启用分代模式。虽然它们最初确实是默认非分代的,但为了获得更好的性能表现和更低的维护成本,OpenJDK 团队已相继将默认行为切换为分代模式
| 垃圾收集器 | 当前默认模式 | 如何显式启用分代模式 | 如何切换回非分代模式 (不推荐) |
|---|---|---|---|
| ZGC | 分代模式 (从 JDK 21 开始引入,JDK 23 起成为默认) | -XX:+UseZGC (无需额外参数) |
-XX:+UseZGC -XX:-ZGenerational (会收到弃用警告,未来版本将移除) |
| Shenandoah | 分代模式 (计划中,目标 JDK 24/25 使其成为默认) | -XX:+UseShenandoahGC (无需额外参数) |
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb (会收到弃用警告,未来版本将移除) |
ZGC:
- JDK 11-20:只需指定
-XX:+UseZGC即可启用非分代模式。 - JDK 21-22:使用
-XX:+UseZGC -XX:+ZGenerational开启分代模式(当时为可选)。 - JDK 23+:使用
-XX:+UseZGC就会默认启用分代模式,无需额外参数
Shenandoah
- JDK 11-23:非分代模式。
- JDK 24:使用
-XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions XX:ShenandoahGCMode=generational开启分代模式(当时为可选)。 - JDK 25+:计划默认使用
-XX:+UseShenandoahGC就会默认启用分代模式,无需额外参数
🔍 为什么会发生这种变化?
这个转变的背后是技术的必然选择,主要有两个原因:
- 性能更优:分代收集基于“弱分代假说”(大部分对象朝生夕灭),能更频繁地回收新生代区域。这带来了更低的并发标记开销、更高的分配吞吐量以及更小的内存占用,对绝大多数应用都是更好的解决方案。
- 降低维护成本:同时维护分代和非分代两套代码,会严重拖累新功能的开发。因此,OpenJDK 团队决定统一为分代模式,并最终移除老的非分代代码,以减轻长期的维护负担。
💡 影响及建议
- 默认就是最佳选择:对于绝大多数Java应用来说,直接使用
-XX:+UseZGC或-XX:+UseShenandoahGC启动,让JVM采用默认的分代模式就是最佳实践。你不需要再像早期版本那样去额外指定分代参数。 - 注意启动参数的变化:如果你之前为ZGC手动添加过
-XX:+ZGenerational或为 Shenandoah 添加过-XX:ShenandoahGCMode=generational来开启分代模式,可以放心地移除这些参数,因为现在不加这些参数才是分代模式,加上了反而会收到参数已过时或不存在的报错。 - 测试验证:在升级JDK版本后,建议在测试环境中充分验证应用的性能和延迟表现。虽然分代模式性能更优,但对于一些极其特殊的、非分代友好的工作负载,也可能需要重新评估
浙公网安备 33010602011771号