ZGC整理

ZGC

更多内容,请已关注公众号:Java晋升

ZGC是一款JDK 11中加入的具有实验性质的低延迟垃圾收集器。在JDK15声明可以用于生产环境,在JDK21开始支持分代,并且在JDK23分代成为默认选项。

各个系统的支持情况如下:

特点:

支持TB量级的堆。目前已支持16TB的内存。 JDK 11支持最大4TB堆内存,JDK13开始支持16TB内存
GC停顿时间非常短。根据官方文档所说,在JDK16以下,最大GC停顿时间在10ms。在JDK16以上,最大GC停顿时间要小于1ms。并且GC停顿时间不会随着堆大小增大而较少。
支持64位的系统,不支持32系统。也不支持指针压缩。
**支持NUMA-Aware内存分配:**在NUMA(非统一内存访问架构)架构下,每个处理器核心有独立管理的本地内存,访问其他核心的内存较慢。ZGC通过优先在请求线程所在处理器的本地内存上分配对象,优化了内存访问效率。
基于Region布局。

基本框架:

看到这张图,大家可能第一时间会想到G1回收器,实际上ZGC确实与G1有相似的地方,比如都是基于Region布局内存的。

ZGC的Region可以具有大、 中、 小三类容量:

小型Region(Small Region) : 容量固定为2MB, 用于放置小于256KB的小对象。
中型Region(Medium Region) : 容量固定为32MB, 用于放置大于等于256KB但小于4MB的对象。
大型Region(Large Region) : 容量不固定, 可以动态变化, 但必须为2MB的整数倍, 用于放置4MB或以上的大对象。 每个大型Region中只会存放一个大对象, 这也预示着虽然名字叫作“大型Region”, 但它的实际容量完全有可能小于中型Region, 最小容量可低至4MB。 大型Region在ZGC的实现中是不会被重分配的, 因为复制一个大对象的代价非常高昂。

NUMA:

在了解NUMA之前,我觉得要先了解一下SMP。

SMP:

SMP全程为Symmetric Multiprocessing – 对称多处理**。所有 CPU 核心通过一条共享总线(Bus)平等地访问一个统一的、集中的内存池。内存访问延迟和带宽对所有 CPU 核心都是统一的(Uniform Memory Access – UMA)。

缺点:

总线的物理带宽(数据传输速率)是固定且有限的。当多个 CPU 核心同时需要访问内存或 I/O 时,它们必须在总线上竞争。总线仲裁器需要决定谁先使用总线,这本身就带来延迟。会导致CPU 核心经常需要空等(Bus Wait State)总线空闲才能进行内存读写。核心越多,等待时间越长,CPU 实际用于计算的时间比例越低。即使核心空闲,也可能因为等总线而无法工作。

因为总线争用和仲裁开销直接增加了每次内存访问所需的时间。所以 SMP 保证了所有 CPU 看到的内存延迟是统一的(UMA),但这个统一的延迟值本身会随着系统规模增大而显著升高

SMP 架构无法通过简单地增加 CPU 核心数量来线性地提升系统整体性能。在核心数较少时,增加核心可能带来接近线性的性能提升。但一旦核心数达到总线带宽的饱和点,再增加核心不仅无法提升性能,反而可能导致性能下降(阿姆达尔定律遇到物理瓶颈)。

可以简单理解为,SMP中,所有的CPU通过一条路去找内存,但是路的宽度是固定的。需要CPU去抢占路的使用权,CPU越多,竞争越激烈,竞争不到的CPU就越需要空等。

于是在SMP基础之上进行了优化,有了NUMA。

NUMA:

NUMA(Non-Uniform Memory Access (非统一内存访问)),在拥有多个 CPU 插槽 (物理处理器) 的服务器上,内存并不是简单地“连在一起”让所有 CPU 平等访问。而是物理内存被划分并物理上靠近特定的 CPU 组(称为 NUMA 节点)CPU 访问本地节点内存快,访问远程节点内存慢(通过 CPU 间互联)。访问是非统一的。

Local Access(本地访问):

CPU访问其所属NUMA节点(Node)直接连接的本地内存(Local Memory)。通常是系统中最快的内存访问方式。

Remote Access(远程访问):

CPU访问其他NUMA节点管理的远程内存(Remote Memory)。延迟显著高于本地访问(可达2倍以上),是NUMA性能瓶颈的主要来源

