
聊JDK 21的ZGC分代功能我先把话题拉回一个常见的场景很多团队在JDK 17甚至JDK 11上还在用G1明明ZGC的名头已经喊了好几年却一直不敢碰。原因不外乎几个——单代ZGC的全堆标记成本不低加上需要额外的内存来维护“不怎么分代”的数据结构导致在小堆场景下优势不明显甚至出现吞吐不如G1的情况。JDK 21把分代带进ZGC算是我个人认为自ZGC面世以来最有价值的一次架构级改动。这篇文章我会拆开聊清楚分代ZGC为什么能解决这些问题、内部到底改了什么、生产环境怎么配、以及我实际迁移和压测时踩到的一些坑。先说结论如果你正在用G1并且堆内存已经超过8GB或者你的服务对延迟非常敏感分代ZGC值得认真评估。JDK 21里通过-XX:ZGenerational开启JDK 21之前那些“ZGC只适合大堆、不适合小堆”的旧印象需要更新了。接下来我按自己的理解从动机、原理、配置、实战四个层面展开尽量讲人话。1. 为什么ZGC需要分代从两个被诟病的点讲起1.1 单代ZGC的痛全堆扫描成本与内存占用老牌ZGC的核心卖点是“暂停时间与堆大小无关”它能在堆扩展到几百GB甚至TB时依然保持较低的GC停顿。这个能力靠的是什么并发标记、并发转移、染色指针、读屏障这些机制组合起来让大部分GC工作都能和应用线程并行执行。但“与堆大小无关”不等于“成本与堆大小无关”。单代ZGC最大的问题是它把所有对象都看作一个代所有存活对象都要并发标记整个堆的活跃对象都要被移动和重映射。这个工作量会随着堆里“长时间存活的对象”数量增加而线性上升。很多后端服务里老年代对象占比很高比如缓存、连接池、Spring容器里的单例Bean这些对象可能占了堆的一半以上。ZGC每次GC都要和这些老家伙打交道标记一遍、转移一遍即使它们几乎永远不会死。更麻烦的是为了支持并发转移ZGC需要为每个已分配的内存块维护额外的元数据还要用染色指针来编码对象状态。在相同堆大小下ZGC的内存占用通常比G1多一块通常是堆的百分之几到十几。对小堆来说这个额外开销非常奢侈。1.2 分代为什么能解决弱分代假说的现实意义分代GC的理论基础是“弱分代假说”——大部分对象出生不久就会死亡只有少数对象会存活很长时间。这个规律在服务端Java应用里非常稳定。比如一个HTTP请求处理过程中创建的对象绝大多数在请求结束时就失去了引用真正活过几轮GC的对象少之又少。单代ZGC等于把这个规律白白浪费掉了。它每次都要对所有对象做标记和转移不管这个对象是刚创建的新生对象还是已经在堆里活了好几个小时的老对象。这就像保洁员每天把整个办公室的所有工作都做一遍而不是只处理今天产生的垃圾。分代ZGC把堆划分为年轻代和老年代年轻代的对象频繁发生GC、高频收集老年代对象少但存活率高GC频率很低。这样一来大部分GC都只扫描年轻代这一小块区域标记、转移的成本被大幅压缩。老年代的GC虽然还是要扫全堆但频率可以降低到“分钟级甚至小时级”总体开销自然比原来每轮都全堆扫描低得多。1.3 不只是模仿G1分代ZGC的不同设计目标G1也是分代GC而且是Java 9之后的默认收集器。分代ZGC看起来像是在向G1靠拢但两者的目标并不一样。G1通过分区Region卡表Card Table来维护跨代引用它的卡表更新是写屏障完成的。ZGC的分代设计同样需要维护跨代引用但它保留了并发转移和染色指针这套核心机制并没有变成另一个G1。G1在超大堆64GB以上下的停顿时间很难稳定控制在几十毫秒以内因为它的转移工作有一部分依然要STW。分代ZGC的目标则是在超大堆、低延迟场景下也能保持低停顿同时通过分代来降低并发标记的负担。这意味着分代ZGC不仅是“给ZGC加了分代”而是要在保留ZGC核心优势的前提下弥补单代模式的性能短板。在JEP 439中分代ZGC一开始被描述为一个实验性特性但它在JDK 21里已经可以用于生产。我个人的理解是这是Oracle为解决“ZGC一直被叫好但不叫座”问题给出的答案。2. 分代ZGC的内幕堆布局与GC周期2.1 新生代与老年代的物理分割分代ZGC将逻辑堆划分为年轻代Young Generation和老年代Old Generation但和G1类似的是这种划分不是传统“一整块连续空间”的物理划分而是基于Region称为ZPage的动态分配。在单代ZGC中ZGC把堆切分成大小不等的ZPage用于分配不同大小的对象。分代ZGC引入代际之后每个ZPage会被标记为属于年轻代还是老年代。年轻代满的时候会触发年轻代收集老年代满的时候会触发老年代收集。老年代收集时年轻代的对象也需要被考虑因为可能存在老年代引用了年轻代对象的情况但这部分通过记忆集Remembered Set来管理不需要全量扫描所有年轻代对象。这样的好处是年轻代可以做得很大也可以做得很小完全由运行时动态调整不依赖固定的内存布局。分代ZGC和G1一样也支持代大小的自适应调整。你可以通过-XX:NewRatio或者-XX:NewSize等参数去影响代大小但分代ZGC的代调整有自己的策略不像Parallel GC那样严格遵循这些参数。2.2 分代ZGC的GC周期年轻代收集与老年代收集先看年轻代收集。年轻代收集只处理年轻代中的对象。整个过程包括并发标记、并发转移、重映射等阶段和单代ZGC的GC周期相似但范围只针对年轻代。因为年轻代的对象通常死亡比例很高所以转移对象比例低并发标记的活对象数量少停顿自然更短。老年代收集则会处理整个堆包括年轻代和老年代。所以它的耗时理论上会接近单代ZGC的全堆收集。但分代ZGC的策略是减少老年代收集的频率让大多数GC都停留在年轻代层面。这样在请求压力大的时候GC压力主要由轻量的年轻代收集承担老年代收集只在老年代空间耗尽或触发特定条件时才发生。这里有个关键点分代ZGC的年轻代收集和老年代收集不是同时进行的。年轻代收集进行中如果老年代空间也告急会触发老年代收集但ZGC有并发协调机制来避免两者互相干扰。不过在实际运行中年轻代收集依然会受老年代收集影响因为老年代收集开始时可能需要STW来初始化一些状态。因此老年代收集依然有STW只是它的目标是把STW控制在更低范围。2.3 并发放置屏障分代ZGC最核心的变化ZGC之所以能做到低停顿依赖的是并发放置屏障Load Barrier和染色指针。单代ZGC通过读屏障在被访问对象的状态异常时进行修正使得应用线程可以一边运行一边协作完成对象转移的后续操作。分代ZGC引入了分代屏障Generational Barrier更准确地说它在读屏障之外增加了写屏障来维护代际引用。在单代ZGC里因为没有代际所以不需要写屏障来记录跨代引用而分代ZGC则需要在年轻代对象引用老年代对象、或老年代对象引用年轻代对象时维护记忆集以便年轻代收集时能快速找到老年代指向年轻代的引用。这一改动带来的性能影响是双向的一方面年轻代收集省去了全堆标记收益巨大另一方面写屏障会带来额外的运行时开销。尤其是指针写入频繁的应用写屏障可能成为新的瓶颈。所以分代ZGC在JDK 21里仍需通过-XX:ZGenerational显式开启而不是默认很大程度上也是因为写屏障的代价还在优化中。2.4 记忆集与卡表分代ZGC的数据结构变化老年代对象引用年轻代对象必须被年轻代收集器感知到否则年轻代收集时可能会漏掉这些“被老年代持有”的年轻对象。分代ZGC使用一种类似卡表Card Table的结构来记录老年代对象中引用年轻代对象的区域。具体来说老年代被划分成多个逻辑卡页每次老年代对象的引用字段被写入时写屏障会标记对应卡页为“脏”表示这里可能包含指向年轻代的引用。年轻代收集时GC只需要扫描这些脏卡页而非整个老年代从而快速找到所有跨代引用。这个设计和G1的卡表机制很相似但在实现细节上分代ZGC做了很多优化。比如它用多级过滤来减少脏卡的数量避免频繁扫描大范围老年代内存。我在查看GC日志时发现分代ZGC的老年代收集后会清理卡表这个过程是并发完成的不会占用户线程的STW时间。3. 配置与调优从JVM参数到生产环境3.1 如何启用分代ZGCJDK 21的开启姿势在JDK 21里启用分代ZGC的标准命令是java -XX:UseZGC -XX:ZGenerational -Xmx16g -Xms16g -jar my-service.jar这里的-XX:UseZGC指定使用ZGC-XX:ZGenerational表示启用分代模式。如果你只设置-XX:UseZGC在JDK 21中默认仍然使用单代ZGC具体版本行为请以启动日志为准。所以我建议所有想尝试分代ZGC的团队一开始就明确加上-XX:ZGenerational避免环境差异导致行为不一致。启动后可以通过启动日志来确认是否生效。分代ZGC会在GC日志中打印“Generational ZGC”或相关字样。我的习惯是用一段简单的启动命令来验证java -XX:UseZGC -XX:ZGenerational -Xms512m -Xmx512m -version观察输出是否包含类似“ZGC”且没有报错。在JDK 21上如果参数组合不兼容JVM会直接报错提示。比如-XX:ZGenerational不能和-XX:UseParallelGC一起使用这点需要注意。3.2 核心参数详解堆内存、并发线程与暂停时间分代ZGC里堆内存参数基本上沿用ZGC的一套。最基础的是-Xmx和-Xms在ZGC场景下我强烈建议把两者设为相同的值。原因在于ZGC支持内存归还uncommit在-Xms远小于-Xmx时JVM会频繁调整堆容量而分代ZGC的代大小也跟随堆容量动态调整。这个伸缩过程虽然不像串行GC那样STW但会产生额外开销。生产环境中直接把初始堆和最大堆设为一致少一个动态伸缩的变量排问题会舒服很多。另一个重要参数是-XX:ConcGCThreads这控制并发标记/转移阶段使用的线程数。ZGC默认会根据CPU核数自动计算但很多容器环境下JVM看到的核数和实际可用核数不一致。如果不能正确获取cgroup限制会导致并发线程数过多CPU争抢严重。建议在容器场景显式设置-XX:ConcGCThreads4通常设置为可用核数的四分之一到一半。另外-XX:ParallelGCThreads影响STW阶段的并行线程数一般不用动但如果你发现STW时间比预期长可以先看看它是不是被自动设置得过大。暂停时间目标-XX:MaxGCPauseMillis在ZGC里只是一个软目标默认值是200ms。分代ZGC一般能远低于这个值不用刻意调。真正需要关注的是-XX:SoftMaxHeapSize它允许JVM在堆达到-Xmx之前就主动触发GC避免在内存压力大的情况下出现紧急GC。对于分代ZGC可以设置一个略低于-Xmx的软上限给系统留一些缓冲-XX:SoftMaxHeapSize14g3.3 与单代ZGC、G1的对照配置为了更直观地理解差异我整理了同一台8核16GB容器上常用配置的对照表配置维度G1传统方案单代ZGC分代ZGC启用方式-XX:UseG1GC-XX:UseZGC-XX:UseZGC -XX:ZGenerational内存占用较低较高维护染色指针/元数据中等新增卡表但可调年轻代收集Region复制STW较短无分代全堆并发年轻代并发收集STW极短老年代收集混合GC可能产生多次STW全堆并发STW短但频率高全堆并发频率低典型适用堆4GB~32GB64GB以上8GB以上均适用调参重点-XX:MaxGCPauseMillis-XX:ConcGCThreads关注年轻代大小与卡表开销注意这个表只是我个人的经验总结不是官方定义。从表里能看出分代ZGC的目标是覆盖原本G1的区间同时把单代ZGC在大堆上的优势保留下来。如果你的应用堆在8GB到64GB之间且对延迟有要求过去只能G1或CMS现在分代ZGC是一个有竞争力的新选项。3.4 从G1迁移到分代ZGC的配置模板我实际迁移一个Spring Boot服务时用的参数模板如下供参考JAVA_OPTS -Xms8g -Xmx8g -XX:UseZGC -XX:ZGenerational -XX:ConcGCThreads4 -XX:ParallelGCThreads4 -XX:SoftMaxHeapSize7g -XX:-ZUncommit -Xlog:gc*:file/data/logs/gc-%t.log:time,uptime,level,tags:filecount10,filesize64m 这里几个点说明一下-XX:-ZUncommit关闭ZGC的堆内存归还。因为容器环境下JVM归还内存给操作系统可能导致后续内存分配变慢还不如保持堆恒定。-Xlog:gc*开启详细GC日志后面在实践部分会细讲。-XX:SoftMaxHeapSize7g保持比-Xmx小一点让GC在堆满前更积极回收降低Full GC的概率。这套配置在我的测试中表现稳定。如果你还在用G1直接替换-XX:UseG1GC为上面这套就行但不要一次性改动太多参数先保留原有的-Xmx和-Xms等基础设置等稳定后再逐步调整。4. 生产环境实践部署、观察与踩坑4.1 生产环境如何观察分代ZGCGC日志配置与解读分代ZGC有非常丰富的日志输出开启方式是-Xlog:gc*。但生产环境不能全量打印最好只保留关键信息-Xlog:gcinfo,gcheapdebug,gcstatsdebug:file/data/logs/gc.log:time,uptime,level,tags:filecount10,filesize64m这样会记录每次GC的类型、堆使用量、停顿时间等。我通常会配合-Xlog:gcphasesdebug来看详细的阶段耗时方便定位是标记阶段慢还是转移阶段慢。日志里的GC类型会有标识比如“Generational ZGC (Young)”、“Generational ZGC (Old)”等。年轻代收集和老年代收集会分开显示。通过日志我可以看到年轻代收集频率可能很高比如每秒一次但每次STW只有零点几毫秒老年代收集可能几分钟一次STW也稳定在几毫秒内。这种“高频低耗”才是分代ZGC的正常状态。解读GC日志时重点看两个指标Pause Mark Start、Pause Mark End这些STW阶段的耗时以及Pause Transfer等转移阶段的耗时。如果发现老年代收集频繁说明老年代空间不足或者卡表清理不够及时这时候需要考虑调整堆大小或-XX:SoftMaxHeapSize。4.2 实际案例从G1迁移到分代ZGC的性能变化举个具体的例子。我负责过一个订单查询服务16GB堆线上流量波动很大高峰时每秒几千次查询内部会加载大量历史订单数据到内存做聚合。旧配置用的G1-XX:MaxGCPauseMillis50日常表现其实还可以但一到大促流量峰值G1的混合GC会频繁发生Young GC停顿一般在20~50ms之间Mixed GC偶尔会超过100ms导致接口P99从30ms飙升到100ms以上。当时尝试过调大-XX:G1MixedGCCountTarget等参数效果有限。后来JDK 21出来后我在压测环境将相同代码切换为分代ZGC堆参数保持16GB不变只是加上了-XX:ZGenerational。压测结果显示Young GC停顿从10~50ms降到了2ms以内老年代GC的停顿也很少超过5ms。接口P99在高流量下稳定在35ms左右相比原G1下降非常明显。还有一个隐藏的收益是吞吐量。G1在GC时会有比较多STW而分代ZGC的并发程度更高在相同QPS下CPU使用率略低。需要注意的是分代ZGC的写屏障会带来轻微的单线程分配开销所以CPU降幅没有想象中那么大。总体感受是用分代ZGC替换G1不是“无脑变快”而是“延迟更平稳吞吐略好”。如果你的应用堆小于4GB这种优势可能不明显甚至可能因为屏障开销出现性能回退。4.3 常见问题与排查内存碎片、CPU飙高、分配失败先说内存碎片。ZGC的ZPage本身不连续但对象分配是靠地址连续的ZPage内部进行的。分代ZGC在频繁年轻代收集后老年代可能有碎片化问题但ZGC的转移机制可以整理碎片所以一般不用担心碎片导致无法分配。如果出现内存分配失败多半是-Xmx设置过小或者-XX:SoftMaxHeapSize压得太厉害导致GC回收速度跟不上分配速度。我踩过的一个真实坑是CPU飙高。上线分代ZGC后发现应用CPU比G1时高出10%一开始怀疑是写屏障开销后来通过-XX:ConcGCThreads从默认的8调整到4同时观察到GC日志里并发标记线程占用大量CPU才确认是容器里JVM自动识别CPU核数过多导致并发线程开太多。显式设置ConcGCThreads后CPU恢复正常。另一个常见问题是-XX:ZGenerational在JDK 21中与某些APM探针不兼容。有几次我们接入字节码增强Agent时出现奇怪的GC日志异常排查后发现是探针修改了对象头相关代码导致ZGC的读屏障判断异常。这个不是ZGC本身的问题但迁移分代ZGC时建议先在预发环境把探针、监控Agent都完整跑一遍特别关注是否会报出ZGC phase相关的错误。最后是分配失败OutOfMemoryError。分代ZGC在年轻代收集和老年代收集都无法回收足够内存时会抛出OOM。排查思路和单代ZGC一致先用jmap -histo:live看存活对象再用-XX:SoftMaxHeapSize给GC更宽松的触发条件或者调大-Xmx。不要一上来就加-Xmx先把SoftMaxHeapSize和GC频率调好避免无意义的扩容。4.4 给团队的建议什么时候该用分代ZGC什么时候不该用分代ZGC不是银弹。我给出的判断标准基于实际观察分为几类场景。第一类强烈建议尝试堆大于8GB、延迟敏感、使用G1但频繁出现长停顿的服务。比如在线交易、实时广告、推荐引擎等。这类服务从G1迁移到分代ZGC通常能明显缩短STW提升稳定性。第二类可以考虑堆在4GB到8GB之间现有G1 GC次数较多但停顿尚可接受可以压测分代ZGC对比CPU和延迟。如果CPU增加低于2%、P99改善明显就值得切换。第三类不建议使用堆小于4GB、对CPU极度敏感、应用本身创建对象极慢或极快。分代ZGC的内存布局和写屏障带来固定开销在小堆上优势不明显。另外如果应用长时间处于低并发状态GC频率本身就很低分代ZGC也发挥不出太大价值。从团队迁移的角度我建议分三步走先在压测环境用生产流量回放跑48小时观察GC日志和业务指标再在预发环境切换一个低流量实例灰度一周最后在流量非高峰时段逐步切全量。不要第一天上线就全量切换碰上APM探针不兼容这类问题会让你措手不及。我在生产环境实际使用两三个月后最深的体感是分代ZGC把“ZGC只适合超大堆、不适合常规应用”的刻板印象打破了。对于大多数Java后端服务只要堆内存不是特别小它都能给出比G1更平滑的GC表现。最后分享一个小技巧观察分代ZGC时别只看GC日志配合JFRJava Flight Recorder的GC事件一起看能更准确地定位是GC停顿导致延迟还是写屏障带来的分配开销在影响吞吐。如果没有使用JFR的条件就在压测时多采集几组CPU火焰图对比GC线程和业务线程的占比这样能避免走弯路。