背景
-
JDK21 已发布一段时间,是 JDK17 后的的又一个长期维护版本,支持了 Virtual Thread、CRaC 特性,并带来了新的分代 ZGC 算法
-
Spring Boot 3.2.1 (Runtime efficiency with Spring (today and tomorrow) )版本发布后,框架层面原生的支持了 Virtual Thread、CRaC 特性
同时在 ops-job 上应用积累经验,可在其他项目如 Apollo 、xxljob 上继续落地
ps:本次升级项目原依赖是 JDK17,Spring Boot 2.6.5
关键结果(收益)
-
在不影响程序逻辑情况下,大幅缩短启动时间
-
内存使用降低,性能更好
-
CPU 资源使用率降低(因 GC 导致的 CPU 使用降低)
升级改动
Maven 依赖调整
调整 Spring Boot 的依赖
org.springframework.boot
spring-boot-starter-parent
3.2.1
调整 Spring Cloud 、JDK 的依赖
21
2021.0.1
代码兼容性改动
Spring Boot3 升级是个比较大的变动,有很多的不兼容性。这里只记录 ops-job 这个项目遇到的问题。更多问题参考:Spring Boot 3.0 Migration Guide (官网升级指南必看)
1、包名变动
javax 更名为 jakarta,相关的资源都需要改动。比如:
@PostConstruct 注解包名拜改变路径:javax.annotation.PostConstruct 变到 jakarta.annotation.PostConstruct
2、Spring 的 @Bean
注解只能用于有返回值的方法。
比如下面代码,想在 Bean 初始化生命周期中运行一些逻辑,升级后就不支持了。被 @Bean
注解的方法需要有返回值了
@Bean
public void initSenTry() {
String dsn = xx
Sentry.init(options -> {
options.setDsn(dsn);
options.setEnvironment(env);
});
SentryAppender sentryAppender = new SentryAppender();
sentryAppender.setContext(ctx);
ThresholdFilter filter = new ThresholdFilter();
filter.setLevel(Level.ERROR.levelStr);
filter.start();
sentryAppender.addFilter(filter);
sentryAppender.start();
ctx.addTurboFilter(new TurboFilter() {
@Override
public FilterReply decide(Marker marker, ch.qos.logback.classic.Logger logger, Level level, String format, Object[] params, Throwable t) {
logger.addAppender(sentryAppender);
return FilterReply.NEUTRAL;
}
});
}
3、Spring 日志系统变化
slf4j 的 StaticLoggerBinder 类没有了,想要获取 LoggerContext 对象实现日志级别的动态调整,需要使用
LoggerFactory.getILoggerFactory 取而代之。比如:
private final LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory();
4、Apollo 功能受限
Apollo 配置中心的 @ApolloConfig
注解失效了(内部配置加载逻辑是正常的,不影响应用启动)。比如:
@ApolloConfig
private Config config;
原因是:Spring Boot 3 milestone 5 EnableAutoConfiguration spring.factories · Issue #32566 · spring-projects/spring-boot
从 Spring Boot 3 M5 开始,来自 spring.factories 文件的“org.springframework.boot.autoconfigure.EnableAutoConfiguration”自动配置注册不再起作用
基于此,预计有很多 start 都会受到影响,比如 mybatis-plus 、xxl-job
- 临时解决
在类路径下创建META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件,把 Apollo 的自动加载类放进去,如:
com.ctrip.framework.apollo.spring.boot.ApolloAutoConfiguration com.taptap.xxl.job.spring.XxlJobAutoConfiguration
其他有影响的框架,都可以复制下自动加载类,放到这个文件里
- 最终解决
可以尝试用最新版本,看官方是否解决了 Spring Boot3.x 的兼容性问题。
1、apollo-client : 升级到 2.1.0(apollo-client support spring boot 3.0 by nobodyiam · Pull Request #4 · apolloconfig/apollo-java )
2、xxl-job-spring-boot-starter : 更新到 2.3.2-183
问题记录
1、日志框架冲突
服务启动,会输出如下的日志
Standard Commons Logging discovery in action with spring-jcl: please remove commons-logging.jar from classpath in order to avoid potential conflicts
这个是 Spring Boot 的依赖冲突检查输出的,在 Maven 里排除 commons-logging.jar
就好了,如:
ru.yandex.clickhouse
clickhouse-jdbc
0.3.2
commons-logging
commons-logging
2、Server VM warning
当加载 agent 时,会出现如下的警告日志。
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
[otel.javaagent 2024-01-23 17:38:45:581 +0800] [main] INFO io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 1.12.1
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
可通过 JVM 参数 -Xshare:off 关闭 CDS 相关的逻辑
大功告成
如果一切顺利,启动成功后会在控制台看到如下输出
开启 Virtual Thread
启用并验证
通过如下配置,可以一键开启 spring 的 Virtual Thread 特性。
spring.threads.virtual.enabled=true
验证是否开启了
@RequestMapping("/")
@RestController
public class VirtualController {
@GetMapping("/test")
public String virtual() {
System.out.println(Thread.currentThread());
return "test";
}
}
上面代码将会在控制台输出
VirtualThread[#70,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1
性能测试
环境:Redis 为本地的实例
@RequestMapping("/")
@RestController
public class VirtualController {
final StringRedisTemplate redisTemplate;
public VirtualController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@GetMapping("/test")
public String virtual() {
redisTemplate.opsForValue().set("test", "test");
return redisTemplate.opsForValue().get("test");
}
}
我在本地通过 wrk 压测上面的代码 (读写了下 Redis ),发现在不做任何参数调优的情况下,结果如下
tomcat 默认的线程池配置:
server.tomcat.threads.max=200
server.tomcat.threads.min-spare=10
-
wrk -t80 -c100 -d 10s –latency http://127.0.0.1:8040/test
-
wrk -t150 -c200 -d 10s –latency http://127.0.0.1:8040/test
从两次测试结果可以看出,在不做任何优化的前提下:
-
低负载(-t80 -c100):性能相当,差别不大
-
高负载(-t150 -c200):Virtual threads 依然保持高性能,Platform threads 出现了性能下降的问题。且两者 QPS 差距非常明显,Virtual threads 比 Platform threads 多 19%~29% 的性能。
在 Platform threads 模式下,尝试调大 tomcat 的线程参数
server.tomcat.threads.max=300
server.tomcat.threads.min-spare=50
分别使用
-
wrk -t150 -c200 -d 10s –latency http://127.0.0.1:8040/test
-
wrk -t80 -c100 -d 10s –latency http://127.0.0.1:8040/test
压 Platform threads 的服务。
从 tomcat 调参后的测试结果看,至少我本地这个环境,这个场景没法在通过加大 threads 数加大性能了。也在一次印证了 Platform threads 模式下,负载高过一个临界值后,性能会下降。
三方测试
-
三方测试参考:虚拟线程原理及性能分析
开启 CRaC
说明(还不成熟)
CRaC 当前只是初步支持,有些场景:比如内存里维护了复杂状态的应用,可能会遇到问题,启用前请谨慎做好全场景的测试。
参考文档:Spring Boot Reference Documentation JVM Checkpoint Restore :: Spring Framework
启用并验证
集成步骤
-
1、JVM 层面(指定内存 dump 路径):启动时添加
-XX:CRaCCheckpointTo=PATH
参数,指定 CRaC 的输出加载路径 -
2、Spring 层面(找个合适的时机触发内存 dump):启动时添加
-Dspring.context.checkpoint=onRefresh
参数。该阶段启动时会自动创建检查点LifecycleProcessor.onRefresh
。此阶段完成后,所有非延迟初始化的单例都已实例化,并且InitializingBean#afterPropertiesSet
回调已被调用;但生命周期尚未开始,且ContextRefreshedEvent
尚未发布。 -
3、JVM层面(加载内存恢复状态):启动时添加
-XX:CRaCRestoreFrom=PATH
参数,指定加载的 CRaC 的路径
从集成步骤看,第 1、2 步应该都发生在 CI 阶段,且 Spring 的触发内存 dump 的意图很明显,等 onRefresh 完成后再出发,相当于初始 Bean 的时间就可以节省出来了。当然还可以使用 jcmd 指令触发 dump,比如:
jcmd target/example-spring-boot-0.0.1-SNAPSHOT.jar JDK.checkpoint
这个可以在任意时候触发,这就可以在 dump 前做完所有的预热逻辑,然后 dump 出来的状态就是性能峰值状态了
遇到的问题
1、JDK依赖问题
CRaC 特性依赖 JDK 特性支持,目前 openjdk 发行版只支持到 JDK17:Releases · CRaC/openjdk-builds 。如果在不支持的 JDK 下启用 CRaCCheckpointTo
,则会输出:
Unrecognized VM option 'CRaCCheckpointTo'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
CRaC 最早是 Azul 发起的一个项目,可以用 Azul 的社区发行版来验证 CRaC 特性,如:
docker image:azul/zulu-openjdk:21-jdk-crac
2、GC 算法问题
ZGC 算法下,不支持 CRaC,在 ZGC 启用时,会输出:
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
-XX:+UseZGC is currently unsupported for -XX:CRaCCheckpointTo.
为了验证 CRaC 功能,只好先移除 -XX:+UseZGC
3、打开的 FD & Socket 问题
CRaC 要求应用程序关闭所有打开的文件、网络连接等。在 Linux 上,这些内容表示为文件描述符。但是,可能很难更改应用程序以与检查点正确协调,例如,由于无法修改库中的代码。在这些情况下,CRaC 通过配置提供有限的处理。
理论上所有的资源都需要向 JVM 注册资源的 Checkpoint 前后的资源状态,Spring 内置的依赖都处理好了这一步,但是三方依赖,比如 Opentelemetry 、Apollo 等没有做这一步,就会出现一些异常。
- Opentelemetry 的问题
Suppressed: jdk.internal.crac.mirror.impl.CheckpointOpenFileException: FD fd=35 type=regular path=/tmp/opentelemetry-temp-jars2649059754140254392/jartqV3k80l.jar (deleted)
at java.base/jdk.internal.crac.mirror.Core.translateJVMExceptions(Core.java:114) ~[na:na]
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore1(Core.java:188) ~[na:na]
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore(Core.java:286) ~[na:na]
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore(Core.java:265) ~[na:na]
at jdk.crac/jdk.crac.Core.checkpointRestore(Core.java:72) ~[jdk.crac:na]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
at org.crac.Core$Compat.checkpointRestore(Core.java:141) ~[crac-1.4.0.jar!/:na]
... 17 common frames omitted
- Apollo
Suppressed: java.nio.channels.IllegalSelectorException
at java.base/sun.nio.ch.EPollSelectorImpl.beforeCheckpoint(EPollSelectorImpl.java:401)
at java.base/jdk.internal.crac.mirror.impl.AbstractContext.invokeBeforeCheckpoint(AbstractContext.java:43)
at java.base/jdk.internal.crac.mirror.impl.AbstractContext.beforeCheckpoint(AbstractContext.java:58)
at java.base/jdk.internal.crac.mirror.impl.BlockingOrderedContext.beforeCheckpoint(BlockingOrderedContext.java:64)
at java.base/jdk.internal.crac.mirror.impl.AbstractContext.invokeBeforeCheckpoint(AbstractContext.java:43)
at java.base/jdk.internal.crac.mirror.impl.AbstractContext.beforeCheckpoint(AbstractContext.java:58)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore1(Core.java:153)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore(Core.java:286)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestoreInternal(Core.java:299)
Suppressed: jdk.internal.crac.mirror.impl.CheckpointOpenSocketException: Socket[addr=apollo-config.dev.tapsvc.com/172.20.12.187,port=80,localport=47004]
at java.base/jdk.internal.crac.JDKSocketResourceBase.lambda$beforeCheckpoint$0(JDKSocketResourceBase.java:68)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore1(Core.java:169)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore(Core.java:286)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestoreInternal(Core.java:299)
解决
可通过 File Descriptor Policies来有限的处理。
新建文件 crac.yaml ,配置内容如下:
type: socket
localAddress: *
action: ignore
---
type: file
path: /opt
action: ignore
---
type: file
path: /tmp
action: ignore
---
type: pipe
action: ignore
在 Java 应用启动系统参数里设置 -Djdk.crac.resource-policies = /{path}/crac.yaml
4、遗留的问题
遗留了一个问题,怎么都处理不了。刚好这个问题我认识的一个好友(Apollo 的作者)也遇到了,issue 如下:
-
CheckpointOpenFileException occurred with spring boot log files · Issue #31680 · spring-projects/spring-framework
Suppressed: jdk.internal.crac.mirror.impl.CheckpointOpenResourceException: FD fd=12 type=unknown path=anon_inode:[eventpoll]
at java.base/jdk.internal.crac.mirror.Core.translateJVMExceptions(Core.java:117)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore1(Core.java:188)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore(Core.java:286)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestoreInternal(Core.java:299)
Suppressed: jdk.internal.crac.mirror.impl.CheckpointOpenResourceException: FD fd=13 type=unknown path=anon_inode:[eventfd]
at java.base/jdk.internal.crac.mirror.Core.translateJVMExceptions(Core.java:117)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore1(Core.java:188)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestore(Core.java:286)
at java.base/jdk.internal.crac.mirror.Core.checkpointRestoreInternal(Core.java:299)
关于CRaC 的结论
基于如下:
-
Openjdk 发行版并未全部覆盖支持,目前支持的最高版本是 JDK17。更高版本的支持只能用 Azul 的 JDK
-
当前还有非常多的集成问题,且大量第三方包并没有做适配,Azul 官方给的 resource-policies 解决方案还属于初步阶段
-
Spring Boot 3.2 也是初步支持,Issue 区有大量集成的问题
综上,CRaC 距离生产可用还有很长的路要长,至少,当前解决应用启动问题,预热峰值问题,GraalVM 的 Native 方案比 CRaC 要更成熟。当然,未来哪个方向会成为标准还不好说,CRaC 的优势是保留了 JIT 的优化。