1 为什么要升级JDK
2 为什么选择JDK21
3 分代ZGC简介
3.1 什么是分代ZGC
3.2 垃圾回收过程
3.3 分代ZGC调优方式
3.4 分代ZGC设计要点
4 接入分代ZGC与监控搭建
4.1 接入分代ZGC
4.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所示。
基于自身使用诉求,需要引入支持ZGC垃圾回收器的JDK,支持ZGC的JDK版本列表如图2所示。
综上可以考虑JDK17或JDK21。对于JDK17我们在线上接入并且压测过。当压测流量为日常峰值的4倍时,因内存回收速率跟不上应用申请内存速率,触发Allocation Stall(Allocation Stall是触发GC的一个原因,由于无充足可用内存导致,会引发应用线程停顿,类似Stop The World),进而引起应用线程等待直到可以重新申请新的内存,最终服务可用率下降。如图3所示,在压测的8分钟内出现396次Allocation Stall。
可以考虑通过增加内存以承载更大的流量,但这并不是一个长期可行的方案。JDK开发组在JEP 439中提出了分代ZGC。在相同的堆内存条件下,分代ZGC只需70%的内存,达到4倍的吞吐量,并且仍然可以保持停顿时间小于1ms,大幅降低了Allocation Stall。至此,我们选择了JDK21,开始了实践之路。
3 分代ZGC简介
3.1 什么是分代ZGC
ZGC是一个可伸缩的低延迟垃圾收集器,最高能支持TB级堆内存,能并发执行繁重任务,且不会让应用的暂停时间超过1ms。ZGC适用于要求低延迟的应用,暂停时间与所使用的堆大小无关。
分代ZGC是ZGC的一个实现版本,依据假说:应用中的大部分对象都是短生命周期的,被设计为分代,即:年轻代、老年代。相对ZGC,分代ZGC提高了应用吞吐率、降低了Allocation Stall频率、且依然能够保持对应用的暂停时间小于1ms。
3.2 垃圾回收过程
3.2.1 堆内存模型
分代ZGC将堆内存分为两个逻辑区域:年轻代、老年代,堆内存模型如图5所示。
当分配对象时,它首先会被分配到年轻代,如图6所示。若该对象经历过多次年轻代回收后依然存活,它将会被晋升到老年代,如图7所示。
在实际的内存分布中,年轻代、老年代会分布在不连续的内存区域,如图8所示。
3.2.2 分代ZGC回收阶段
回收一个代的阶段如图9所示,包含:垂直方向的GC暂停,以及水平方向的并发阶段。
(1)暂停点1:这是一个同步点,仅标识标记开始。
(2)并发阶段1:开始运行应用程序、并发标记获取对象是否可达,在并发标记的同时,对最近一次GC Cycle内的对象remapping(当我们获取对象引用时,分代ZGC的load barrier会检查对象引用,若对象引用过期,会生成新的对象引用,这个过程称为remapping)。
(3)暂停点2:这是也一个同步点,用于标识标记结束。
(4)并发阶段2:为疏散区域(Region)做准备工作、处理reference、类的卸载等。
(5)暂停点3:同样也一个同步点,用于标识将要移动对象。
(6)并发阶段3:移动对象,以便释放出连续的内存。
在分代ZGC各阶段(Phases)中,年轻代回收阶段、老年代回收阶段以及应用程序的运行完全是并发的,如图10所示。
分代ZGC将回收阶段划分为两类:Minor Collections和Major Collections以统一管理。
Minor Collection:该阶段只回收年轻代,访问年轻代以及老年代对象中指向年轻代对象的字段,访问他们的主要原因是:
(1)GC Marking Roots:这样的字段包含唯一引用,使年轻代Object Graph的一部分保持可达。GC必须将这些字段视为Object Graph的根,以确保所有存活的对象都被发现,并标记他们的存活状态。
(2)老年代中的陈旧指针:收集年轻代时会移动对象,这些对象的指针没有被立即更新。
老年代到年轻代的指针集合称为remembered set,包含了所有指向年轻代的指针。
Major Collection:该阶段期望回收整个堆,既访问年轻代,也访问老年代。和Minor Collection类似,找到GC Marking Roots,以及年轻代中指向老年代的Roots。当年轻代收集完之后,可以找到所有老年代中存活的对象。当估算到所有存活的对象之后,就可以移动对象、回收内存。
3.3 分代ZGC调优方式
分代ZGC在设计之初,希望是自适应的,且以最小化人工配置对其进行调优,大部分内容都由分代ZGC内部自动计算调整,唯一重要的、需要调优的参数只有最大堆内存,即:-Xmx。
堆内存的大小根据内存分配速率以及应用中的存活对象集大小决定。通常来说,提供的堆内存越大,分代ZGC的性能表现越好。
此前用到的很多参数项都不需要再设置,在分代ZGC中即使设置了这些参数也是无效的。例如:-Xmn、-XX:TenuringThrehold、-XX:InitiatingHeapOccupancyPercent、-XX:ConGCThreads等等。对于分代ZGC的其他调优点,例如:使用大页、使用透明大页等,详见:参考资料第3点。分代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倍。
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=50m
4.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通知过滤器
*/
public class InfoShowGCNotificationFilter implements NotificationFilter {
/**
* 是否启用通知
*/
@Override
public boolean isNotificationEnabled(Notification notification) {
boolean enable = GarbageCollectionNotificationInfo.GARBAGE_COLLECTION_NOTIFICATION.equals(notification.getType());
return enable;
}
}
/**
* GC监听器注册
*/
@Slf4j
@Component
public 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监听器
*/
@Slf4j
public 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所示。
5 性能测试
5.1 压测环境
JDK21的ZGC和JDK17的ZGC并无区别,为了验证其一致性,也压测过,篇幅原因不再赘述。本压测通过对比JDK21(21.0.2_13)的ZGC和分代ZGC,评估下ZGC在支持分代前/后的性能。环境配置信息如下:
每组有3个实例。
压测的接口为:App首页商列、App主搜商列、App C2C商详推荐等核心商品列表页接口。
一共压测3轮,每轮压测时长为10分钟,压测流量倍数分别为日常流量峰值(QPS)的2倍、4倍、8倍。
5.2 压测数据
汇总各轮次的压测数据如图20-22所示。第一行数据对应ZGC,第二行数据对应分代ZGC。
另附上:8倍日常流量峰值时,GC Allocation Stall、集群QPS、压测错误率、GC暂停时间监控附图,如图23-26所示。
5.3 压测结论
以日常流量峰值的8倍场景为例,详细数据为:
CPU平均使用率:上涨20% 最大内存使用率:基本不变,使用率为98% GC暂停时间:几乎无暂停,分代ZGC单次停顿时间不超过1ms,暂停QPS为2~3 GC 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
想了解更多转转公司的业务实践,欢迎点击关注下方公众号: