|
1为什么要升级JDK2为什么选择JDK213分代ZGC简介3.1什么是分代ZGC3.2垃圾回收过程3.3分代ZGC调优方式3.4分代ZGC设计要点4接入分代ZGC与监控搭建4.1接入分代ZGC4.2分代ZGC监控搭建5性能测试5.1压测环境5.2压测数据5.3压测结论6后续进展7致谢8参考资料1为什么要升级JDK此前转转平台基础体验部的后端服务JDK版本为1.8(1.8.0_191),在1.8以后已经迭代了十几个版本,每个版本中都包含了许多新特性。新特性有助于简化代码操作、提升系统安全性、降低系统的开销等等。结合实际场景,商列服务作为最上层服务只有RPC调用和业务代码,包含了:商品列表页数据以及筛选项,其中下游返回的筛选项报文很大。当商列服务有尖峰流量涌入时,如:618、双11场景,大量新生对象产生,内存回收速率跟不上应用申请内存速率,导致高频YGC/FGC、GC时间过长,降低服务可用性。通过纵向扩容方式,对于大内存,1.8的各种垃圾回收器都不能达到良好的回收效果;通过横向扩容方式,成本增加、数据库等连接数也会升高。随着ZGC垃圾回收器问世,借助它的并发回收、低延迟、毫秒级暂停、支持大内存的特性,与我们的现状契合,基于此平台开始考虑升级JDK。2为什么选择JDK21从Oracle长期支持的版本看,可选的版本有11、17、21,如图1所示。图1OracleJavaSESupportRoadmap基于自身使用诉求,需要引入支持ZGC垃圾回收器的JDK,支持ZGC的JDK版本列表如图2所示。图2支持ZGC的JDK版本列表综上可以考虑JDK17或JDK21。对于JDK17我们在线上接入并且压测过。当压测流量为日常峰值的4倍时,因内存回收速率跟不上应用申请内存速率,触发AllocationStall(AllocationStall是触发GC的一个原因,由于无充足可用内存导致,会引发应用线程停顿,类似StopTheWorld),进而引起应用线程等待直到可以重新申请新的内存,最终服务可用率下降。如图3所示,在压测的8分钟内出现396次AllocationStall。图3JDK17AllocationStall示例可以考虑通过增加内存以承载更大的流量,但这并不是一个长期可行的方案。JDK开发组在JEP439中提出了分代ZGC。在相同的堆内存条件下,分代ZGC只需70%的内存,达到4倍的吞吐量,并且仍然可以保持停顿时间小于1ms,大幅降低了AllocationStall。至此,我们选择了JDK21,开始了实践之路。图4ZGC与分代ZGCBenchmarks3分代ZGC简介3.1什么是分代ZGCZGC是一个可伸缩的低延迟垃圾收集器,最高能支持TB级堆内存,能并发执行繁重任务,且不会让应用的暂停时间超过1ms。ZGC适用于要求低延迟的应用,暂停时间与所使用的堆大小无关。分代ZGC是ZGC的一个实现版本,依据假说:应用中的大部分对象都是短生命周期的,被设计为分代,即:年轻代、老年代。相对ZGC,分代ZGC提高了应用吞吐率、降低了AllocationStall频率、且依然能够保持对应用的暂停时间小于1ms。3.2垃圾回收过程3.2.1堆内存模型分代ZGC将堆内存分为两个逻辑区域:年轻代、老年代,堆内存模型如图5所示。图5分代ZGC堆内存模型当分配对象时,它首先会被分配到年轻代,如图6所示。若该对象经历过多次年轻代回收后依然存活,它将会被晋升到老年代,如图7所示。图6新对象被分配到年轻代图7对象被晋升到老年代在实际的内存分布中,年轻代、老年代会分布在不连续的内存区域,如图8所示。图8年轻代、老年代的内存分布3.2.2分代ZGC回收阶段回收一个代的阶段如图9所示,包含:垂直方向的GC暂停,以及水平方向的并发阶段。图9回收一个代的阶段(1)暂停点1:这是一个同步点,仅标识标记开始。(2)并发阶段1:开始运行应用程序、并发标记获取对象是否可达,在并发标记的同时,对最近一次GCCycle内的对象remapping(当我们获取对象引用时,分代ZGC的loadbarrier会检查对象引用,若对象引用过期,会生成新的对象引用,这个过程称为remapping)。(3)暂停点2:这是也一个同步点,用于标识标记结束。(4)并发阶段2:为疏散区域(Region)做准备工作、处理reference、类的卸载等。(5)暂停点3:同样也一个同步点,用于标识将要移动对象。(6)并发阶段3:移动对象,以便释放出连续的内存。在分代ZGC各阶段(Phases)中,年轻代回收阶段、老年代回收阶段以及应用程序的运行完全是并发的,如图10所示。图10分代ZGC各阶段分代ZGC将回收阶段划分为两类:MinorCollections和MajorCollections以统一管理。MinorCollection:该阶段只回收年轻代,访问年轻代以及老年代对象中指向年轻代对象的字段,访问他们的主要原因是:(1)GCMarkingRoots:这样的字段包含唯一引用,使年轻代ObjectGraph的一部分保持可达。GC必须将这些字段视为ObjectGraph的根,以确保所有存活的对象都被发现,并标记他们的存活状态。(2)老年代中的陈旧指针:收集年轻代时会移动对象,这些对象的指针没有被立即更新。老年代到年轻代的指针集合称为rememberedset,包含了所有指向年轻代的指针。图11MinorCollectionMajorCollection:该阶段期望回收整个堆,既访问年轻代,也访问老年代。和MinorCollection类似,找到GCMarkingRoots,以及年轻代中指向老年代的Roots。当年轻代收集完之后,可以找到所有老年代中存活的对象。当估算到所有存活的对象之后,就可以移动对象、回收内存。图12MajorCollection3.3分代ZGC调优方式分代ZGC在设计之初,希望是自适应的,且以最小化人工配置对其进行调优,大部分内容都由分代ZGC内部自动计算调整,唯一重要的、需要调优的参数只有最大堆内存,即:-Xmx。堆内存的大小根据内存分配速率以及应用中的存活对象集大小决定。通常来说,提供的堆内存越大,分代ZGC的性能表现越好。此前用到的很多参数项都不需要再设置,在分代ZGC中即使设置了这些参数也是无效的。例如:-Xmn、-XX:TenuringThrehold、-XX:InitiatingHeapOccupancyPercent、-XX:ConGCThreads等等。对于分代ZGC的其他调优点,例如:使用大页、使用透明大页等,详见:参考资料第3点。分代ZGC支持的所有GC参数项如下:图13分代ZGC支持的GC参数项列表需要注意的是,在JDK21版本中,仍然保留了ZGC的参数项。某些参数刚刚提到过,对于分代ZGC无需设置-XX:ConGCThreads参数项。3.4分代ZGC设计要点分代ZGC将堆划分为两个逻辑区域:年轻代、老年代,二者的回收完全独立,分代ZGC关注更有回收价值的年轻代对象。与ZGC一样,分代ZGC的执行和应用运行并发。由于与应用程序同时需要读取/修改ObjectGraph,必须为应用程序提供一致的ObjectGraph。分代ZGC通过:coloredpointers(染色指针)、loadbarrier(加载屏障)、storebarrier(存储屏障)实现,不再使用multi-mappedmemory做多次映射。coloredpointers:染色指针,是指向堆中对象的指针,和对象内存地址一起包含了对对象已知状态进行编码的元数据,元数据描述了:地址是否正确、对象是否存活等,如图14、15所示。附ZGCcoloredpointers地址结构以作对比,如图16所示。新的染色指针数据结构,支持了更多的colorbit(染色位)以支持实现更复杂的算法、扩大了对象地址的存储空间、规避了因使用multi-mappedmemory导致的RSS统计为ZGC实际内存使用的3倍。图14分代ZGCloadbarrier染色指针地址结构图15分代ZGCstorebarrier染色指针地址结构图16ZGCloadbarrier染色指针地址结构loadbarrier:加载屏障,是从堆中加载对象引用时,由JIT注入的一段代码。负责移除染色指针中的元数据位、更新GC重定位对象的过期指针。storebarrier:存储屏障,是向堆中存储对象引用时,由JIT注入的一段代码。负责填充元数据位以创建染色指针、维护rememberedset(老年代中指向年轻代的对象指针)、标记对象正在存活。分代ZGC还有其他设计要点,帮助分代ZGC实现卓越的性能。简列如下,因篇幅限制、理论性较强,读者可以查看参考资料第6点JEP439,获取更多技术细节。Optimizedbarriers:屏障优化Fastpathsandslowpaths:快路径、慢路径Minimizingloadbarrierresponsibilities:最小化loadbarrier职责Remembered-setbarriers:使用rememberedset集合SATBmarkingbarriers:使用Snapshot-at-beginning算法标记Fusedstorebarrierchecks:融合storebarrier检查Storebarrierbuffers:storebarrier缓冲区Barrierpatching:barrier修补Double-bufferedrememberedsets:双重缓存,rememberedsets由bitmaps实现Relocationswithoutadditionalheapmemory:不需要额外的堆内存完成重定位Denseheapregions:密集堆区域,减少年轻代回收工作Largeobjects:允许大对象分配在年轻代,避免重定位发生Fullgarbagecollections:完整的垃圾回收,讲述年轻代指向老年代对象指针的回收方式4接入分代ZGC与监控搭建4.1接入分代ZGC接入分代ZGC的前提是要接入JDK21。接入JDK21期间,你可能会遇到以下问题:JDKAPI过期:JDK21中有些API已经标记过期,已过期的API列表详见参考资料第7点。SpringBoot版本不适配:如果项目中使用了SpringBoot,从SpringBoot官方文档来看,JDK21最少需要SpringBoot2.7.17版本,2.7.17版本是SpringBoot2.0的倒数第二个版本,建议升级SpringBoot到2.7.18(2.0最后一个版本)。2.7.18是SpringBoot2.0兼容JDK21有限的几个版本,JDK21新特性在SpringBoot的主要应用将发布在SpringBoot3.+上。老的SpringBoot1.5项目升级2.0官方指南详见参考资料第9点。IDEA无法启动项目:老版本IDEA无法启动JDK21的项目,需要将IDEA版本升级到2023.3.2及以上。lombok异常:lombok报java:java.lang.NoSuchFieldError:Classcom.sun.tools.javac.tree.JCTree$JCImportdoesnothavememberfield'com.sun.tools.javac.tree.JCTreequalid',升级版本至1.18.30以上即可解决。成功接入JDK21后,使用-XX:+UseZGC-XX:+ZGenerational即可开启分代ZGC。JVM参数配置样例:-XX:MetaspaceSize=640m-XX:MaxMetaspaceSize=640m-Xms12g-Xmx12g-XX:+UseZGC-XX:+ZGenerational-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=日志路径/gc-%t.log:time,tid,tags:filecount=5,filesize=50m4.2分代ZGC监控搭建GC的暂停时间、GC的频率、引发GC的原因等是衡量GC健康度的关键指标。因为分代ZGC的暂停时间极低,日常中主要关注:GC原因中的AllocationStall频率即可。GC原因包括:ProactiveGC:自主进行垃圾回收,常见于服务刚启动时。AllocationRate:按照分配率自动调节,日常中常见该类型。HighUsage:当堆内存占用率过高时会触发,常见服务运行一段时间后,流量较低时,因没有及时触发GC,内存使用率到达了阈值。CodeCacheGCThreshold:达到CodeCache阈值时触发。AllocationStall:内存回收速率跟不上应用申请内存速率时触发(即:内存不足时),会引发应用线程停顿,类似StopTheWorld,应最大限度避免。接下来我们看下监控搭建,通过实现NotificationListener接口完成:自定义监听、数据上报逻辑,然后将该监听器注册到垃圾回收的管理接口中,即可完成监控数据的获取。示例如下,监控中包含了:堆内存使用、内存使用、GC暂停时间、GC暂停次数、GC原因、GC回收周期、GC回收次数监控项。/** * GC通知过滤器 */public class InfoShowGCNotificationFilter implements NotificationFilter { /** * 是否启用通知 */ @Override public boolean isNotificationEnabled(Notification notification) { boolean enable = GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION.equals(notification.getType()); return enable; }}/** * GC监听器注册 */@Slf4j@Componentpublic class InfoShowGCNotificationRegister implements InitializingBean { private static List garbageCollectorMXBeanList = ManagementFactory.getGarbageCollectorMXBeans(); @Override public void afterPropertiesSet() throws Exception { if (CollectionUtils.isEmpty(garbageCollectorMXBeanList)) { return; } for (GarbageCollectorMXBean garbageCollectorMXBean : garbageCollectorMXBeanList) { try { NotificationEmitter notificationEmitter = (NotificationEmitter) garbageCollectorMXBean; InfoShowGCNotificationListener notificationListener = new InfoShowGCNotificationListener(); // 声明一个监听器 InfoShowGCNotificationFilter notificationFilter = new InfoShowGCNotificationFilter(); // 声GC通知过滤器 notificationEmitter.addNotificationListener(notificationListener, notificationFilter, garbageCollectorMXBean); // 注册监听器、通知过滤器 } catch (Exception e) { log.error("desc=GC监听器注册失败 e=", e); } } }}/** * GC监听器 */@Slf4jpublic class InfoShowGCNotificationListener implements NotificationListener { /** * 处理通知 */ @Override public void handleNotification(Notification notification, Object handback) { try { GarbageCollectionNotificationInfo notificationInfo = GarbageCollectionNotificationInfo.from((CompositeData) notification.getUserData()); GcInfo gcInfo = notificationInfo.getGcInfo(); String gcName = notificationInfo.getGcName(); // GC类别名称: Minor/Major GC String gcCause = notificationInfo.getGcCause(); // GC原因 String gcAction = notificationInfo.getGcAction(); // GC动作 // 因篇幅原因,字符串不再定义为常量,直接书写在代码中 if ("end of GC pause".equals(gcAction)) { ZGC_GC_PAUSE_TIME.labels("time").set(new BigDecimal(String.valueOf(gcInfo.getDuration())).doubleValue()); ZGC_GC_PAUSE_TIMES.labels("times").inc(); } if ("end of GC cycle".equals(gcAction)) { StringBuilder gcCauseStr = new StringBuilder(); gcCauseStr.append(gcName).append(" ").append(gcCause); ZGC_GC_CAUSE.labels(gcCauseStr.toString()).inc(); ZGC_GC_CYCLE_TIMES.labels("times").inc(); double gcCycleTime = gcInfo.getDuration(); ZGC_GC_CYCLE_TIME.labels("time").set(gcCycleTime); Map gcBeforeMemoryInfo = gcInfo.getMemoryUsageBeforeGc(); Map gcAfterMemoryInfo = gcInfo.getMemoryUsageAfterGc(); MemoryUsage youngGenerationMemoryBeforeGc = MapUtils.getObject(gcBeforeMemoryInfo, "ZGC Young Generation", null); MemoryUsage youngGenerationMemoryAfterGc = MapUtils.getObject(gcAfterMemoryInfo, "ZGC Young Generation", null); MemoryUsage oldGenerationMemoryBeforeGc = MapUtils.getObject(gcBeforeMemoryInfo, "ZGC Old Generation", null); MemoryUsage oldGenerationMemoryAfterGc = MapUtils.getObject(gcAfterMemoryInfo, "ZGC Old Generation", null); // 其他代码为GC发生前后的内存使用量计算、上报逻辑,因篇幅原因,省略。 // 如果想统计堆内存,需要排除以下这几个内存部分:"Metaspace","CompressedClassSpace","CodeHeap'profilednmethods'","CodeHeap'non-profilednmethods'","CodeHeap'non-nmethods'" } } catch (Exception e) { log.error("desc=上报分代ZGC监控数据异常 e=", e); } return; }}PrometheusCollector定义如下: public static final Counter ZGC_GC_CAUSE = Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CAUSE").labelNames("reason").help("GC原因").register(); public static final Gauge ZGC_HEAP_USED = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_HEAP_USED").labelNames("phase").help("堆内存使用(M)").register(); public static final Gauge ZGC_MEMORY_USED = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_MEMORY_USED").labelNames("memory").help("内存使用(M)").register(); public static final Gauge ZGC_GC_PAUSE_TIME = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_PAUSE_TIME").labelNames("time").help("GC暂停时间ms").register(); public static final Counter ZGC_GC_PAUSE_TIMES = Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_PAUSE_TIMES").labelNames("times").help("GC暂停次数").register(); public static final Gauge ZGC_GC_CYCLE_TIME = Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CYCLE_TIME").labelNames("time").help("GC回收周期ms").register(); public static final Counter ZGC_GC_CYCLE_TIMES = Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CYCLE_TIMES").labelNames("times").help("GC回收次数").register();我们将GC的监控数据上报到了Prometheus中,监控看板样例如图17、18所示。图17分代ZGC监控看板示例-1图18分代ZGC监控看板示例-25性能测试5.1压测环境JDK21的ZGC和JDK17的ZGC并无区别,为了验证其一致性,也压测过,篇幅原因不再赘述。本压测通过对比JDK21(21.0.2_13)的ZGC和分代ZGC,评估下ZGC在支持分代前/后的性能。环境配置信息如下:图19压测环境每组有3个实例。压测的接口为:App首页商列、App主搜商列、AppC2C商详推荐等核心商品列表页接口。一共压测3轮,每轮压测时长为10分钟,压测流量倍数分别为日常流量峰值(QPS)的2倍、4倍、8倍。5.2压测数据汇总各轮次的压测数据如图20-22所示。第一行数据对应ZGC,第二行数据对应分代ZGC。图202倍日常峰值流量时,集群基础数据图214倍日常峰值流量时,集群基础数据图228倍日常峰值流量时,集群基础数据另附上:8倍日常流量峰值时,GCAllocationStall、集群QPS、压测错误率、GC暂停时间监控附图,如图23-26所示。图238倍日常峰值流量时,GCAllocationStall对比数据图248倍日常峰值流量时,集群QPS对比数据图258倍日常峰值流量时,压测错误率对比数据图268倍日常峰值流量时,GC暂停时间对比数据5.3压测结论以日常流量峰值的8倍场景为例,详细数据为:CPU平均使用率:上涨20%最大内存使用率:基本不变,使用率为98%GC暂停时间:几乎无暂停,分代ZGC单次停顿时间不超过1ms,暂停QPS为2~3GCAllocationStall次数:降低85%(638-->94次)QPS:提升15%(737-->842)TPAvg:降500ms(1300-->788ms)TP90:降低300ms(1963-->1660ms)TP99:降低2.5s(4473-->1967ms)错误比率降低了28个百分点(40.88%-->12.91%)综上,分代ZGC可提高资源利用率,更低的AllocationStall次数,更高的集群QPS,更低的TP,更低的接口错误率,垃圾回收几乎没有停顿。至此,可全量使用JDK21分代ZGC。6后续进展转转平台基础体验部的新媒体承接服务、商列服务都已经接入了JDK21,其中商列服务更是经历了2024年618的实战考验,服务非常稳定。其他核心服务之后陆续也会升级到JDK21。除了分代ZGC,借助JDK21的虚拟线程、结构化并发等新特性,将会带来更多新的可能。7致谢感谢架构部、工程效率部、运维部在支持JDK21过程中付出的努力,使得JDK21能够顺利地应用在服务中。相信JDK21定会是下一个具有划时代意义的版本,通过本次JDK的升级,让我们保持在技术革命风口的最前沿。8参考资料[1]OracleJavaSESupportRoadmap,2024,https://www.oracle.com/java/technologies/java-se-support-roadmap.html[2]IrisClark,StefanKarlsson.TheZGarbageCollector(ZGC),2023,https://wiki.openjdk.org/display/zgc/Main[3]TheZGarbageCollector,https://docs.oracle.com/en/java/javase/21/gctuning/z-garbage-collector.html[4]ErikÖsterlund.GenerationalZGCandBeyond,2023,https://inside.java/2023/08/31/generational-zgc-and-beyond/[5]GarbageCollectorImplementation,https://docs.oracle.com/en/java/javase/21/gctuning/garbage-collector-implementation.html[6]StefanKarlsson,ErikHelin,ErikÖsterlund,VladimirKozlov.JEP439:GenerationalZGC,2023,https://openjdk.org/jeps/439[7]DeprecatedAPI,https://docs.oracle.com/en/java/javase/21/docs/api/deprecated-list.html[8]苑冲.JDK21调研踩坑记录,2024[9]AndyWilkinson.SpringBoot2.0MigrationGuide,2021,https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.0-Migration-Guide作者张鹏程,来自转转集团-研发中心-平台基础体验后端团队,负责转转App后端开发工作。微信号:zpc_1994
|
|