外观
Spring Boot 3 升级避坑实录:从 JDK 17 到 21
为什么现在必须升级 Spring Boot 3
很多仍运行在 Spring Boot 2.x 的服务,表面上稳定,实际已经进入维护成本递增阶段:Spring Framework 5 的开源支持结束,大量生态库停止发布兼容补丁,JDK 8/11 也无法继续获得新的语言特性和性能优化。升级到 Spring Boot 3 + JDK 21,不只是把版本号改大,而是一次围绕 Jakarta EE 9+、Micrometer Tracing、Native Image 和虚拟线程的工程基线迁移。
典型收益可以概括为三类:第一,依赖和运行时进入长期支持区间;第二,JDK 21 的 ZGC、G1、虚拟线程和模式匹配降低并发服务成本;第三,Spring Boot 3 的 GraalVM AOT 能力为 Serverless、弹性扩容和低启动延迟场景提供新选择。
但升级不能直接修改 parent 版本后等待编译通过。实际项目中,最常见的阻塞点是 javax.* 包迁移、第三方中间件 Starter 不兼容、Spring Security 配置类失效、Lombok/ByteBuddy/Mockito 等字节码工具版本过低。建议按“依赖评估 → 编译改造 → 运行验证 → 性能观测 → 可选原生镜像”的路径推进。
升级方案对比:一次性大版本还是分层迁移
常见方案有两种。第一种是直接从 Spring Boot 2.7 跳到 3.2/3.3,并同步切换 JDK 21。优点是链路短,适合依赖简单、自动化测试完善的新服务;缺点是编译错误和运行时问题会集中暴露。第二种是先在 JDK 17 上升级到 Spring Boot 3.x,完成 Jakarta 包和第三方库替换,再单独切换 JDK 21 并验证虚拟线程。对于中大型业务系统,我更推荐第二种,因为它能把“框架行为变化”和“JDK 运行时变化”拆开定位。
如果当前仍在 Spring Boot 2.4 以下,建议先升到 2.7.x。2.7 同时支持较新的依赖坐标和迁移检查工具,可以利用 spring-boot-properties-migrator 先清理废弃配置,再进入 Boot 3。不要把多个跨度的升级塞进一个提交,否则回滚边界会非常模糊。
第一步:统一 Maven 依赖与 JDK 21 编译参数
下面是一份可直接运行的最小 pom.xml,使用 Spring Boot 3.3.x、Java 21,并显式加入虚拟线程所需的 Web 与 Actuator 依赖。
xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.example
<artifactId>boot3-jdk21-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 虚拟线程压测时观察 JVM、线程和内存指标 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>升级时优先让 Spring Boot parent 管理常用依赖版本,不要在业务模块里手动锁一堆旧版本。对内部二方库,需要检查其是否直接引用 javax.servlet、javax.persistence、javax.validation。只要这些类出现在公共方法签名里,调用方升级后就可能出现 NoClassDefFoundError。
第二步:完成 Jakarta EE 包名迁移
Spring Boot 3 基于 Spring Framework 6,最低要求 Jakarta EE 9+。包名规则非常机械:javax.servlet 变为 jakarta.servlet,javax.persistence 变为 jakarta.persistence,javax.validation 变为 jakarta.validation。注意不是所有 javax.* 都迁移,例如 javax.crypto、javax.net.ssl 仍属于 JDK,不能批量替换。
推荐使用 OpenRewrite 或 IDE 的结构化替换,先改源码,再通过 mvn dependency:tree 检查残留依赖。一个标准 Controller 应写成如下形式:
java
package com.example.demo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
class OrderController {
@PostMapping("/api/orders")
@ResponseStatus(HttpStatus.ACCEPTED)
OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
// 这里只演示 Boot 3 + Jakarta Validation 的入口签名
return new OrderResponse(System.currentTimeMillis(), request.itemName());
}
record CreateOrderRequest(@NotBlank String itemName, int quantity) {}
record OrderResponse(long acceptedAt, String itemName) {}
}如果项目使用 MyBatis、Quartz、Druid、Swagger、MapStruct、Lombok 等生态组件,要提前确认兼容版本。例如 Springfox 基本不再适配 Boot 3,应迁移到 springdoc-openapi-starter-webmvc-ui;Hibernate 5 升级到 Hibernate 6 后,SQL 函数、隐式关联和日期类型处理都可能变化,必须对核心 SQL 做执行计划和结果集对比。
第三步:迁移配置并启用 JDK 21 虚拟线程
Spring Boot 3 可以通过一个配置开启虚拟线程。它适合 Tomcat 请求处理、JDBC 阻塞调用、HTTP Client、Redis 访问等 IO 密集场景,但不适合用 synchronized 长时间包住外部调用,因为 JDK 21 中这种模式仍可能造成载体线程钉住。
yaml
server:
port: 8080
spring:
threads:
virtual:
enabled: true # Tomcat 请求处理与部分异步任务使用虚拟线程
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
show-details: when_authorized启动类保持简洁即可:
java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}如果要确认请求是否跑在虚拟线程上,可以临时增加一个调试接口:
java
package com.example.demo;
import java.time.Duration;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ThreadInspectController {
@GetMapping("/api/thread")
String currentThread() throws InterruptedException {
// 模拟一次下游 IO 等待,压测时观察线程数与内存占用
Thread.sleep(Duration.ofMillis(100));
Thread current = Thread.currentThread();
return "thread=%s, virtual=%s%n".formatted(current.getName(), current.isVirtual());
}
}在 IO 等待较多的接口上,虚拟线程通常能显著降低平台线程数量,提升并发承载。但数据库连接池仍是硬上限,不要误以为开启虚拟线程后就能无限访问数据库。连接池、限流和下游超时仍要按容量配置。
第四步:处理 Spring Security 与可观测性变化
Spring Security 6 大量使用 Lambda DSL,旧的 authorizeRequests()、WebSecurityConfigurerAdapter 已移除。最小配置如下:
java
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/api/orders").permitAll()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}链路追踪方面,Spring Cloud Sleuth 不再适配 Boot 3,应迁移到 Micrometer Tracing,并根据后端选择 OpenTelemetry 或 Brave。升级后要核对 traceId 是否透传、日志格式是否变化、异步线程池和定时任务是否会丢上下文。
可选验证:GraalVM Native Image 的边界
Spring Boot 3 的 AOT 和 GraalVM Native Image 能把启动时间从秒级压缩到几十毫秒级,内存峰值也可能下降,适合函数计算、CLI 和短时任务。但普通业务服务不要把它当作升级必选项。反射、动态代理、资源加载、SPI、表达式引擎和 CGLIB 都可能需要额外 reflect-config.json、resource-config.json 或 @RegisterReflectionForBinding。
如果要试验,可增加 native profile 后执行:
bash
mvn -Pnative native:compile
./target/boot3-jdk21-demo验证重点不是“能否打包”,而是启动后关键接口、JSON 序列化、数据库驱动、缓存客户端和配置解密是否全部可用。Native Image 的编译时间长、构建环境要求高,建议先在独立服务试点,不要在核心交易链路上全量铺开。
实测收益与上线建议
在一个以 HTTP 聚合、Redis 和 JDBC 查询为主的后端服务中,我们先完成 Boot 3.3 + JDK 17 迁移,再切换 JDK 21 并开启虚拟线程。接口平均响应时间变化不大,但 800 并发下 Tomcat 工作线程从约 200 个平台线程降低为少量载体线程加大量虚拟线程,扩容时冷启动和线程调度压力更小。Native 试点服务启动时间约从 2.4 秒降到 0.08 秒,但构建时间明显增加,因此只用于弹性任务节点。
上线时建议按流量灰度:先关闭虚拟线程发布 Boot 3 版本,观察一周 GC、类加载、SQL 耗时和错误率;再开启虚拟线程发布第二版本;最后选择低风险服务验证 Native。这样每次发布都能独立回滚,避免把收益和风险绑在一起。
避坑总结
不要全量机械替换
javax.*。错误现象是编译期提示package javax.crypto does not exist或安全工具类异常。修正方法是只迁移 Jakarta 管理的 Servlet、Validation、Persistence、Annotation 等包,JDK 自带加密、网络包保持javax.*不变。不要只改 Spring Boot parent,忽略字节码工具。典型报错是
Unsupported class file major version 65、Lombok 无法生成 getter、Mockito 无法 mock final 类。应升级 Lombok 1.18.30+、ByteBuddy 1.14+、Mockito 5.x、JaCoCo 0.8.11+,并让插件版本与 JDK 21 对齐。虚拟线程不是无限并发开关。错误现象是开启后数据库连接等待升高、接口超时增加、CPU 没打满但 RT 变长。原因通常是请求并发远大于连接池容量。修正方法是保留 HikariCP 最大连接数、下游线程隔离和信号量限流,并把长时间
synchronized块改为ReentrantLock或缩小临界区。Spring Security 配置不能照搬旧代码。常见异常是启动时报
WebSecurityConfigurerAdapter找不到,或所有接口意外返回 401。应使用SecurityFilterChainBean,明确放行 actuator、登录和内部回调路径,并通过集成测试覆盖未登录、已登录、越权三类请求。Native Image 下动态能力最容易运行时才失败。现象是 JVM 模式正常,原生镜像启动后 JSON 反序列化报
ClassNotFoundException,或资源文件读取为空。修正方法是为 DTO、枚举和动态加载类补充反射注册,把资源路径加入 AOT 配置,并在 CI 中直接运行原生产物的契约测试。
总体来看,Spring Boot 3 + JDK 21 的升级收益明确,但它更像一次工程基线治理。只要控制好依赖兼容、Jakarta 迁移、安全配置和虚拟线程边界,再用灰度发布与可观测指标兜底,就能把一次看似高风险的大版本升级,拆成可验证、可回滚、可持续的平台演进。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。