JDK21(21.0.2_13)分代ZGC在转转商列服务中的实践

学术   2024-08-23 18:40   北京  
  • 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所示。

图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以统一管理。

  • Minor Collection:该阶段只回收年轻代,访问年轻代以及老年代对象中指向年轻代对象的字段,访问他们的主要原因是:

(1)GC Marking Roots:这样的字段包含唯一引用,使年轻代Object Graph的一部分保持可达。GC必须将这些字段视为Object Graph的根,以确保所有存活的对象都被发现,并标记他们的存活状态。

(2)老年代中的陈旧指针:收集年轻代时会移动对象,这些对象的指针没有被立即更新。

老年代到年轻代的指针集合称为remembered set,包含了所有指向年轻代的指针。

图11 Minor Collection
  • Major Collection:该阶段期望回收整个堆,既访问年轻代,也访问老年代。和Minor Collection类似,找到GC Marking Roots,以及年轻代中指向老年代的Roots。当年轻代收集完之后,可以找到所有老年代中存活的对象。当估算到所有存活的对象之后,就可以移动对象、回收内存。
图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做多次映射。

  • 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=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所示。

图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倍场景为例,详细数据为:

  • 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

想了解更多转转公司的业务实践,欢迎点击关注下方公众号:

转转技术
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。 各种干货实践,欢迎交流分享,如有问题可随时联系 waterystone ~
 最新文章