前言
之前介绍了ZGC,埋了个坑,说是会写分代ZGC,但是因为所在的业务组比较忙,且分代ZGC的相关资料较少,所以就搁置了一段时间,现在有时间了,就继续完成。因为网上的ZGC的资料很少介绍到分代ZGC,一般都是介绍完ZGC之后就没了,所以我这篇文章主要也是参考官方的文档和YouTube上的视频,结合自己的理解,尽可能通俗的描述清楚分代ZGC到底做了什么优化。
阅读此篇文章前建议先查看上篇:JDK新特性】聊聊ZGC的核心原理
本文将围绕着分代ZGC所做的几个优化进行分析:
- 没有使用多映射内存
- 屏障优化
- 双缓冲记忆集(Remember set)
- 无需额外堆内存的重定位
- 密集堆区域
- 大对象
分代ZGC
介绍
官方文档:openjdk.org/jeps/439
为什么要做分代ZGC
学过JVM的jy都知道,堆中的大部分对象基本上生命周期都很短,朝生暮死。而如果不分代,那么就会导致整堆扫描,性能较差。
为什么一开始不就直接做分代ZGC?
这个问题,我在油管上的一个视频看到了答案:www.youtube.com/watch?v=LXW...
官方的这个视频介绍了几种垃圾回收器,也解释了为什么要做分代zgc, 视频中介绍了,一开始为了考虑性能,重点着重于解决延迟问题

