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 Benchmarks
3 分代ZGC简介
3.1 什么是分代ZGC
ZGC是一个可伸缩的低延迟垃圾收集器,最高能支持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以统一管理。
(1)GC Marking Roots:这样的字段包含唯一引用,使年轻代Object Graph的一部分保持可达。GC必须将这些字段视为Object Graph的根,以确保所有存活的对象都被发现,并标记他们的存活状态。
(2)老年代中的陈旧指针:收集年轻代时会移动对象,这些对象的指针没有被立即更新。
老年代到年轻代的指针集合称为remembered set,包含了所有指向年轻代的指针。
图11 Minor Collection
图12 Major Collection
3.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做多次映射。
新的染色指针数据结构,支持了更多的color bit(染色位)以支持实现更复杂的算法、扩大了对象地址的存储空间、规避了因使用multi-mapped memory导致的RSS统计为ZGC实际内存使用的3倍。
图14 分代ZGC load barrier染色指针地址结构
图15 分代ZGC store barrier染色指针地址结构
图16 ZGC load barrier染色指针地址结构
分代ZGC还有其他设计要点,帮助分代ZGC实现卓越的性能。简列如下,因篇幅限制、理论性较强,读者可以查看参考资料第6点JEP 439,获取更多技术细节。
4 接入分代ZGC与监控搭建
4.1 接入分代ZGC
接入分代ZGC的前提是要接入JDK21。
接入JDK21期间,你可能会遇到以下问题:
成功接入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=50m
4.2 分代ZGC监控搭建
GC的暂停时间、GC的频率、引发GC的原因等是衡量GC健康度的关键指标。因为分代ZGC的暂停时间极低,日常中主要关注:GC原因中的Allocation Stall频率即可。GC原因包括:
接下来我们看下监控搭建,通过实现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<GarbageCollectorMXBean> 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<String, MemoryUsage> gcBeforeMemoryInfo = gcInfo.getMemoryUsageBeforeGc(); Map<String, MemoryUsage> 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", "Compressed class Space", "CodeHeap 'profiled nmethods'", "CodeHeap 'non-profiled nmethods'", "CodeHeap 'non-nmethods'" } } catch (Exception e) { log.error("desc=上报分代ZGC监控数据异常 e=", e); } return; }}
Prometheus Collector定义如下:
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监控看板示例-2
5 性能测试
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倍场景为例,详细数据为:
综上,分代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后端开发工作。
来源-微信公众号:转转技术
出处:https://mp.weixin.qq.com/s/bDp_4EAZoaGdXLV17s4vjw