随着转转业务规模的不断增长,我们的搜索推荐服务正在面临严峻的垃圾回收(Garbage Colletion, GC)带来的服务接口耗时毛刺问题。
我们当前所使用的JDK1.8版本中的CMS和G1收集器,在应对请求高峰时均不理想,经常出现的停顿问题直接影响了服务的可用性及用户体验。
我们面临的核心挑战是:
为此,我们计划通过升级JDK版本来实现GC问题的改善。JDK新版本带来了如ZGC、Shenandoah等新一代GC算法,它们能够提供极低的GC停顿时间,有望解决我们的在线服务目前的GC毛刺问题。
我们的升级目标是利用新版本JDK中的新GC算法,将搜索推荐服务的GC停顿时间降低90%以上,保证高流量服务的可用性和吞吐量,进一步提升用户体验。
我们选择将JDK版本升级到JDK17,主要原因有:
具体来看,此次JDK 17的升级在语法上带来了以下几个值得注意的新特性:
类型推断
从JDK10版本开始,引入了局部变量类型推断(Local Variable Type Inference)功能,它可以让我们在声明局部变量时省略变量类型,而由编译器根据变量初始化的值自动推断出类型。
// 传统变量声明方法
String str = "hello";
// 使用类型推断的变量声明方法
var str = "hello";
Stream API的增强
JDK新版本对Stream API进行了一些增强,主要有:
takeWhile和dropWhile会对流中每个元素逐一校验,遇到第一个不符合条件的元素终止,takeWhile返回终止位置前面的所有元素,而dropWhile则返回包含终止位置后面的所有元素。它们功能虽然与filter类似,区别是前者并非对整个流进行校验,可以提升过滤效率,但需要注意流内元素的顺序。
var list = List.of(1,2,3,4,5,6);
// 输出:1,2;
list.stream().takeWhile(n -> n < 3).forEach(System.out::println);
// 输出:3,4,5,6
list.stream().dropWhile(n -> n < 3).forEach(System.out::println);
iterate可以生成一个无限的流,它在JDK9之前需要limit()等操作来配合终止,否则将无限递归下去。在JDK9中iterate新增了一个重载方法,现在支持使用条件来终止,它在语法上更简洁,也提供了更多的灵活性。
// 输出:1,2,4,8,...,512,1024
Stream.iterate(1, n -> n <= 1024, n -> n * 2).forEach(System.out::println);
集合API新增方法
操作集合将更加方便,如可以更加简洁的创建List和Map,但需要注意这种方式创建的集合均是不可变的。
var list = List.of(1, 2, 3, 4, 5);
var map1 = Map.of("a", 1, "b", 2);
var map2 = Map.ofEntries(Map.entry("a", 1), Map.entry("b", 2));
}
同时新增了多种Collector方法,如可以通过groupingBy新增的重载方法实现多级分组,假设Product类有Cate、Brand、Model成员,则可以做如下多层分组收集:
// 按Cate、Brand分组,收集Model列表
List<Product> products = new ArrayList<>();
Map<Cate, Map<Brand, List<Model>>> result = products.stream()
.collect(Collectors.groupingBy(Product::getCate,
Collectors.groupingBy(Product::getBrand,
Collectors.mapping(Product::getModel, Collectors.toList()))));
Swtich新语法
JDK12开始,switch语句增加了新的语法形式,允许使用更灵活的表达式匹配,并可以返回值,提升了代码的简洁性。
int month = ...;
String days = switch(month) {
case 1, 3, 5, 7, 8, 10, 12 -> "31 days";
case 4, 6, 9, 11 -> "30 days";
case 2 -> "28 or 29 days";
}
文本块
JDK13开始提供了一种新的字符串格式,用户可以选择用三个双引号(“”")作为字符串开头及结尾,直接编写多行文本,它为JSON、SQL等格式的字符串编写提升了简洁性和便利性。
String textBlock = """
This is a text block
spanning multiple lines.
""";
Record类型
JDK14中新增的Record提供了更简洁的语法来生成只用于数据存储的类,并自动生成访问方法、equals和hashCode比较方法以及toString方法,它可以在类内部或方法内部生成。它相比class类更轻量简洁,相比Pair、Triple等组合类Record的语义上更加明确、代码可读性更强。
void someMethod() {
record Product(long id, String category);
Product product = new Product(101L, "phone");
long productId = product.getId();
String productCategory = product.getCategory();
}
模式匹配新语法
JDK14版本引入了模式匹配新语法,避免了冗余的类型转换语句。
Object obj = ...;
if (obj instanceof String s) {
System.out.println("String: " + s.length());
} else if (obj instanceof Integer i) {
System.out.println("Integer: " + i);
} else {
System.out.println("Unknown object");
}
JDK17版本后,新的模式匹配方式也可以在Switch语句中使用了。
Object obj = ...;
switch(obj) {
case String s -> System.out.println("String: " + s.length());
case Integer i -> System.out.println("Integer: " + i);
default -> System.out.println("Unknown object");
}
密封类
密封类(Sealed Class)是JDK15引入的新特性,当使用sealed关键字修饰一个抽象类时,表示这个抽象类只允许指定的类来继承实现。
如下ProductField类只允许Cate、Brand、Model类继承,这种特性避免了意料外的类型扩展,提升了类型安全性。
sealed abstract class ProductField permits Cate, Brand, Model {
//...
}
此外,JDK新版本还有向量API等新特性和诸多改进等待我们探索发现。
ZGC介绍
ZGC在JDK11作为实验性的GC算法被引入时,最初的设计目标是实现10毫秒以内的最大停顿时间。在过去一段时间里,ZGC经过JDK版本的数次迭代,在JDK15中被宣布为可用于生产,目前据官方介绍已经可以实现亚毫秒级的最大停顿时间,且停顿时间不随堆内存、存活对象集合或GCRoot集合大小的增加而增加,它可以处理从8MB到16TB的大范围堆内存。
在官方介绍里,ZGC是并发的、基于区域的(Region-based)、压缩的(Compacting)、NUMA感知(NUMA-aware)的垃圾回收器。它主要使用了染色指针(Colored Pointor)和读屏障(Load Barriers)技术,并在新一代的JDK21版本中实现了分代回收,它的主要工作是在用户线程工作执行时完成的,这大大降低了GC对应用响应时间的影响。
使用如下JVM启动参数可以快速应用ZGC:
-XX:+UseZGC
Shenandoah GC介绍
Shenandoah GC是一种全新的低延迟垃圾收集器,在JDK 8的部分版本可用,从JDK 11版本正式引入,它通过读写屏障和并发标记技术,可以极大缩短GC时的应用程序停顿。
相比CMS、G1等算法,其停顿时间更短,支持超大内存,非常适合对响应时间敏感类型的服务。由于Shenandoah使用了读写屏障技术,虽然可能导致吞吐量略降,但总体来说是更有效的GC算法之一。
使用如下参数可以快速应用Shenandoah GC:
-XX:+UseShenandoahGC
JDK版本升级无需做太多代码改动,但要平滑过渡到新版本,也需要做充分准备和规划。本节将分享我们升级到JDK17的具体步骤,在此过程中遇到的问题及解决方法,以及对ZGC相关问题的分析。
安装JDK17
我们在本地测试时选择了Eclipse Temurin Build版本,根据官网介绍它是由基于OpenJDK的开源Java SE产生的构建版本,这里根据开发环境的机器配置下载并安装了jdk-17.0.7+7 macos aarch64版本。
调整IDE配置
在使用IntelliJ Idea开发环境时,可以在文件–项目结构配置中,将SDK选项调整到刚刚安装的JDK版本。
调整项目配置
由于我们的项目是Maven项目,需要选择POM文件,修改Maven的编译插件的source和target配置到17。
<properties>
<jdk.version>17jdk.version>
properties>
...
<plugins>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-compiler-pluginartifactId>
<configuration>
<source>${java.version}source>
<target>${java.version}target>
configuration>
plugin>
plugins>
部署测试
本地编译测试通过后,意味着可以到测试环境进行部署和验证了,验证内容包括全场景的功能验证、DIFF验证、压力性能测试等等(由于部署功能是由公司其他系统提供,不展开叙述)。
升级JDK17后,JVM启动参数需要调整,一些旧参数被废弃,同时增加新的参数,我们用于测试环境部署的参数为:
-Xms6g -Xmx6g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xss256k -XX:+UseZGC -XX:ParallelGCThreads=12
生产环境部署及效果数据回收
升级JDK后,回收线上服务的效果数据至关重要,我们主要关注服务延时表现、GC暂停表现以及内存消耗表现。
我们选择服务集群的50%节点来部署JDK17,另外50%节点保持JDK8不变作为对照组,分配节点时,保持各组的节点机器配置情况一致,将实验变量控制在仅JDK版本的切换上。
同时将服务访问的延时信息、GC暂停信息、堆内存使用信息上报到日志收集系统。由于前者的日志规模庞大,为了获取更精确的统计信息,通过上报到大数据平台并使用HiveSql分析,后两者则通过上报Promethues监控平台来实现实时信息收集。
在实际编译和部署的过程中,还可能会遇到各种各样的问题,下面我们对遇到的问题及解决方法做了一些梳理。
以下为编译期间遇到的一些问题:
非法字符引发的异常
Maven编译期间遇见如下报错:
[ERROR] Internal error: java.lang.IllegalArgumentException: Malformed \uxxxx encoding. -> [Help 1]
问题原因:这是Maven在加载一些配置文件时遇到了不兼容的编码字符导致的。本地Maven仓库路径下的resolver-status.properties文件中存在格式不正确的unicode编码字符,这些字符在JDK 17的字符串处理方式下无法解析。
解决方法:使用以下命令,递归删除本地仓库下所有的resolver-status.properties文件:
find ~/.m2/ -name resolver-status.properties -delete
包不存在引发的异常
编译器期间提示包不存在:
import javafx.util.Pair
问题原因:javafx等包在JDK新版本中被默认移除。
解决方法:可以使用apache.commons提供的Pair类替代,也可以手动引入被移除的依赖,其他被移除的类也可以通过类似的方法解决。
以下为部署期间遇到的问题:
JVM参数引发的异常
启动阶段可能遇到类似如下问题:
Unrecognized VM option 'UseGCLogFileRotation'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
问题原因:部分JVM参数在新版本不再兼容,导致不能识别。
解决方法:从启动参数里将不兼容的参数移除即可,同时寻找替代参数。
反射访问引发的异常
如以下日志所示,我们在初始化apollo配置中心组件时遇到了启动异常,从异常描述看是程序反射访问期间引起的。
java.lang.IllegalArgumentException: Cannot instantiate interface org.springframework.context.ApplicationContextInitializer : com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer
...
at com.bj58.spat.scf.server.bootstrap.Main.main(Main.java:27) [zzscf.server-2.7.12.jar:?]
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer]: Constructor threw exception; nested exception is com.ctrip.framework.apollo.exceptions.ApolloConfigException: [ARCH_APOLLO_CLIENT]Unable to load instance for com.ctrip.framework.apollo.spring.config.ConfigPropertySourceFactory!
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:154) ~[spring-beans-4.3.12.RELEASE.jar:4.3.12.RELEASE]
at org.springframework.boot.SpringApplication.createSpringFactoriesInstances(SpringApplication.java:409) ~[spring-boot-1.5.8.RELEASE.jar:1.5.8.RELEASE]
... 8 more
...
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @5e265ba4
at java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354) ~[?:?]
at java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297) ~[?:?]
at java.lang.reflect.Method.checkCanSetAccessible(Method.java:199) ~[?:?]
at java.lang.reflect.Method.setAccessible(Method.java:193) ~[?:?]
...
问题原因:新版本JDK引入了模块访问控制,跨模块时无法简单的直接通过反射访问了,上述异常是想要通过反射访问Java内部模块而抛出的
解决方法:对于此类问题,可以通过临时增加如下启动参数解决,也可以查阅依赖包的新版本,了解它们是否已对JDK新版本做出了适配
--add-opens java.base/java.lang=ALL-UNNAMED
java.base/java.lang是本次异常需要用到的模块参数,在解决此类异常时,需要根据实际要访问的模块名进行调整,以下为我们收集的一些启动参数,可以按需增加启动参数配置。
--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/java.security=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED --add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/jdk.internal.access=ALL-UNNAMED --add-opens java.base/jdk.internal.misc=ALL-UNNAMED
注解类型被默认移除引发的异常
启动过程中,发现抛出了如下空指针异常
Caused by: java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null
at java.base/java.net.URLEncoder.encode(URLEncoder.java:224)
at java.base/java.net.URLEncoder.encode(URLEncoder.java:196)
at com.bj58.zhuanzhuan.arch.service.manager.sdk.client.CallPermissionService.initUri(CallPermissionService.java:192)
at com.bj58.zhuanzhuan.arch.service.manager.sdk.client.CallPermissionService.(CallPermissionService.java:72)
at com.bj58.spat.scf.server.filter.BlackKeyRequestFilter.afterPropertiesSet(BlackKeyRequestFilter.java:120)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1687)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1624)
... 15 more
问题原因:分析调用链路后发现,问题发生在一个上下文类,它的init方法是通过@PostConstruct注解触发执行的,该注解在JDK新版本中被默认移除了,导致init方法未能执行
解决方法:短期可以通过手动引入以下依赖方式解决,长期同样可以查阅依赖包维护方的更新日志,或与维护方进行沟通,将依赖更新到已适配版本。
javax.annotation
javax.annotation-api
1.3.2
此外我们在发布到生产环境后,还遇到了以下问题:
ZGC的虚拟内存申请的疑问
我们的服务升级JDK并在实际生产环境部署后,同物理节点的其他服务曾出现了一次短暂的资源耗尽异常,当时我们怀疑导致问题原因之一是ZGC申请了过多的虚拟内存。
针对ZGC申请过多虚拟内存问题,我们经过排查发现,这并不是JDK17存在的问题,而是由ZGC自身的实现机制所导致的。ZGC通过染色指针和多重映射技术来实现高吞吐低延迟的GC。
为了实现染色指针,ZGC需要使用地址高位额外的bit来记录对象状态,所以需要的虚拟内存空间远高于实际堆大小。此外,以目前JDK17版本,它还会为不同状态的对象分配独立的虚拟内存,以实现并发回收,具体来说需要为remapped、marked0、marked1三种状态申请三份独立虚拟内存空间。
以4TB堆为例,ZGC需要4bit用于染色,所以需要4TB * 2^4 = 128TB虚拟内存。它还会为每种染色状态各自申请128TB空间。所以4TB堆最终会申请128TB * 3约等于384TB的虚拟内存。
本例中我们的服务实际使用6GB堆,通过与内存工具Native Memory Tracking输出结果比对,发现跟公式计算结果一致,ZGC申请了约300GB的虚拟内存,符合其技术实现的需要。
所以结论是ZGC申请虚拟内存并非JDK问题,是其特有的技术实现方式导致。
以下是我们在转转的通用推荐服务升级过程中,持续对比三个全天收集到的效果数据,我们设立50%节点升级到JDK17作为实验组,另50%节点不升级作为对照组。
首先看下服务的整体耗时数据,如下图所示,可以看到该服务升级JDK17后tp999及tp9999时间有显著降低。
整体耗时对比
通过新版本GC算法的引入,服务处理请求的尾部延时情况得到了改善,响应时间的毛刺问题明显减轻。
下表为详细数据:
指标/版本 | JDK8 | JDK17 | 降幅 |
---|---|---|---|
AVG耗时 | 22ms | 22ms | 持平 |
TP50耗时 | 11ms | 11ms | 持平 |
TP90耗时 | 57ms | 57ms | 持平 |
TP99耗时 | 149ms | 148ms | 0.67% |
TP999耗时 | 249ms | 242ms | 2.81% |
TP9999耗时 | 601ms | 458ms | 23.78% |
在分节点的指标对比上,我们发现应用JDK17的节点在tp999和tp9999这两个高延迟分位数指标上的表现更加平稳。
如下图所示,相比保持JDK8的对照组节点,升级到JDK17的实验组节点,其tp999和tp9999指标的变化曲线更加平坦。
对于GC数据,我们收集了服务晚间4小时JDK8和JDK17版本的GC停顿数据。JDK8统计了其Young GC的暂停时间,而JDK 17统计了ZGC Pause时间。
从下表可以明显看出,使用JDK17的ZGC算法后,GC停顿时长大幅减少。JDK8下YGC每分钟平均暂停时间为221ms,而JDK 17下的ZGC只有0.37ms,降幅高达99.83%。
指标/版本 | JDK8 | JDK17 | 降幅 |
---|---|---|---|
统计口径 | YGC时间 | ZGC Pause时间 | - |
总时长 | 106250ms | 221ms | 99.67% |
每分钟平均时长 | 355ms | 0.37ms | 99.83% |
停顿时间的降低不仅提高了服务的可用性,也使系统吞吐量获得大幅提升。
从下表统计数据可以看出,使用JDK 17后,相同堆空间配置下,实际堆内存占用有所降低,堆空间的利用效率得到提高。
在同为6G堆大小情况下,JDK 8堆占用平均为2.92G,占比48.7%;而JDK 17堆占用平均减少至2.42G,占比降至40.3%。堆内存占用比降低了17.2%。
这表明在不改变堆区设置的前提下,JDK 17可以提高堆空间的利用效率,降低内存占用,为系统留出更多可用内存空间,从而提高系统稳定性。
指标/版本 | JDK8 | JDK17 | 降幅 |
---|---|---|---|
堆空间申请 | 6G | 6G | - |
每分钟平均堆占用 | 2.922G | 2.419G | 17.20% |
每分钟平均堆占用比 | 48.70% | 40.32% | 17.20% |
另外ZGC提供了-XX:SoftMaxHeapSize参数,用于弹性调节堆空间的最大值,当堆大小未超出设定值时可以释放更多空闲内存。
截止至发文,服务已成功部署应用JDK17并平稳运行一月有余。通过本次升级,我们获得了显著的GC停顿时间和内存占用率的改善效果,有效解决了服务GC问题,进而降低了服务高分位延迟指标,充分验证了JDK17新版本GC算法的优势。
同时,我们也积累了语法改进、升级中跨部门协调、问题排查等方面的宝贵经验。升级过程中遇到了服务稳定性问题,也让我们意识到需要对新特性有更深入的理解,平稳地应用到生产环境。
后续我们将继续关注JDK新版本的特性改进,并逐步将搜索推荐核心服务完全升级到JDK17新版本,以获得更好的开发体验和服务运行效果。
关于作者
曾祥瑞,转转搜索推荐研发工程师
锐意进取,勇于试验,与时俱进。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~