一、分代ZGC不再使用多重映射
(ps:这一部分我看很多文章都引用到了这个官方视频的截图,如果你感兴趣,可以直接去看这个视频:www.youtube.com/watch?v=YyX...)
不分代的ZGC使用Java堆的3个视图("marked0","marked1","remapped"),即3种不同"颜色"的堆指针和同一个堆的3个虚拟内存映射。
因此,操作系统可能会报告 3 倍大的内存使用量。例如,对于 512 MB 的堆,报告的已提交内存可能高达 1.5 GB,不包括堆以外的内存。
多重映射会影响报告的使用内存,但物理上堆仍将使用 512 MB 的 RAM。这有时会导致一个有趣的效果,即进程的 RSS 看起来大于物理 RAM 的数量。
与不分代ZGC的4个颜色位相比,分代ZGC需要12个颜色位来标识不同的GC阶段,这显然不能用多重映射内存来实现了。(说明!! 很多文章提到分代ZGC并不是完全不使用多重映射,而是在老年代依旧保持了多重映射的机制。这种说法是错误的!可以参考官方的资料,官方地址:openjdk.org/jeps/439
读过我上篇的朋友一定知道,多重映射主要是为了读屏障在处理的时候,能够知道新的地址在哪里,而做的一个优化。但是在分代ZGC中,分代ZGC通过巧妙地代码逻辑来优化了这部分,而不是多重映射(可以看下文,或者下文中的源码链接)。
而分代ZGC最终的布局是这样的(图就是视频链接里面的截图)
Generational ZGC 采用了新的设计:
-
栈上指针是无色指针(colorless pointer)
- JVM 栈和 CPU 寄存器中不存元数据。
- 加载/存储屏障负责 彩色指针 ↔ 无色指针 的转换。
- 因此彩色指针从来不会出现在硬件栈/寄存器中 → 彩色指针布局可以更加灵活。
-
彩色指针低位布局
-
元数据位放在低位,对象地址放在高位。
-
通过位移(shift)指令就可以:
- 检查指针是否需要 GC 处理
- 去掉元数据位
-
这在 x64 架构上只需要 一条指令,效率很高。
-
二、屏障优化
在介绍分代zgc的屏障优化之前,我们先回顾一下什么是屏障。
什么是屏障
垃圾回收器(GC)为了在程序运行时不打断用户逻辑,会在 对象访问(load) 和 对象修改(store) 时,插入一些"小检查逻辑",叫做 barriers(屏障) 。
分代ZGC中屏障主要做了这几件事儿:
加载屏障(读屏障):
- 从染色指针中移除元数据位。
- 更新GC重定位过程中的对象的过时指针。
存储屏障(写屏障):
- 添加元数据位用来创建染色指针
- 维护remembered set,用来追踪老年代对象指向年轻代的指针。
- 标记对象为存活
为什么要优化?
因为对象的每一次访问和写入都需要触发屏障,频率极高,如果屏障很慢就会导致整个程序的吞吐量被拖垮。
优化了哪些内容
用于优化屏障的一些技术包括:
- 快速路径和慢速路径: Fast paths and slow paths
- 最小化加载屏障责任:Minimizing load barrier responsibilities
- 已记录集屏障:Remembered-set barriers
- SATB 标记屏障:SATB marking barriers
- 融合存储屏障检查:Fused store barrier checks
- 存储屏障缓冲区:Store barrier buffers
- 屏障补丁:Barrier patching
OK,接下来我们一个一个开始分析。
1. 快速路径和慢路径
官方文档的描述:
ZGC 将屏障分为两部分。快速路径检查在应用程序使用引用对象之前是否必须执行额外的 GC 工作。慢速路径执行该额外工作。所有对象访问都会运行快速路径检查。正如其名称所示,它需要快速,因此这段代码直接插入到即时编译的应用程序代码中。慢速路径仅占用一小部分时间。当慢速路径被触发时,访问的对象指针颜色会改变,以便后续对同一指针的访问在一段时间内不会再次触发慢速路径。由于这个原因,慢速路径高度优化的重要性相对较低。为了可维护性,它们在 JVM 中实现为 C++函数。
我们将结合源码进行分析:
链接中的这段代码是 ZGC 的屏障实现核心之一 ,具体是 ZBarrier 的 快速路径与慢路径逻辑总控 。
我们来系统分析 快速路径的主逻辑 。
cpp
template <typename ZBarrierSlowPath>
inline zaddress ZBarrier::barrier(ZBarrierFastPath fast_path,
ZBarrierSlowPath slow_path,
ZBarrierColor color,
volatile zpointer* p,
zpointer o,
bool allow_null) {
z_verify_safepoints_are_blocked();
// Fast path
if (fast_path(o)) {
return ZPointer::uncolor(o);
}
// Make load good
const zaddress load_good_addr = make_load_good(o);
// Slow path
const zaddress good_addr = slow_path(load_good_addr);
// Self heal
if (p != nullptr) {
// Color
const zpointer good_ptr = color(good_addr, o);
assert(!is_null(good_ptr), "Always block raw null");
self_heal(fast_path, p, o, good_ptr, allow_null);
}
return good_addr;
}
快速路径是:
cpp
if (fast_path(o)) {
return ZPointer::uncolor(o);
}
即 检查对象指针 o 是否已经是load good 或 mark good 或 store good 等状态。
如果是,就直接去掉颜色位(ZPointer::uncolor(o)),返回真实的对象地址,不触发任何 barrier 操作。
触发慢路径是 当 fast_path(o) 为 false 时:
表示指针中包含无效或过期的标志位(例如指向被转发的对象、被回收的 page、或 Remap Bad 状态)。 于是进入:
cpp
const zaddress load_good_addr = make_load_good(o);
这里调用:
cpp
inline zaddress ZBarrier::make_load_good(zpointer o) {
if (is_null_any(o)) {
return zaddress::null;
}
if (ZPointer::is_load_good_or_null(o)) {
return ZPointer::uncolor(o);
}
return relocate_or_remap(ZPointer::uncolor_unsafe(o), remap_generation(o));
}
也就是说,慢路径主要做两件事:
- 通过 remap 或 relocate 修复指针;
- 更新颜色位(coloring)与元数据位。
最后调用:
cpp
self_heal(fast_path, p, o, good_ptr, allow_null);
执行自愈(CAS 原子替换),将旧的坏指针换成新修复的指针。
总结就是:快速路径只做检查,如果没问题直接返回裸地址,跳过一切屏障操作,从而实现近似零开销的访问性能,而慢路径才是真正的GC。
2. 缩小读加载屏障(读屏障)的责任
在非分代ZGC中,加载屏障要负责:
- 更新 GC 重定位的对象的过时指针
- 将加载的对象标记为存活------应用程序正在加载该对象,因此被视为存活。
但是因为分代ZGC,需要追踪新生代和老年代,并且在有颜色指针和无颜色指针的切换,所以为了减少复杂性,优化快速路径,标记的责任被转移到了存储屏障上面。
3.记录集的屏障 Remembered-set barriers
当分代ZGC收集年轻代的时候,只会访问年轻代的对象,但是如果老年代中的对象指向了年轻代,那我怎么知道哪些对象是被老年代的对象访问的呢?这个时候就需要Remembered-set,Remembered-set维护了老年代对于年轻代的引用。但这样就会有一个问题,收集年轻代的时候会移动对象,但是对象的指针不会立刻更新!
存储屏障是在程序给对象字段赋值时自动触发的逻辑,它的作用就是:每当你在老年代对象中写入一个引用字段时,把这个字段地址记到记忆集中。
但是屏障是非常"热"的路径(几乎所有赋值操作都会触发),如果每次写字段都要进入慢路径、往记忆集加一条记录,那开销会很大。
ZGC 的优化就是:每个字段,在一个年轻代 GC 周期中,只被慢路径处理一次。
也就是说 第一次写入这个字段时:
- 快速路径检查字段的"颜色"(元数据);
- 发现它从上次年轻代 GC 以来没被写过;
- 于是进入慢路径;
- 把字段地址加入记忆集;
- 给字段打上"已处理"的颜色。
后续再写入这个字段:
- 快速路径检测到"颜色"表明它已经处理过;
- 于是直接跳过,不再加记忆集,性能极高。
4.SATB 标记屏障 SATB marking barriers
由于ZGC是并发GC的,那么就有一个问题:在 GC 标记过程中,应用还在不停修改对象引用。
那我怎么知道哪些对象在「标记开始时」是活的呢?
因为如果 GC 一边在标记,一边应用把某个引用删掉(断开了),GC 就可能漏标(遗漏存活对象) 。
SATB(Snapshot-At-The-Beginning)就是一种解决办法。
在标记阶段开始的那一刻,GC 认为堆中所有从根对象可达的对象都是活的。 不管程序之后怎么改引用,我都要把当时那一刻的'可达图'完整标记完。GC 想维护的是那一刻的"快照视图" (snapshot)
但是这就有一个问题,假设在标记期间,程序可能会这样做:
java
a.field = null;
这就意味着 "a.field" 原来指向的对象失去了一个引用。
如果此时 GC 还没来得及扫描 a.field 指向的对象,那它就会被漏掉。
虽然说a.field指向的是null了,但是原先的对象我们依旧要认为它是活的,这就是GC想要维护快照的一致性。
为了维护这个一致性,写屏障(store barrier)会在"引用被覆盖前"告诉 GC:"这个字段原来指向的对象 X 要被我改掉了,请你把 X 标记为活的!"
但这里也有一个关键的优化点:"Store barriers need only report a to-be-overwritten field value the first time that the field is stored to within a marking cycle."
也就是说:
- 在同一个标记周期内,如果你第一次修改字段
a.field,写屏障会报告"旧值"。 - 但之后如果你再改这个字段(第二次、第三次),就不用再报告了。
原因是:
- SATB 的"快照"已经保证:被第一次覆盖掉的对象要么被标记过,要么已经在快照中;
- 后续修改只是替换那些 GC 已经知道(可达) 的值;
- 因此不需要重复触发屏障逻辑 → 节省开销。
5. 融合存储屏障检查 Fused store barrier checks
这里就直接看原文了
原文是:
存储屏障的记住集维护和标记功能之间有许多相似之处。两者都使用带颜色的指针快速路径检查,并具有各自的一次性执行属性。我们不是为每个条件设置单独的快速路径检查,而是将它们融合为一个综合的快速路径检查。如果这两个属性中的任何一个失败,就会采用慢路径,并执行所需的 GC 工作。
其实就是说,前面介绍到的存储集的优化和STAB的标记优化有很多类似的地方。比如
- 都需要快速路径检查
- 都具有一次性执行属性(只执行一次,后面就不再重复)
所以就直接将两个快速路径融合为了一个快速路径的检查,如果两个属性中的任何一个失败,就会采用慢路径,并且执行GC。
6. 存储屏障缓冲区 Store barrier buffers
这里也直接看原文吧,很好理解
将屏障分为快速路径和慢路径,并使用指针着色,减少了调用 C++慢路径函数的次数。分代 ZGC 通过在快速路径和慢路径之间放置一个 JIT 编译的中等路径,进一步降低了开销。中等路径将待覆盖的值和对象字段的地址存储在存储屏障缓冲区中,并返回到编译的应用程序代码,而不会采用昂贵的慢路径。只有当存储屏障缓冲区满时,才会采用慢路径。这摊销了从编译的应用程序代码切换到 C++慢路径代码的部分开销。
我个人理解,其实就是慢路径过于昂贵,所以创建了一个缓冲区,当前快速路径检查需要GC时,会放到缓冲区中,缓冲区满了,才会丢给慢路径进行GC。
7. ZGC 的屏障补丁(Barrier Patching)
无论是加载屏障还是存储屏障,执行时都需要检查某些GC的状态变量(比如当前GC阶段,年轻代/老年代状态)这些状态变量通常存储在
- 全局变量
- 线程局部变量
但是读取这些变量在不同 CPU 架构上的性能差异较大,可能成为瓶颈。
屏障补丁优化思路:
- 将这些变量直接编码到屏障的机器指令里面(叫做立即数),而不是引用,减少加载引用的内存消耗
- 当GC切换的时候(如进入年轻代标记阶段),直接修改这个立即数,后面直接使用这个立即数,而不是使用这些变量。
总结一下,由于分代ZGC的复杂性,ZGC做了很多的屏障上的优化,来提高分代ZGC的吞吐量。接下来我们介绍第二个分代ZGC优化点。
三、双缓冲的已记录集 Double-buffered remembered sets 双缓冲的已记录集
许多垃圾回收器使用一种称为卡表标记的 remembered-set 技术。当应用程序线程写入对象字段时,它也会写入(即弄脏)一个称为卡表的大字节数组中的一个字节。通常,表中的一字节对应于堆中 512 字节的地址范围。为了找到所有从旧代到年轻代的对象指针,垃圾回收器必须定位并访问卡表中脏字节所对应的地址范围内的所有对象字段。
但是ZGC的认为这样的标记,"粒度" 太大,也就是将一个region认为是脏的,但是GC的时候,还是要扫描所有的"脏卡",然后把里面的年轻代的字段找出来。
ZGC做的优化就是精确到每个字段(每个对象中的字段可能会持有跨代引用)而不是region。
分代ZGC使用的是bitmap,每个比特位代表一个潜在的对象地址。而这样的位图,分代ZGC做了两个。
为什么需要两个bitmap
两个bitmap中,一个用于给应用线程不断地写入,而另一个作为前一次记录的可读副本。当年轻代开始收集的时候,会交换这两个bitmap,将上一次的可读副本用于写入,将上一次写入的作为GC新的可读的remembered set,这种交换是原子性的。
这样做的好处是,应用线程无需等待位图被清除,GC处理一个位图时,另一个位图可并行的被应用线程填充。而且分为两个还减少了读和写之间的并发问题。比如G1,在标记卡片的时候,需要使用内存栅栏,这导致存储屏障的性能更差了。
四、重定位,但是不需要额外的堆内存 Relocations without additional heap memory
其实这个优化主要是因为传统的GC是"存活对象会在单次遍历中被发现和重定位"。传统的GC由于一边遍历一边重定位,所以需要估算新的目标区域的大小,而如果估算失败会导致很严重的问题。
- 原地固定对象,导致内存碎片。
- 触发full gc
为了避免这种问题,所以估算的时候,一般会往大了估,这就是额外的堆内存。
而分代ZGC是在搬迁之前就完整的标记了存活的对象,不需要偏大估计,也就不需要额外的堆内存浪费。
五、 密集堆区域
假设一个ZGC中的一个Region的存活对象非常多的话,那么搬迁不如"原地老化"。所以ZGC通过分析年轻代的里面的密度来确定哪些适合搬迁。
但这里的"原地老化"并不是说,整个region就不回收了,同样的,即使这个region中大多数都是存活对象,但是依旧会标记哪些是死亡对象,但不会清除。下次年轻代再分析时,如果这块区域死的对象比较多了,就是认为"值得搬"了,那时,会彻底释放死对象占的空间。
即使升到了老年代,如果region太满了(绝大多数都是存活对象),则忽略哪些已经标记死亡的对象,但是下一次老年代GC的时候会被正式清理。也就是晋升后的 Region 会被老年代的标记---清理流程接管。
六、大对象处理
在有些垃圾回收期中,会直接将大对象放到老年代。但是因为分代ZGC的灵活性,我们允许大对象在年轻代分配,并且在不移动的情况下,进行老化(年龄+1),而且如果这些大对象很快就死了,那么就可以在年轻代中快速销毁,即使要升到老年代也可以原地升级:ZGC 的设计支持 region aging without relocation,也就是: 区域(region)可以"老化"(从 young → old)而不需要移动对象。。
七、为什么不维护年轻代到老年代的引用
我们知道remembered-set是老年代对于年轻代的引用,目的是为了防止每次young-gc的时候去扫描老年代,那为什么不反过来维护一个呢?
因为年轻代的回收频率比老年代高的多,所以这样的维护成本很高,所以当要回收老年代时,会在老年代标记阶段前,来一次年轻代收集,这次收集和正常的年轻代收集一样执行,但是这样就可以扫描出来,哪些引用指向了老年代。
ok,结束,其实本文还有很多内容没有详细的讲,比如垃圾回收算法,一些垃圾回收器的比较,希望读者能够有基本的JVM常识再来阅读此篇,最后希望正在读的你,能够有所收获。