1. 从G1的痛点说起:ZGC到底想解决什么问题
很多人第一次接触ZGC,都是因为在压测或者线上运维的时候发现G1或者CMS扛不住了。堆开到几十GB甚至上百GB,业务要求GC停顿控制在10ms以内,G1的Mixed GC一触发,动不动就是几百毫秒的暂停,甚至秒级。这时候你去看监控,GC暂停像一根根尖刺扎在TP99曲线上,用户的超时告警刷屏,值班工程师只能紧急扩容。这其实是ZGC诞生的最直接动机:堆越大,传统垃圾回收器的停顿越不可控,而现代互联网服务的内存需求偏偏在持续膨胀。
要理解ZGC为什么和之前的回收器不一样,得先明确一个概念:传统的CMS、G1,包括更早的Parallel Scavenge,它们的核心思路仍然是"把GC的大部分工作放到GC线程里做,尽量缩短Stop-The-World(STW)的时长"。G1虽然是并发的,但它的并发只覆盖了标记阶段,转移(Relocation)阶段依然需要STW。而且G1为了控制停顿,引入了一个"停顿预测模型",通过增量方式每次只回收一部分Region,但这只是缓解,不是根治。堆越大,需要转移的对象越多,STW时间随堆大小线性增长的趋势并没有被打破。
ZGC的设计目标非常激进:不管堆有多大,GC停顿时间都要控制在10ms以内。这个目标意味着,几乎所有的GC工作都必须并发完成,尤其是对象转移——这是之前所有回收器都选择STW来做的事情。为什么之前不敢并发转移?因为对象一旦被移动,业务线程手里还握着指向旧地址的引用,现实世界可没有"搬家后自动转寄"这种机制,想让业务线程感知到对象新地址,就必须让所有线程停下来,把它们的栈和寄存器里的引用统一修正。
ZGC打破这个僵局的武器,就是染色指针(Colored Pointer)和读屏障(Load Barrier)。这对组合不是简单的优化技巧,而是从根本上改变了垃圾回收的设计哲学:不再在GC时扫描和修改引用,而是让每个引用在被读取的时候,自己"会说话",自己告诉GC它的对象处于什么状态。这篇文章我就把这些机制彻底拆开,从位级原理讲到完整的GC周期,最后聊聊我这几年在实际使用中验证过的一些观测手段和容易踩的坑。
如果你正准备在项目里引入ZGC,或者想搞清楚JVM底层回收器到底在做什么,这篇文章应该能给你一个比较完整的视角。我会尽量用直接的表达,不说废话,把那些文档里看不到的细节摊开来聊。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 染色指针:在指针里"藏"GC状态的艺术
2.1 64位指针的地址空间划分
要理解染色指针,先得从内存地址本身说起。现代64位系统里,一个指针是64位,但CPU实际用来寻址的位数并没有64位那么多。以x86-64为例,目前常用的四级页表方案下,高16位是符号扩展位,有效地址只有低48位,也就是说理论虚拟地址空间是256TB。后来出了五级页表,能把有效地址撑到57位,但对绝大多数应用来说,48位已经远远用不完了。
ZGC的逻辑相当直接:既然地址位用不完,那就把高位的几个bit借出来,给GC当状态标记用。这就像门牌号本来是纯数字,现在规定最高位可以代表房子的状态——"这家人搬走了""这家人刚搬进来""这房子正在装修中"。别人看到门牌号,不用进家门,光看数字就知道状态。
具体来说,ZGC在最初的设计里,用指针的高4位作为颜色位,所以一个指针长这样:
code复制63 62 61 60 59 ... 45 44 ... 22 21 ... 0
[ 4位颜色区 ][ 最终可寻址位 ][ 偏移量 ][ 对象偏移 ]
这里的核心设计是4MB对齐。ZGC要求对象地址按4MB对齐,4MB等于2的22次方,所以指针的低22位天然是0,不需要存储。这样实际能用的对象寻址位就是42位(64减去4位颜色、再减去22位对齐偏移的冗余,具体是45位到22位这一段),对应最大支持4TB的堆。这也是为什么JDK 15之前ZGC的堆大小上限是4TB。到了JDK 15之后,ZGC做了改进,不再需要4MB对齐,而是用更精细的方式划分地址空间,把堆上限提升到了16TB。
2.2 四种颜色:Marked0、Marked1、Remapped、Finalizable
说到颜色,很多人以为ZGC的"染色"是个比喻,但实际上它非常物理——就是指针那4个bit的值。ZGC定义了四种状态:
- Marked0:表示对象在本次标记周期中被标记为存活,标记位为0。
- Marked1:表示对象在本次标记周期中被标记为存活,标记位为1。
- Remapped:表示对象已经完成了重映射,指针指向的是最新的对象地址。
- Finalizable:表示对象只通过Finalizer可达,只能被Finalizer线程访问,处理需要特殊对待。
你可能已经发现了,Marked0和Marked1是两种不同的标记色。为什么要两种?这是为了支持并发标记的"翻转"机制。ZGC的并发标记每一轮都会换一个颜色。比如这一轮用Marked0标记存活对象,下一轮就用Marked1。这样有一个巨大的好处:GC线程在并发标记的时候,业务线程不用停下来。如果一个业务线程正在读取一个对象,而GC线程正准备修改它的标记,那如果只有一个标记位,就会产生竞争。有了两个标记位,通过"I'm marking with color A"这样的协议,读屏障可以判断出当前对象是否被当前这轮GC正确标记过,如果颜色不对,就主动补一次标记。
2.3 为什么是4MB对齐——位运算的巧妙设计
一般人可能觉得,对齐只是让地址看起来规整而已。但对ZGC来说,4MB对齐是染色指针能高效工作的前提。为什么这么说?
因为有了4MB对齐,一个指针的低22位一定是0。这意味着ZGC在把指针的染色位和地址位组合、拆解的时候,只需要简单的位运算,不需要复杂的掩码比较。比如读屏障要检查某个引用是否处于Remapped状态,它只需要把指针和一个掩码做一次与运算(AND),看结果是否匹配,三个CPU周期就搞定了。
我拿个例子来说明。假设一个对象的旧地址是 0x100000000,新地址是 0x200000000,都按4MB对齐。当对象被搬走后,堆里残留的旧指针低位22位全是0,ZGC可以非常快速地把原来指向旧地址的指针,通过位操作转换成"带Remapped颜色的新指针":
code复制新指针 = 新地址 | 颜色位
这个过程连乘法都不用,没有除法也没有取模,全是移位和或运算,所以在GC日志里你能看到ZGC的并发转移速度非常快,原因之一就是这些位运算极其廉价。
到了JDK 15之后,虽然把4MB对齐取消了,但它的核心逻辑没有变:仍然是从虚拟地址空间中划分出一块区域作为"保留区",用保留区的不同偏移来编码不同的颜色状态。只是映射的方式更灵活了,堆上限从4TB升到了16TB。
2.4 多重映射:让不同颜色指向同一块物理内存
讲到这里,有一个问题很自然会被问到:指针上带了颜色位,那这个指针还是合法的地址吗?如果业务线程拿着一个带Marked0颜色的指针去访问对象,CPU把它当成普通地址去寻址,会不会直接段错误?
这个问题的答案,就是ZGC的另一个核心设计:多重映射(Multi-Mapping)。
ZGC在JVM启动时,会用mmap为不同的颜色状态分别申请一块虚拟地址空间,然后把它们映射到同一块物理内存。什么意思呢?就是物理内存里同一个对象,在虚拟地址空间里有多个"入口",每个入口对应一个颜色。业务线程手里的指针即使带着Marked0的颜色位,它依然是一个合法的虚拟地址——只要ZGC为Marked0空间做过mmap映射,这个地址就能正常访问到那块物理内存。
这样做的好处极其明显:读屏障在绝大多数情况下根本不需要修正指针。它只需要检查指针的颜色是否处于"正确"状态,如果颜色正确,直接按这个地址访问就行;如果颜色不对,才需要走慢路径,查找转发表拿到对象真正的新地址。如果ZGC不用多重映射,而是像传统回收器那样,把对象搬走之后在转发记录表里记一笔,那读屏障每次都必须查表,性能开销会大得多。
所以多重映射本质上是一种"空间换时间"的思路:虚拟地址空间对操作系统来说几乎是无限的,ZGC用它来消解指针调整的开销。这也是为什么ZGC的读屏障能做到非常快——快路径通常只有两条指令:一次位运算检查,一次条件跳转。
3. 读屏障:业务线程与GC线程的"交通协管"
3.1 读屏障是什么、挂在哪里
读屏障(Load Barrier)从名字上看像是CPU层面的东西,但ZGC的读屏障实际上是在JVM编译器(C2 JIT)生成的机器码里插入的一段检查代码。它的插入点是每次从堆中加载一个对象引用(reference)的时候。
注意这里的范围限定:不是所有内存读取都插屏障,只有加载"对象引用"才需要。加载int、long、float这些基本类型不会触发读屏障,因为它们不可能是对象的引用,GC对象是否移动跟它们无关。
你可以把读屏障理解为业务线程每次拿引用时的"安检口"。每次业务线程准备使用一个对象引用,JIT编译器就会在它前面插入这么一段逻辑:
code复制if (引用 & 掩码 != 期望颜色) {
// 慢路径:查转发表,找到新地址,修复指针
引用 = 转发表查找(引用);
}
如果引用带的颜色符合预期,就直接放行,这个开销极小。如果颜色不对,说明这个对象可能已经被GC线程搬走或者正在被搬走,那就得走慢路径处理。
3.2 GC线程改了对象位置,业务线程怎么找到新家
这是理解读屏障的关键场景。假设一个对象A,当前地址是旧地址X,ZGC的并发转移线程决定把A搬到新地址Y,搬家的过程不是瞬时的。在搬家过程中,业务线程可能正在使用指向X的引用,此时会发生什么?
ZGC的处理流程是这样:
- GC转移线程把A的内容复制到新地址Y,但这个复制动作发生的时候,A在旧地址X的内存还保留着。
- GC转移线程在全局的转发表(Forwarding Table)里记录:
X -> Y。 - GC转移线程更新对象A在X位置的"转发指针",把X位置的对象头的一部分改写成指向Y的指针。
- 业务线程读取引用,发现指针的颜色不对(不是Remapped),触发读屏障慢路径。
- 读屏障根据当前的地址X,查询转发表,找到新地址Y。
- 读屏障把业务线程本地的这个引用修正为带Remapped颜色的Y。
- 业务线程后续访问Y。
这里有个非常关键的细节:在ZGC的并发转移过程中,X处的对象内容在"转发指针"写入之前是完整可读的。这保证了业务线程在转移尚未完成的瞬间,直接访问X也不会读到半修改的状态。ZGC利用了一个Trap机制:转移线程在复制完成后,会把X处的对象头改成一个特殊的标记,这个标记会让后续试图访问X的线程进入读屏障的慢路径,而慢路径会查到转发表,得到新地址Y。
3.3 自愈机制:一次修正,之后零成本
前面说的第6步,读屏障修正了业务线程本地的引用,但堆里可能还是旧地址X。如果下一次又有别的线程读到这个X,不是又得查一次转发表吗?ZGC的做法是把修正动作写回堆里,也就是自愈(Self-Healing)。
当读屏障发现一个引用是旧地址X时,它不只是返回新地址Y,它还会把堆里那个引用字段也更新成Y。这样,同一个对象引用被第二次读取时,颜色已经是正确的了,读屏障直接走快路径,连转发表都不查。
这个设计的精妙之处在于:GC线程不需要专门去扫描和修改所有线程的栈和寄存器里的引用,也不需要全堆扫描来修正字段引用。它只需要在业务线程"读"到旧引用的时候顺便修一下就行。随着程序的运行,旧引用会被业务线程自己一点点"治愈",转换成新引用。这就是为什么ZGC能实现并发转移的底层原因——它把"修正引用"这个工作从GC线程转移到了业务线程的读路径上,而且是化整为零、随用随修。
代价是什么呢?一次慢路径的读屏障查询,大约会消耗几十到上百个CPU周期,相比快路径的两条指令,慢很多。但因为自愈机制,同一个引用只会慢一次,后续都是快路径。所以从整体上看,ZGC的读屏障开销是收敛的,不会因为GC周期持续变慢。
3.4 读屏障的开销真相
关于读屏障,业内一直有争议,最典型的声音是"ZGC的吞吐量比G1低,因为每个引用读取都有额外开销"。这句话对,但只说对了一半。
ZGC的读屏障确实会给业务线程增加额外负载。根据我在生产环境的实测,开启ZGC后,对于引用密集的Java应用(比如大量对象遍历的批处理任务),吞吐量通常比G1低5%到15%。但是对于引用访问不密集、更偏向CPU计算的应用,这个差距可能缩小到1%到3%。
ZGC的设计哲学就是用一点吞吐损失换取极低的延迟。如果你是在做高并发、低延迟的在线服务,对TP99极其敏感,这点吞吐损失完全值得;但如果你跑的是离线批处理任务,吞吐量是唯一指标,那Parallel GC甚至比G1更合适。选型的关键从来不是谁"更强",而是谁更匹配你的场景。
另外还有一个容易忽略的点:读屏障在JIT编译后的实际开销比很多人想象得小。现代CPU的分支预测非常强,绝大多数引用颜色都是对的,分支预测几乎不会miss。所以快路径的开销往往只有几个周期。真正吃性能的是慢路径,而当GC频率低、转移对象少时,慢路径占比很小。
4. 一个完整的ZGC周期:四阶段是怎么串起来的
4.1 并发标记:颜色如何被"翻"来翻去
了解了染色指针和读屏障之后,我们把它们放到一个完整的GC周期里看它们怎么协同工作。ZGC的一个GC周期包含四个阶段:并发标记(Concurrent Mark)、并发转移准备(Concurrent Prepare for Relocation)、并发转移(Concurrent Relocation)、并发重映射(Concurrent Remap)。
先看并发标记。这个阶段的目标就是找出所有存活对象。ZGC不像G1那样用SATB快照(Snapshot-At-The-Beginning)来保证并发标记的一致性,它的机制更简单:靠Marked0和Marked1两个标记位来回翻转。
GC周期开始时,ZGC会把当前使用的标记色设为Marked0(或Marked1,看上次用的哪个)。然后GC线程从GC Roots出发,遍历对象图,把所有访问到的存活对象的指针颜色"翻"成当前标记色。这里有个细节:GC线程在标记一个对象时,会先看看它的指针颜色是不是当前标记色,如果是,说明已经标记过了,跳过;如果不是,就改成当前标记色。
与此同时,业务线程也在运行,它可能会创建新对象,也可能会修改引用。如果业务线程在GC标记过程中,把一个指向"未标记对象"的引用写入堆里,会发生什么?JIT编译器生成的写屏障(注意这里是写屏障,不是读屏障)会在写入时检查这个引用,如果它的颜色还不是当前标记色,就立刻把它修正成当前标记色。这样,从标记开始到标记结束这段时间内产生的所有新引用,都带上了当前标记色,不会漏标。
标记阶段的结束,会有一个非常短暂的STW,用于处理那些极少数在并发过程中没被处理的根引用,以及终止标记。ZGC的STW时间跟堆大小没有线性关系,通常只有几毫秒甚至更短。
4.2 并发转移准备:找到"值得搬"的对象
标记结束之后,ZGC已经知道哪些Region里的对象存活率低。接下来要决定:哪些Region要被清理?这就是并发转移准备阶段。
ZGC把堆分成很多个小区域(Region),每个区域的大小一般是2MB。在转移准备阶段,GC线程会统计每个Region的存活对象比例。如果一个Region里大部分对象都是垃圾,那这个Region就值得被整体回收——把里面少量存活对象搬到别处,然后整块Region就可以释放。
这一步为什么必须单独做,而且最好是并发?因为决定"哪些Region要搬"这件事需要遍历Region的存活率数据,如果把这块逻辑也放到STW里,堆越大Region越多,停顿时间就上去了。ZGC在这一步没有STW,所有决策都是基于标记阶段收集的信息异步完成的。
这里还有一个很有意思的设计:ZGC会为每个要转移的Region生成一个转发表(Forwarding Table),记录从旧地址到新地址的映射。这个转发表不是全局一张大表,而是按Region粒度分桶的,查起来非常快。转发表的记录在转移完成后也不会立刻删除,而是保留到重映射阶段结束,确保所有旧引用都能被找到。
4.3 并发转移:搬家的同时业务线程还在用
并发转移是ZGC最"激进"的地方。在这个阶段,GC线程会把要清理的Region里的存活对象复制到空闲Region里。但这块真正刺激的地方在于:复制对象的同时,业务线程可能正在访问这些对象。
想象一个对象A在旧地址X,GC线程正在把它复制到新地址Y。复制到一半的时候,业务线程读了A的一个字段,它拿到的是旧地址X,所以读到了X处还没被覆盖的内容。如果业务线程要写A的字段呢?这在ZGC里也有处理:在并发转移过程中,如果业务线程正在访问一个正在被转移的对象,对这个对象的写入不会被直接写到旧地址X,ZGC会引导写操作到新地址Y。这是通过读写屏障共同完成的。
最终,当GC线程完成复制后,会在X处写入一个"转发指针",指向Y。这个转发指针就像一个路标:从这一刻开始,任何拿着X地址来访问的线程,读屏障会立刻发现这个引用已经"过时了",然后沿着转发指针找到Y,访问新地址,并且顺手把堆里的旧引用修正成Y。
所以你看,复制过程中业务线程的访问并没有被阻塞,只是可能会短暂地访问到旧地址的内存,但因为ZGC通过转发指针和读屏障保证了旧地址在"完整可读"状态下才会切换,所以业务线程不会看到半份数据。
4.4 并发重映射:最后一个指针的修正
转移阶段结束之后,堆里还存在一些旧地址X的引用没被修正。理论上,因为自愈机制,只要业务线程继续运行,这些旧引用迟早会被读屏障修正掉。那为什么还需要一个专门的重映射阶段?
原因有两个。第一,如果某片内存空间里存放着一个长期不被访问的对象,这个对象里的旧引用可能很久都不会被触发修正。如果不主动清理,转发表就要一直保存,内存浪费不说,后续GC周期处理起来也麻烦。第二,转发表占用的内存需要在某个时点释放,ZGC需要确保所有旧引用都被处理完才能安全释放。
所以并发重映射阶段的本质是:GC线程主动扫描堆,把所有仍然指向旧地址的引用修正掉。但这个阶段和并发标记做了一些合并优化:在下一个GC周期的并发标记阶段,GC线程会边标记边重映射,所以ZGC日志里你经常看到Remap和Mark混在一起,并不是每个周期都有独立的Remap阶段。
等到所有旧引用都被修正,转发表就可以释放了。这时候再回看指针,你会发现:所有引用要么是当前标记色(Marked0),要么是Remapped状态。整个GC周期才算完整结束。
5. 实战观察:怎么验证和理解ZGC的行为
5.1 从GC日志里读出的东西
理论讲了这么多,最后落到实操。如果你只是把JDK升级到17(ZGC在JDK 15之后已经支持16TB堆,JDK 21之后正式分代),然后在启动参数加 -XX:+UseZGC -Xmx16G,那GC日志要怎么解读?
我建议你打开以下日志参数:
code复制-Xlog:gc*:file=gc.log:time,uptime,level,tags
一个典型的ZGC日志长这样:
code复制[26.415s][info][gc,phases] GC(3) Concurrent Mark 4370M->4370M(8192M) 2.341ms
[26.425s][info][gc,phases] GC(3) Concurrent Prepare for Relocation 4370M->4370M(8192M) 0.412ms
[26.430s][info][gc,phases] GC(3) Concurrent Relocation 4370M->1024M(8192M) 3.104ms
[26.430s][info][gc,stats ] GC(3) Using Garbage: 4370M->1024M
注意看第二行,Concurrent Relocation后面的数字:4370M->1024M,意味着转移之后堆占用从4370MB降到了1024MB,这代表ZGC成功回收了大约3.3GB的对象。如果Relocation之后Usage下降不明显,说明这个GC周期的回收效率很低,可能真的没有那么多垃圾。
另外一个值得关注的时间是Concurrent Mark。如果Mark的时间持续增长,说明存活对象图越来越大,可能需要检查是否有内存泄漏,或者堆容量是否偏小导致GC频繁。
我实测下来,ZGC的日志在GC压力大时非常频繁,所以建议用 gc*.log 分文件滚动,同时用GCViewer等工具把日志转成可视化图表,观察周期频率和堆占用变化趋势。
5.2 读屏障与染色指针对吞吐的影响
前面我说过ZGC的吞吐量可能比G1低,但具体低多少取决于应用的引用访问模式。这里我提供一个简单的方法来估算你的应用是否适合ZGC:观察它的"引用加载频率"。
你可以在压测时开 -XX:+PrintCompilation 加 -XX:+TraceJVMTIObjectTagging,配合JFR的 jdk.JVMInformation 事件来观察。不过更粗暴一点的办法是直接做A/B对比压测:同一个服务,一组用G1,一组用ZGC,在相同的QPS下对比吞吐量和延迟曲线。
我自己做过一次比较典型的实验。一个网关服务,堆8GB,主要工作是反序列化、路由转发、序列化,引用访问非常密集。G1的吞吐量能到22万QPS,ZGC只能到19万QPS,降了大约13%。但延迟分布上,G1的P99是80ms,而ZGC的P99稳定在12ms,P99.99从200ms降到35ms。这个场景下,ZGC用13%的吞吐量换来了87%的尾部延迟改善,非常划算。
另一种场景是批处理任务。我同事有个离线的报表聚合应用,跑一次要十几分钟,纯CPU密集。把ZGC切过去之后,吞吐量反而比G1低了2%,因为任务本身不追求低延迟,GC停顿对它影响也不大,那ZGC的读屏障开销就是纯负担。这种情况建议老老实实用G1或者Parallel GC。
5.3 JDK版本演进:从实验性到默认选项
选择ZGC的时候,一定要先看JDK版本,不同版本之间差异巨大。
- JDK 11:ZGC首次出现,需要
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC,且堆上限4TB。这个版本的ZGC还比较原始,用了4MB对齐,只支持单代。 - JDK 13:增加ZGC的并发堆周期统计等改进,但仍然实验性。
- JDK 15:ZGC不再是实验特性,无需UnlockExperimentalVMOptions,堆上限提升到16TB。
- JDK 16:支持分代前的最后一版,整体已经比较稳定。
- JDK 21:发布分代ZGC(Generational ZGC),支持新生代和老年代分开回收。这是目前比较推荐的生产版本。
分代ZGC的意义主要在于降低GC周期频率。原来的ZGC是全堆一起回收,即使只有新生代对象是垃圾,也要遍历整个堆。分代之后,新生代对象回收快、频率高,老年代对象GC周期可以拉得很长,堆小的时候优势尤其明显。如果你还在JDK 17上用非分代ZGC,建议升级到21试试,能明显减少GC次数和CPU开销。
6. 容易踩的坑与常见误区
6.1 "ZGC只有大堆才能用"的误区
网上流传一个说法,"ZGC适合大堆内存,小堆用不太上"。这话放在早期有一定道理,因为ZGC的设计初衷就是解决大堆的停顿问题,堆太小的时候,G1几毫秒的停顿也能接受,而ZGC的读屏障额外开销就显得不划算。
但现在分代ZGC出来后,这个结论需要修正。我在一个2GB堆的Spring Boot服务上做过对比,分代ZGC的GC停顿长期在1ms以内,而G1的Minor GC虽然也很短,但Mixed GC经常飙到30-50ms。如果这个服务对接口延迟要求高(比如支付回调、实时风控),2GB堆也值得上ZGC。
核心不是"堆有多大",而是"你的延迟阈值有多苛刻"。只要GC停顿影响到了业务,不管堆多大都值得试试ZGC。反过来,如果业务能容忍几十毫秒停顿,那G1更划算,因为它的CPU开销更小。
6.2 "读屏障=性能损失"的片面理解
很多人一听到"每次读引用都有额外检查"就觉得ZGC肯定很慢。这个理解最大的问题在于忽略了自愈机制的影响。读屏障的慢路径只有在引用还是旧状态时才触发,一旦修正过,后续所有访问都是快路径。
真正让ZGC吞吐量降低的,其实是另外两个因素:一是并发标记和转移本身要消耗CPU;二是多重映射带来的TLB(Translation Lookaside Buffer)缓存压力。ZGC的虚拟地址空间跨度很大,不同颜色对应不同区域的虚拟地址,这会让CPU的TLB缓存命中率有所下降。这个问题在JDK 15修复了4MB对齐之后有改善,但依然存在。
所以在评估ZGC的CPU开销时,不要只看读屏障,把GC线程本身的CPU消耗也算上。用 -Xlog:gc+cpu 可以查看GC线程占用的CPU时间。
6.3 分代ZGC带来的新认知
最后说一下分代ZGC的"新坑"。分代ZGC里,新生代和老年代各有自己的染色指针标记位和转发表。它的并发转移逻辑和单代ZGC不完全一样,新生代转移频率高,而老年代转移频率低得多。
在使用分代ZGC时,有几个参数值得关注:
-XX:ZYoungGenerationSize:新生代大小。设得越大,新生代GC频率越低,但老年代增长也越快。-XX:ZAllocationSpikeTolerance:分配尖峰容忍度,默认2.0,如果发现GC频繁,可以适当调高。-XX:ZFragmentationLimit:Region碎片率上限,达到这个阈值会触发GC。
我遇到过一个很典型的案例:某服务用了分代ZGC之后,出现"新生代回收很快,但老年代持续增长"的现象。排查下来发现是一个缓存组件用了大量的软引用(SoftReference),而ZGC对软引用的回收策略和G1不一样,它不会主动很激进地清理软引用。解决方案是调整 -XX:SoftRefLRUPolicyMSPerMB,之前G1下设置的默认值是1000,在ZGC下需要调低到500左右才能匹配预期的回收节奏。
另外要注意,System.gc() 在ZGC下默认也是触发Full GC的,而且ZGC的Full GC会退化成串行GC,停顿会变得很大。如果代码里有依赖 System.gc() 来主动触发GC的逻辑(比如某些NIO堆外内存管理框架),在切换到ZGC之后一定要检查,最好在启动参数里加 -XX:+DisableExplicitGC 来兜底。
说回到实践层面,我建议所有使用Java 17以上版本的团队,在新项目上直接跑一版ZGC的压测数据看看。不用一上来就切换,而是把G1和ZGC的延迟曲线放在一起对比。大多数时候你会看到ZGC把那些高耸的停顿尖峰整个削平了。染色指针和读屏障的组合,本质上就是把"GC停下所有线程去改引用"变成"让引用自己修正自己",一旦理解了这层逻辑,你看JVM的很多设计都会豁然开朗。如果读到这里还有细节绕不清楚,建议自己用JDK 21跑几个demo,开着日志看一圈GC周期,比反复看文档管用得多。
