|
JDK21(21.0.2_13)分代ZGC在转转商列服务中的实践
张鹏程
转转技术
转转技术 转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 各种干货实践,欢迎交流分享,如有问题可随时联系 waterystone ~ 149篇内容
2024年08月23日 18:40
北京
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所示。图1 Oracle Java SE Support Roadmap基于自身使用诉求,需要引入支持ZGC垃圾回收器的JDK,支持ZGC的JDK版本列表如图2所示。图2 支持ZGC的JDK版本列表综上可以考虑JDK17或JDK21。对于JDK17我们在线上接入并且压测过。当压测流量为日常峰值的4倍时,因内存回收速率跟不上应用申请内存速率,触发Allocation Stall(Allocation Stall是触发GC的一个原因,由于无充足可用内存导致,会引发应用线程停顿,类似Stop The World),进而引起应用线程等待直到可以重新申请新的内存,最终服务可用率下降。如图3所示,在压测的8分钟内出现396次Allocation Stall。图3 JDK17 Allocation Stall示例可以考虑通过增加内存以承载更大的流量,但这并不是一个长期可行的方案。JDK开发组在JEP 439中提出了分代ZGC。在相同的堆内存条件下,分代ZGC只需70%的内存,达到4倍的吞吐量,并且仍然可以保持停顿时间小于1ms,大幅降低了Allocation Stall。至此,我们选择了JDK21,开始了实践之路。图4 ZGC与分代ZGC Benchmarks3 分代ZGC简介3.1 什么是分代ZGCZGC是一个可伸缩的低延迟垃圾收集器,最高能支持TB级堆内存,能并发执行繁重任务,且不会让应用的暂停时间超过1ms。ZGC适用于要求低延迟的应用,暂停时间与所使用的堆大小无关。分代ZGC是ZGC的一个实现版本,依据假说:应用中的大部分对象都是短生命周期的,被设计为分代,即:年轻代、老年代。相对ZGC,分代ZGC提高了应用吞吐率、降低了Allocation Stall频率、且依然能够保持对应用的暂停时间小于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:开始运行应用程序、并发标记获取对象是否可达,在并发标记的同时,对最近一次GC Cycle内的对象remapping(当我们获取对象引用时,分代ZGC的load barrier会检查对象引用,若对象引用过期,会生成新的对象引用,这个过程称为remapping)。(3)暂停点2:这是也一个同步点,用于标识标记结束。(4)并发阶段2:为疏散区域(Region)做准备工作、处理reference、类的卸载等。(5)暂停点3:同样也一个同步点,用于标识将要移动对象。(6)并发阶段3:移动对象,以便释放出连续的内存。在分代ZGC各阶段(Phases)中,年轻代回收阶段、老年代回收阶段以及应用程序的运行完全是并发的,如图10所示。图10 分代ZGC各阶段分代ZGC将回收阶段划分为两类:Minor Collections和Major Collections以统一管理。Minor Collection:该阶段只回收年轻代,访问年轻代以及老年代对象中指向年轻代对象的字段,访问他们的主要原因是:(1)GC Marking Roots:这样的字段包含唯一引用,使年轻代Object Graph的一部分保持可达。GC必须将这些字段视为Object Graph的根,以确保所有存活的对象都被发现,并标记他们的存活状态。(2)老年代中的陈旧指针:收集年轻代时会移动对象,这些对象的指针没有被立即更新。老年代到年轻代的指针集合称为remembered set,包含了所有指向年轻代的指针。图11 Minor CollectionMajor Collection:该阶段期望回收整个堆,既访问年轻代,也访问老年代。和Minor Collection类似,找到GC Marking Roots,以及年轻代中指向老年代的Roots。当年轻代收集完之后,可以找到所有老年代中存活的对象。当估算到所有存活的对象之后,就可以移动对象、回收内存。图12 Major Collection3.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的执行和应用运行并发。由于与应用程序同时需要读取/修改Object Graph,必须为应用程序提供一致的Object Graph。分代ZGC通过:colored pointers(染色指针)、load barrier(加载屏障)、store barrier(存储屏障)实现,不再使用multi-mapped memory做多次映射。colored pointers:染色指针,是指向堆中对象的指针,和对象内存地址一起包含了对对象已知状态进行编码的元数据,元数据描述了:地址是否正确、对象是否存活等,如图14、15所示。附ZGC colored pointers地址结构以作对比,如图16所示。新的染色指针数据结构,支持了更多的color bit(染色位)以支持实现更复杂的算法、扩大了对象地址的存储空间、规避了因使用multi-mapped memory导致的RSS统计为ZGC实际内存使用的3倍。图14 分代ZGC load barrier染色指针地址结构图15 分代ZGC store barrier染色指针地址结构图16 ZGC load barrier染色指针地址结构load barrier:加载屏障,是从堆中加载对象引用时,由JIT注入的一段代码。负责移除染色指针中的元数据位、更新GC重定位对象的过期指针。store barrier:存储屏障,是向堆中存储对象引用时,由JIT注入的一段代码。负责填充元数据位以创建染色指针、维护remembered set(老年代中指向年轻代的对象指针)、标记对象正在存活。分代ZGC还有其他设计要点,帮助分代ZGC实现卓越的性能。简列如下,因篇幅限制、理论性较强,读者可以查看参考资料第6点JEP 439,获取更多技术细节。Optimized barriers:屏障优化Fast paths and slow paths:快路径、慢路径Minimizing load barrier responsibilities:最小化load barrier职责Remembered-set barriers:使用remembered set集合SATB marking barriers:使用Snapshot-at-beginning算法标记Fused store barrier checks:融合store barrier检查Store barrier buffers:store barrier缓冲区Barrier patching:barrier修补Double-buffered remembered sets:双重缓存,remembered sets由bitmaps实现Relocations without additional heap memory:不需要额外的堆内存完成重定位Dense heap regions:密集堆区域,减少年轻代回收工作Large objects:允许大对象分配在年轻代,避免重定位发生Full garbage collections:完整的垃圾回收,讲述年轻代指向老年代对象指针的回收方式4 接入分代ZGC与监控搭建4.1 接入分代ZGC接入分代ZGC的前提是要接入JDK21。接入JDK21期间,你可能会遇到以下问题:JDK API过期:JDK21中有些API已经标记过期,已过期的API列表详见参考资料第7点。Spring Boot版本不适配:如果项目中使用了Spring Boot,从Spring Boot官方文档来看,JDK21最少需要Spring Boot 2.7.17版本,2.7.17版本是Spring Boot2.0的倒数第二个版本,建议升级Spring Boot到2.7.18(2.0最后一个版本)。2.7.18是Spring Boot2.0兼容JDK21有限的几个版本,JDK21新特性在Spring Boot的主要应用将发布在Spring Boot3.+上。老的Spring Boot1.5项目升级2.0官方指南详见参考资料第9点。IDEA无法启动项目:老版本IDEA无法启动JDK21的项目,需要将IDEA版本升级到2023.3.2及以上。lombok异常:lombok报java: java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid',升级版本至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原因中的Allocation Stall频率即可。GC原因包括:Proactive GC:自主进行垃圾回收,常见于服务刚启动时。Allocation Rate:按照分配率自动调节,日常中常见该类型。High Usage:当堆内存占用率过高时会触发,常见服务运行一段时间后,流量较低时,因没有及时触发GC,内存使用率到达了阈值。CodeCache GC Threshold:达到CodeCache阈值时触发。Allocation Stall:内存回收速率跟不上应用申请内存速率时触发(即:内存不足时),会引发应用线程停顿,类似Stop The World,应最大限度避免。接下来我们看下监控搭建,通过实现NotificationListener接口完成:自定义监听、数据上报逻辑,然后将该监听器注册到垃圾回收的管理接口中,即可完成监控数据的获取。示例如下,监控中包含了:堆内存使用、内存使用、GC暂停时间、GC暂停次数、GC原因、GC回收周期、GC回收次数监控项。/***GC通知过滤器*/publicclassInfoShowGCNotificationFilterimplementsNotificationFilter{/***是否启用通知*/@OverridepublicbooleanisNotificationEnabled(Notificationnotification){booleanenable=GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION.equals(notification.getType());returnenable;}}/***GC监听器注册*/@Slf4j@ComponentpublicclassInfoShowGCNotificationRegisterimplementsInitializingBean{privatestaticListgarbageCollectorMXBeanList=ManagementFactory.getGarbageCollectorMXBeans();@OverridepublicvoidafterPropertiesSet()throwsException{if(CollectionUtils.isEmpty(garbageCollectorMXBeanList)){return;}for(GarbageCollectorMXBeangarbageCollectorMXBean:garbageCollectorMXBeanList){try{NotificationEmitternotificationEmitter=(NotificationEmitter)garbageCollectorMXBean;InfoShowGCNotificationListenernotificationListener=newInfoShowGCNotificationListener();//声明一个监听器InfoShowGCNotificationFilternotificationFilter=newInfoShowGCNotificationFilter();//声GC通知过滤器notificationEmitter.addNotificationListener(notificationListener,notificationFilter,garbageCollectorMXBean);//注册监听器、通知过滤器}catch(Exceptione){log.error("desc=GC监听器注册失败e=",e);}}}}/***GC监听器*/@Slf4jpublicclassInfoShowGCNotificationListenerimplementsNotificationListener{/***处理通知*/@OverridepublicvoidhandleNotification(Notificationnotification,Objecthandback){try{GarbageCollectionNotificationInfonotificationInfo=GarbageCollectionNotificationInfo.from((CompositeData)notification.getUserData());GcInfogcInfo=notificationInfo.getGcInfo();StringgcName=notificationInfo.getGcName();//GC类别名称:Minor/MajorGCStringgcCause=notificationInfo.getGcCause();//GC原因StringgcAction=notificationInfo.getGcAction();//GC动作//因篇幅原因,字符串不再定义为常量,直接书写在代码中if("endofGCpause".equals(gcAction)){ZGC_GC_PAUSE_TIME.labels("time").set(newBigDecimal(String.valueOf(gcInfo.getDuration())).doubleValue());ZGC_GC_PAUSE_TIMES.labels("times").inc();}if("endofGCcycle".equals(gcAction)){StringBuildergcCauseStr=newStringBuilder();gcCauseStr.append(gcName).append("").append(gcCause);ZGC_GC_CAUSE.labels(gcCauseStr.toString()).inc();ZGC_GC_CYCLE_TIMES.labels("times").inc();doublegcCycleTime=gcInfo.getDuration();ZGC_GC_CYCLE_TIME.labels("time").set(gcCycleTime);MapgcBeforeMemoryInfo=gcInfo.getMemoryUsageBeforeGc();MapgcAfterMemoryInfo=gcInfo.getMemoryUsageAfterGc();MemoryUsageyoungGenerationMemoryBeforeGc=MapUtils.getObject(gcBeforeMemoryInfo,"ZGCYoungGeneration",null);MemoryUsageyoungGenerationMemoryAfterGc=MapUtils.getObject(gcAfterMemoryInfo,"ZGCYoungGeneration",null);MemoryUsageoldGenerationMemoryBeforeGc=MapUtils.getObject(gcBeforeMemoryInfo,"ZGCOldGeneration",null);MemoryUsageoldGenerationMemoryAfterGc=MapUtils.getObject(gcAfterMemoryInfo,"ZGCOldGeneration",null);//其他代码为GC发生前后的内存使用量计算、上报逻辑,因篇幅原因,省略。//如果想统计堆内存,需要排除以下这几个内存部分:"Metaspace", "Compressed Class Space", "CodeHeap 'profiled nmethods'", "CodeHeap 'non-profiled nmethods'", "CodeHeap 'non-nmethods'"}}catch(Exceptione){log.error("desc=上报分代ZGC监控数据异常e=",e);}return;}}Prometheus Collector定义如下:publicstaticfinalCounterZGC_GC_CAUSE=Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CAUSE").labelNames("reason").help("GC原因").register();publicstaticfinalGaugeZGC_HEAP_USED=Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_HEAP_USED").labelNames("phase").help("堆内存使用(M)").register();publicstaticfinalGaugeZGC_MEMORY_USED=Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_MEMORY_USED").labelNames("memory").help("内存使用(M)").register();publicstaticfinalGaugeZGC_GC_PAUSE_TIME=Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_PAUSE_TIME").labelNames("time").help("GC暂停时间ms").register();publicstaticfinalCounterZGC_GC_PAUSE_TIMES=Counter.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_PAUSE_TIMES").labelNames("times").help("GC暂停次数").register();publicstaticfinalGaugeZGC_GC_CYCLE_TIME=Gauge.build().row("垃圾回收(分代ZGC)").name("ZGC_GC_CYCLE_TIME").labelNames("time").help("GC回收周期ms").register();publicstaticfinalCounterZGC_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主搜商列、App C2C商详推荐等核心商品列表页接口。一共压测3轮,每轮压测时长为10分钟,压测流量倍数分别为日常流量峰值(QPS)的2倍、4倍、8倍。5.2 压测数据汇总各轮次的压测数据如图20-22所示。第一行数据对应ZGC,第二行数据对应分代ZGC。图20 2倍日常峰值流量时,集群基础数据图21 4倍日常峰值流量时,集群基础数据图22 8倍日常峰值流量时,集群基础数据另附上:8倍日常流量峰值时,GC Allocation Stall、集群QPS、压测错误率、GC暂停时间监控附图,如图23-26所示。图23 8倍日常峰值流量时,GC Allocation Stall对比数据图24 8倍日常峰值流量时,集群QPS对比数据图25 8倍日常峰值流量时,压测错误率对比数据图26 8倍日常峰值流量时,GC暂停时间对比数据5.3 压测结论以日常流量峰值的8倍场景为例,详细数据为:CPU平均使用率:上涨20%最大内存使用率:基本不变,使用率为98%GC暂停时间:几乎无暂停,分代ZGC单次停顿时间不超过1ms,暂停QPS为2~3GC Allocation Stall次数:降低85%(638-->94次)QPS:提升15%(737-->842)TPAvg:降 500 ms(1300-->788ms)TP90:降低 300 ms(1963-->1660ms)TP99:降低 2.5 s(4473-->1967ms)错误比率降低了28个百分点(40.88%-->12.91%)综上,分代ZGC可提高资源利用率,更低的Allocation Stall次数,更高的集群QPS,更低的TP,更低的接口错误率,垃圾回收几乎没有停顿。至此,可全量使用JDK21分代ZGC。6 后续进展转转平台基础体验部的新媒体承接服务、商列服务都已经接入了JDK21,其中商列服务更是经历了2024年618的实战考验,服务非常稳定。其他核心服务之后陆续也会升级到JDK21。除了分代ZGC,借助JDK21的虚拟线程、结构化并发等新特性,将会带来更多新的可能。7 致谢感谢架构部、工程效率部、运维部在支持JDK21过程中付出的努力,使得JDK21能够顺利地应用在服务中。相信JDK21定会是下一个具有划时代意义的版本,通过本次JDK的升级,让我们保持在技术革命风口的最前沿。8 参考资料[1] Oracle Java SE Support Roadmap,2024,https://www.oracle.com/java/technologies/java-se-support-roadmap.html[2] Iris Clark,Stefan Karlsson.The Z Garbage Collector (ZGC),2023,https://wiki.openjdk.org/display/zgc/Main[3] The Z Garbage Collector,https://docs.oracle.com/en/java/javase/21/gctuning/z-garbage-collector.html[4] Erik sterlund.Generational ZGC and Beyond,2023,https://inside.java/2023/08/31/generational-zgc-and-beyond/[5] Garbage Collector Implementation,https://docs.oracle.com/en/java/javase/21/gctuning/garbage-collector-implementation.html[6] Stefan Karlsson,Erik Helin,Erik sterlund,Vladimir Kozlov.JEP 439: Generational ZGC,2023,https://openjdk.org/jeps/439[7] Deprecated API,https://docs.oracle.com/en/java/javase/21/docs/api/deprecated-list.html[8] 苑冲.JDK21 调研踩坑记录,2024[9] Andy Wilkinson.Spring Boot 2.0 Migration Guide,2021,https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.0-Migration-Guide作者张鹏程,来自转转集团-研发中心-平台基础体验后端团队,负责转转App后端开发工作。微信号:zpc_1994想了解更多转转公司的业务实践,欢迎点击关注下方公众号:
|
|