Interconnect(互联通道):

连接不同NUMA节点的高速通信网络,用于传输跨节点访问的请求与数据。

NUMA解决传统 SMP/UMA 架构在 CPU 核心数量非常多时面临的内存带宽瓶颈和访问延迟问题。

ZGC在分配对象时,会识别当前执行线程所在的NUMA节点(CPU插槽),并‌优先从该节点关联的本地内存分配内存空间‌。提高了内存访问效率,增强扩展性。

颜色指针:

颜色指针是利用现代 64 位系统(如 Linux x86_64)上指针地址的高位通常未全部使用这一情况,在这些未使用的比特位中嵌入 GC 相关的元数据(“颜色”)

每个对象有一个64位指针,这64位被分为:

16位:预留给以后使用;
1位:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问;
1位:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合);
1位:Marked1标识;
1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC;
44位:对象的地址(所以它可以支持2^44=16T内存):

颜色指针的三大优势:

一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉(配合转发表),而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。
颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。
颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

M0 (Marked 0):

作用:标记阶段使用。当 GC 线程标记一个对象为活动对象时,会根据当前标记周期是奇数还是偶数,设置 M0M1 位。
值: 0 或 1。

M1 (Marked 1):

作用: 同上,与 M0 交替使用,用于区分不同的标记周期。一个周期只使用 M0M1 中的一个。
值: 0 或 1。

Remapped :

作用: 表示这个指针是 “重映射” 后的指针。当一个对象被移动(重定位/压缩)后,所有指向它的指针都需要更新到新地址。设置 Remapped 位表示这个指针指向的是对象的最新位置
值: 0 或 1。
0:指针未重映射(可能指向旧位置)。
1:指针已重映射(指向对象当前正确的新位置)。

颜色组合的含义: ZGC 通过检查这三个关键位的组合来判断对象状态和指针状态:

M0/M1 位:表示对象在当前或上一个标记周期中是否被标记为存活(需结合当前 GC 周期)。
Remapped 位:表示指针是否指向对象的最新位置
例如,一个指针的 Remapped=0M0=1 (假设当前周期用 M0),可能表示:对象在上个周期被标记存活,但尚未被重定位,指针指向旧地址。GC 或应用线程(通过读屏障)看到这个组合,就知道可能需要处理(如触发重定位或更新指针)。

颜色指针的虚拟映射

CPU 和操作系统看到的指针就是一个内存地址。如果 ZGC 随意修改指针的高位(颜色位),当应用线程直接使用这个“染色”后的指针去访问内存时,肯定会发生异常的。

为了解决上述问题,ZGC 在初始化时,会向操作系统请求将**同一块物理内存区域映射到多个不同的虚拟地址范围(称为 “视图” View)**上。每个视图对应一种特定的颜色位组合(主要是 M0, M1, Remapped 的组合)。

也就是说,,ZGC 会在 M0、M1、Remapped 视图中为该对象分别申请一个虚拟地址,且三个虚拟地址都映射到同一个物理地址。

这样,对于堆内存中的同一个物理字节,存在多个虚拟地址可以访问它。这些虚拟地址的区别仅在于它们的高位(颜色位)不同,而它们的低 44位(Address 部分)完全相同

例如,物理地址 0x00000000'1234'5678 可能同时映射到:

0x0000'**10*00'1234'5678
0x00002'**8**00'1234'5678
0x00000'**4**00'1234'5678

在G1/CMS等垃圾回收器中,GC的标志位是存储在对象头的markword区域。ZGC使用了对象指针去存储GC标志。

颜色指针 vs 传统对象头 GC 标志

理解颜色指针的优势,对比传统 GC 在对象头存储标志的方式就非常清晰:

特性 传统对象头 GC 标志 (如 G1, CMS) ZGC 颜色指针 颜色指针的优势
元数据位置 存储在对象本身的头部 (mark word 中) 存储在指向对象的指针 解耦: 元数据与对象分离,访问元数据不强制访问对象 (减少缓存污染)。
元数据访问速度 需要先通过引用指针加载对象头才能读取标志 直接从指针值中位操作提取颜色位 (极快) 速度: 访问元数据速度快几个数量级,无内存访问延迟。
对象移动支持 对象移动后,需扫描整个堆/卡表/记忆集更新所有引用 对象移动后,只需设置旧指针的 Remapped 位=0 高效重定位: 更新指针状态只需位操作,无需立即修改所有引用地址。读屏障延迟更新。
并发性 修改对象头标志通常需要 CAS 或锁 (易争用) 修改指针颜色位是 纯算术操作 (加/减固定偏移量) 无锁并发: 修改元数据无竞争,极大提升并发标记/重定位效率。
内存占用 每个对象头都需要空间存储标志位 无额外对象头开销 (用于 GC 元数据) 空间效率: 节省堆内存,尤其对小对象比例高的应用。
屏障开销 写屏障通常更重 (记录引用变化) 读屏障 相对较轻 (检查颜色位,触发少量操作) 屏障优化: 读屏障频率通常低于写屏障,且现代 CPU 优化读屏障更好。
处理跨代引用 需要 Remembered Sets (卡表) 无需 Remembered Sets (得益于染色指针的全局视图) 简化: 消除卡表维护开销和空间占用。

读屏障:

传统 GC (如 G1, CMS) 主要依赖 写屏障 (Write Barrier) 来记录引用变化(用于维护 Remembered Sets 等),这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。

在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。

那么我们该如何理解它呢?

看下面的代码,第一行代码我们尝试读取堆中的一个对象引用person.name并赋给引用n(name也是一个对象时才会加上读屏障)。

如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针“修正”到原本的字段里。

这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW。

有点类似于AOP。

简单说明一下流程就是:

当从堆中加载对象引用时,由即时编译器(JIT)在关键位置注入的少量代码。
检查所加载对象引用的标记(颜色指针)是否异常,若异常,则触发处理逻辑并修正该引用。

使用读屏障的目的就是确保应用线程看到的引用是“正确”的(指向活动对象的最新位置),并协助 GC 线程完成并发任务,避免长时间的全局 Stop-The-World (STW)。

ZGC流程

下图为整体的GC流程:

在整个GC流程中,会发生3次STW,就是上图中向下箭头的部分。

初始状态:

ZGC将内存划分为不同大小的Region。

图片[1] - ZGC整理 - 宋马

初始标记(STW):

在初始标记阶段,会STW,ZGC直接标记所有从GC ROOTS对象直接关联的对象。

并发标记:

并发标记阶段,这个过程不会发生STW,ZGC会遍历对象图做可达性分析,并进一步标记可以抵达的对象。

最终标记(STW):

最终标记阶段:STW,这个阶段会再次标记在并发标记阶段新产生的对象。这里没有新对象产生,所以没有发生变化。

并发转移准备:

这个阶段主要是为真正转移对象前做一些准备工作,会根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。后续还会为重分配集中维护一个转发表(Forwarding Tables),记录从旧对象到新对象转向关系。

初始重分配(STW):

初始重分配阶段,会先迁移gc roots 直接关联的对象到新的分区,由于只迁移直接引用的对象,所以停顿时间非常短。对象1、2直接引用,但是对饮区域没有未标记对象,没有选择对这个区域进行回收。

并发重分配:

并发重分配阶段会移动存活对象到新Region,并且释放原来的Region区域。可以看到对象2引用的对象5还是重分配前的状态,对象4引用对象5,对象5引用对象8同样。转发表中记录了旧对象到新对象的映射。

图片[2] - ZGC整理 - 宋马

此时,如果有用户线程通过对象4访问对象5,也就是用户线程访问了位于重分配集中的对象,这次访问会被读屏障锁截获,然后根据Region上的转发表记录将访问转发到新复制的对象上,同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。

从图中看,对象4值引用对象5的指针,不再指向就得Region,而是指向对象5复制到的新Region区域。

并发重映射:

重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后, 原来记录新旧对象关系的转发表就可以释放掉了。

下图中展示的是第二次GC的时候,在遍历对象图的时候,会修正上一次的指向重分配集中就对象的引用。

图片[3] - ZGC整理 - 宋马

在第二次GC的并发转移准备阶段,所有对象的引用都被修正了,会将第一次GC的时候维护的转发表释放。

ZGC参数:

-XX:+UseZGC -Xmx<size> -Xlog:gc*
# 指定堆空间大小 以及GC日志(详细)

参考文章:

[The Design of ZGC]:

https://cr.openjdk.org/~pliden/slides/ZGC-PLMeetup-2019.pdf

[Open JDK wiki]:

https://wiki.openjdk.org/display/zgc/Main#Main-JDK21

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容