编辑
2026-10-25
学习记录
00

目录

一、版本矩阵
二、推荐升级路径
为什么不能一步到位
六步走
三、构建配置
Maven
Gradle
预览特性
四、Boot 3 的破坏性变化
1. 总览
2. javax → jakarta 对照
3. Security 6
4. 自动配置注册
5. 配置项迁移
6. JPA / Hibernate 6
7. 国内常用栈
8. 路径匹配
9. @ConstructorBinding
五、把新语法用在 Spring 里
1. record 做 DTO、配置、事件
2. sealed + switch 做事件分发
3. 文本块写 SQL 和测试 JSON
4. 模式匹配清理类型判断
5. Optional / Stream 的日常写法
六、虚拟线程实战
1. 开启
2. 直接使用 JDK API
3. 结构化并发(预览)
4. 适合与不适合
5. 常见坑
6. 怎么验证有没有效果
七、HTTP 客户端
八、JVM 参数与部署
九、测试
十、迁移踩坑清单
十一、行动清单
十二、结语

语言篇讲语法,运行时篇讲 JVM;这一篇讲工程落地。

Spring 项目的升级难点,通常不在 JDK 21 本身,而在两件事:Spring Boot 2.x → 3.x 的命名空间迁移,以及虚拟线程到底值不值得开、开了为什么可能没效果。

TL;DR

  • JDK 17 是 Spring Boot 3 的门票;虚拟线程要 Boot 3.2+。
  • 推荐路径:8 → 17(Boot 2.7 验证)→ Boot 3.x → 21,不要把 JDK 和框架放在同一个 PR 里升。
  • 最大的机械改动是 javax.* → jakarta.*;最大的认知改动是虚拟线程的适用边界。
  • 虚拟线程只提升 I/O 密集场景的并发承载;瓶颈会转移到连接池、下游和 CPU。

一、版本矩阵

Spring BootJava 兼容范围Spring Framework备注
2.78–215.3最后一个 javax 时代,可用于过渡验证
3.017–196.0javax → jakarta,AOT 基础
3.117–206.0过渡版本
3.217–216.1虚拟线程开关、RestClient、JdbcClient、CDS
3.317–226.1CDS 支持增强
3.417–236.2@MockitoBean、结构化日志
3.517–246.23.x 收尾

关键结论:

  • JDK 17 是 Spring Boot 3 的门票。
  • JDK 21 是虚拟线程和模式匹配的起点,但 Boot 3.2+ 才提供 spring.threads.virtual.enabled。
  • 如果你的项目还在 Boot 2.7,先把 JDK 升到 17 跑通,再升 Boot 3,不要同时动。

二、推荐升级路径

为什么不能一步到位

同时改 JDK 和 Spring Boot,出问题时你无法判断是运行时变化还是框架变化:

  • InaccessibleObjectException 可能是 JDK 强封装,也可能是某个库还没适配。
  • 启动失败可能是 javax 没换干净,也可能是配置键在 Boot 3 被重命名。
  • 接口 404 可能是 Security 规则变了,也可能是路径匹配语义变了。

分两步,每步都能独立回滚、独立压测。

六步走

  1. 盘点:jdeps、jdeprscan、依赖树、自定义 agent、字节码增强、反射调用、JAXB/Nashorn 使用点。
  2. 构建与 CI 先支持 17/21:本地可以多 JDK 并存,CI 镜像换到 Temurin 21。
  3. Boot 2.7 + JDK 17:跑全量测试,修 InaccessibleObjectException、Lombok/Mockito 版本、反射问题。这一步不引入 Boot 3 的变量。
  4. Boot 2.7 → Boot 3.x:用 OpenRewrite 批量处理 javax → jakarta,再用 spring-boot-properties-migrator 找失效配置。
  5. JDK 21 + 虚拟线程:先开开关压测,确认瓶颈不在 DB/下游,再逐步扩大范围。
  6. 新代码用新语法:record、sealed、switch 模式匹配先在新模块落地,老代码按需重构。

OpenRewrite 的配方按目标版本选,例如:

text
org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2

同系列还有 3.0、3.1、3.3、3.4 等版本配方,按你的目标版本选择。


三、构建配置

Maven

使用 spring-boot-starter-parent 时:

xml
<properties> <java.version>21</java.version> </properties> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <release>21</release> <parameters>true</parameters> </configuration> </plugin>
  • 用 release 而不是 source/target,可以避免编译时误用更高版本的 API。
  • -parameters 对 Spring 6 的参数名解析很重要;Boot 3 的 parent 默认已经处理,不用 parent 时要自己加。
  • 编译器插件建议用较新版本,老版本可能不认 21 的 class 文件。

Gradle

kotlin
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } tasks.withType<JavaCompile>().configureEach { options.release = 21 }

使用 toolchain 时,CI 机器上没有对应 JDK 会构建失败;要么预装,要么配置 Foojay resolver 自动下载。

预览特性

如果要用结构化并发这类预览 API,编译和测试都要加参数:

xml
<compilerArgs> <arg>--enable-preview</arg> </compilerArgs>
xml
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <argLine>--enable-preview</argLine> </configuration> </plugin>

运行时 JDK 必须和编译时一致,类文件版本是绑定的。预览特性不要上生产。


四、Boot 3 的破坏性变化

1. 总览

领域Boot 2.xBoot 3.x
命名空间javax.servlet、javax.persistence、javax.validationjakarta.*
Servlet 容器Tomcat 9Tomcat 10.1(Servlet 6)
ORMHibernate 5Hibernate 6
SecurityWebSecurityConfigurerAdapter、antMatchersSecurityFilterChain、requestMatchers
自动配置注册spring.factoriesAutoConfiguration.imports
路径匹配可选 AntPathMatcherPathPatternParser 语义
测试@MockBean@MockitoBean(3.4+)
JDK8+17+

2. javax → jakarta 对照

旧命名空间新命名空间
javax.servletjakarta.servlet
javax.persistencejakarta.persistence
javax.validationjakarta.validation
javax.annotationjakarta.annotation
javax.transactionjakarta.transaction
javax.mailjakarta.mail
javax.xml.bindjakarta.xml.bind

不要只改 import 就完事:

  • 依赖 jar 也必须是 Jakarta 版本,否则运行时会 ClassNotFoundException。
  • 第三方 jar 短期没有 Jakarta 版本,可以用 Eclipse Transformer 做字节码转换过渡。
  • 自研 starter 里的 spring.factories 要换成新文件,否则自动配置不会生效。

3. Security 6

java
// Boot 2 / Security 5 @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/api/public/**").permitAll() .anyRequest().authenticated() .and() .httpBasic(); } }
java
// Boot 3 / Security 6 @Configuration @EnableWebSecurity public class SecurityConfig { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated()) .httpBasic(Customizer.withDefaults()) .build(); } }

WebSecurityConfigurerAdapter 已移除,antMatchers 换成 requestMatchers,链式写法改成 Lambda DSL。

4. 自动配置注册

新文件:

text
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

如果你有自定义 starter,这一步不能漏;旧文件不会被读取。

5. 配置项迁移

临时加:

xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-properties-migrator</artifactId> <scope>runtime</scope> </dependency>

它会在启动时报告失效的配置键,迁移完成后删掉。

6. JPA / Hibernate 6

  • 实体注解换成 jakarta.persistence.*。
  • Hibernate 6 的方言、类型映射有调整;不要硬编码老版本方言类。
  • 以前用 @Type(type = "json") 的地方,很多要改成 @JdbcTypeCode(SqlTypes.JSON)。
  • 分页、批量插入、二级缓存的配置项要重新核对。
  • 实体继续用普通类,不要因为上了 JDK 21 就把实体改成 record。

7. 国内常用栈

确认 starter 的 Jakarta 版本:

  • MyBatis-Plus:mybatis-plus-spring-boot3-starter
  • Sa-Token:sa-token-spring-boot3-starter
  • Druid:druid-spring-boot-3-starter
  • springdoc-openapi:1.x → 2.x;Knife4j 用 4.x

不要直接把旧 starter 换个名字,里面可能还引用着 javax。

8. 路径匹配

PathPatternParser 的语义比 AntPathMatcher 严格:

  • ** 只能出现在模式末尾。
  • Spring 6 默认不再匹配末尾斜杠,/orders 和 /orders/ 不再等价。
  • 以前靠宽松匹配“碰巧能通”的接口,升级后可能 404。

检查一遍 Controller 映射和网关转发规则,不要依赖 Ant 的宽松行为。

9. @ConstructorBinding

Boot 3 里,单构造器的 @ConfigurationProperties 不再需要 @ConstructorBinding。多构造器场景仍然需要显式标注。


五、把新语法用在 Spring 里

语法细节见语言篇,这里只讲落地方式。

1. record 做 DTO、配置、事件

java
public record CreateOrderRequest( @NotBlank String sku, @Min(1) @Max(99) int qty) {} @PostMapping("/orders") public ResponseEntity<OrderView> create(@Valid @RequestBody CreateOrderRequest request) { var view = service.create(request); return ResponseEntity.created(URI.create("/orders/" + view.id())).body(view); }

@ConfigurationProperties 也可以直接用 record:

java
@ConfigurationProperties("app.payment") public record PaymentProperties(URI baseUrl, Duration timeout, int maxRetries) {}
java
@SpringBootApplication @ConfigurationPropertiesScan public class Application {}

注意:

  • Boot 3 自带的 Jackson 已支持 record;Boot 2.7 上也能用,但要注意 Jackson 版本。
  • record 组件上的校验注解需要 @Valid 触发。
  • 如果 record 作为 JPA 查询结果的投影,确认 Spring Data 的构造器映射能匹配参数顺序。

2. sealed + switch 做事件分发

java
public sealed interface OrderEvent { long orderId(); record Paid(long orderId, BigDecimal amount) implements OrderEvent {} record Cancelled(long orderId, String reason) implements OrderEvent {} }
java
@Component class OrderEventHandler { @EventListener void on(OrderEvent event) { var message = switch (event) { case OrderEvent.Paid p -> "已支付 " + p.amount(); case OrderEvent.Cancelled c -> "已取消:" + c.reason(); }; log.info("order {} {}", event.orderId(), message); } }

好处是:新增一种事件类型时,所有 switch 会在编译期报“分支未覆盖”,而不是等到线上才发现漏了逻辑。

如果 sealed DTO 需要 JSON 反序列化,通常仍然要配 @JsonTypeInfo / @JsonSubTypes,不要假设 Jackson 能自动推导所有子类型。

3. 文本块写 SQL 和测试 JSON

java
@Query(""" select o from Order o where o.status = :status and o.createdAt >= :from """) List<Order> findRecent(Status status, Instant from);
java
var body = """ { "sku": "A-1", "qty": 2 } """; mockMvc.perform(post("/orders") .contentType(MediaType.APPLICATION_JSON) .content(body)) .andExpect(status().isCreated());

文本块只解决可读性,不解决 SQL 注入。参数仍然要用绑定变量。

4. 模式匹配清理类型判断

java
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod method) { var rateLimit = method.getMethodAnnotation(RateLimit.class); if (rateLimit != null) { ... } } return true; }

5. Optional / Stream 的日常写法

java
var order = repository.findById(id) .orElseThrow(() -> new OrderNotFoundException(id)); var skus = orders.stream().map(Order::sku).toList();

orElseThrow() 无参版本抛 NoSuchElementException;需要 404 语义时,仍然抛自己的领域异常。


六、虚拟线程实战

1. 开启

yaml
spring: threads: virtual: enabled: true

要求:Spring Boot 3.2+、JDK 21+。

Boot 会为 Tomcat 请求处理和 applicationTaskExecutor 配置虚拟线程;@Async 默认也会跟着走,除非你自己定义了 executor。

@Scheduled 不受这个开关影响,仍然是独立的任务调度器。

2. 直接使用 JDK API

java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var futures = ids.stream() .map(id -> executor.submit(() -> client.fetch(id))) .toList(); for (var future : futures) { results.add(future.get()); } }

ExecutorService 从 JDK 19 起实现了 AutoCloseable,所以可以 try-with-resources。不要池化虚拟线程,每个任务一个就是正确用法。

3. 结构化并发(预览)

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var user = scope.fork(() -> userClient.get(userId)); var orders = scope.fork(() -> orderClient.list(userId)); scope.join(); scope.throwIfFailed(); return new UserProfile(user.get(), orders.get()); }

它解决的是“并发子任务的生命周期、取消和异常传播”,比裸 CompletableFuture 更容易推理。但这是预览 API,后续版本有调整,生产环境慎用。

4. 适合与不适合

适合 I/O 密集、阻塞等待多 的 Spring MVC 服务:查数据库、调下游 HTTP、读文件、等消息。线程在阻塞时会让出载体线程,同样的硬件可以承载更多并发请求。

不适合:

场景原因建议
CPU 密集计算载体线程数约等于 CPU 核数,虚拟线程不增加算力平台线程池 / 并行流 / 调整并行度
大量 synchronized 的旧库JDK 21 会 pin 住载体线程升 JDK 24(JEP 491)或改 ReentrantLock
需要背压的流式处理虚拟线程没有背压语义WebFlux / Reactor
依赖 ThreadLocal 缓存大对象每个虚拟线程一份,内存放大ScopedValue / 减小缓存
数据库连接池瓶颈线程不是瓶颈,连接才是调连接池、SQL、分库分表
长事务并发上来后连接被占用更久缩短事务、设置超时

还要注意:虚拟线程不会降低单个请求的延迟。它提升的是并发承载能力;单次调用该等多久还是等多久。

5. 常见坑

1. synchronized 导致 pinning

JDK 21 里,虚拟线程在 synchronized 块中阻塞会钉住载体线程。可以先用 JFR 的 jdk.VirtualThreadPinned 事件或诊断参数确认:

bash
-Djdk.tracePinnedThreads=full

这个参数只用于诊断,有性能开销,不要常开。把热点路径改成 ReentrantLock:

java
private final ReentrantLock lock = new ReentrantLock(); public void doWork() { lock.lock(); try { blockingCall(); } finally { lock.unlock(); } }

2. CompletableFuture 仍然跑在平台线程

java
// 使用 ForkJoinPool.commonPool,不是虚拟线程 CompletableFuture.supplyAsync(() -> remoteCall()); // 显式传入虚拟线程执行器 CompletableFuture.supplyAsync(() -> remoteCall(), executor);

3. 连接池不会自动变大

虚拟线程让请求线程变多,但 HikariCP 的连接数没变:

yaml
spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 3000

真正的并发上限仍然是连接池、下游限流和 CPU。虚拟线程只是把“线程等待”的成本降下来了。

4. Tomcat 线程数不再是主要闸门

开启虚拟线程后,server.tomcat.threads.max 的意义变小;但仍然要关注 server.tomcat.max-connections、连接池和下游容量。

5. @Async 自调用不生效

这是 Spring 代理机制的问题,和 JDK 无关,但升级时经常一起踩:

java
// 同类内部调用不会走代理 this.asyncMethod();

6. 自定义 executor 会覆盖 Boot 的默认配置

如果你自己定义了 AsyncConfigurer 或 TaskExecutor Bean,spring.threads.virtual.enabled 可能不会按预期生效。检查一下容器里的 applicationTaskExecutor 到底是谁。

7. ThreadLocal 内存放大

每个虚拟线程都有独立的 ThreadLocal。如果缓存了大对象,线程数一多内存就会上去。逐步迁移到 ScopedValue(JDK 21 预览),或者把缓存移到 request 之外。

8. WebFlux / Netty 不受影响

spring.threads.virtual.enabled 不会改变 Netty event loop。响应式栈继续用自己的调度模型,不要指望这个开关给 WebFlux 加速。

6. 怎么验证有没有效果

压测时至少看:

  • QPS、p50、p99、错误率
  • Hikari 的 pending / timeout 指标
  • CPU 使用率和负载
  • 堆内存、线程数、GC 次数
  • JFR 里的 jdk.VirtualThreadPinned 事件
  • 下游服务的限流和超时

如果 QPS 不涨,先看连接池和下游,不要继续加线程。如果 p99 变差,先看 pinning、锁竞争和 GC。


七、HTTP 客户端

Spring 6.1 / Boot 3.2+ 可以用 RestClient,并让它底层走 JDK HttpClient:

java
@Bean RestClient paymentClient(RestClient.Builder builder) { return builder .baseUrl("https://pay.example.com") .requestFactory(new JdkClientHttpRequestFactory()) .build(); }
java
var result = paymentClient.get() .uri("/orders/{id}", id) .retrieve() .body(OrderView.class);

同步阻塞调用配合虚拟线程很自然;如果是流式或高并发扇出,仍然优先 WebClient。


八、JVM 参数与部署

JDK 21 上比较稳的起点:

bash
-XX:+UseG1GC -XX:MaxRAMPercentage=70 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app -XX:StartFlightRecording=filename=app.jfr,settings=profile,dumponexit=true -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

低延迟服务压测后再换:

bash
-XX:+UseZGC -XX:+ZGenerational -XX:MaxRAMPercentage=70

容器里不要写 -Xmx 的绝对值,除非你非常确定内存限制;MaxRAMPercentage 更容易跟着规格走。

升级到 21 后可以顺带做 CDS:

bash
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar java -XX:SharedArchiveFile=app.jsa -jar app.jar

更完整的运行时参数和排查方式见运行时篇。


九、测试

Boot 3 默认 JUnit 5。Boot 3.4+ 用 @MockitoBean 替代 @MockBean:

java
@SpringBootTest class OrderApiTest { @Autowired MockMvc mvc; @MockitoBean PaymentClient payment; }

如果要用预览特性,记得给 Surefire 加 --enable-preview(见构建配置)。

集成测试建议用 Testcontainers 跑真实数据库,尤其是升级 Hibernate 6 之后;Mock 数据库很难发现方言、类型映射和 SQL 兼容性问题。


十、迁移踩坑清单

现象原因处理
ClassNotFoundException: javax.servlet...依赖还是 javax 版本换 Jakarta 版 starter;第三方 jar 用 Eclipse Transformer 过渡
NoClassDefFoundError: javax/xml/bind/...JDK 11 移除了 Java EE 模块显式加 jakarta.xml.bind 等依赖
InaccessibleObjectException强封装拒绝非法反射升级 CGLIB/Lombok/Byte Buddy/Netty;最后才 --add-opens
Lombok / MapStruct / Mockito 编译失败版本太老,不认新 class 文件Lombok 1.18.30+、MapStruct 1.5.5+、Mockito 5.x
配置项启动报错或静默失效Boot 3 改了配置键spring-boot-properties-migrator 扫一遍
接口突然 404PathPatternParser 语义、尾斜杠匹配变化检查映射,不要依赖 Ant 的宽松匹配
中文乱码JDK 18 默认 UTF-8,旧代码没指定编码所有读写显式 StandardCharsets.UTF_8;控制台单独设 stdout.encoding
开了虚拟线程但 QPS 不涨瓶颈在 DB/下游/CPU压测定位,不要继续加线程
虚拟线程下吞吐反而下降synchronized pinning、ThreadLocal 放大ReentrantLock、减少 ThreadLocal、升 JDK 24
CompletableFuture 还是平台线程默认 commonPool显式传 executor
@Async 没生效同类自调用、自定义 executor走代理调用,检查 executor
Native Image 启动失败反射/资源没注册RuntimeHints、@RegisterReflectionForBinding
生产开 --enable-preview 崩溃类文件绑定 JDK 版本预览特性只在实验分支使用
List.of 抛 NPE工厂方法不接受 null用 Arrays.asList 或先过滤 null
Stream.toList() 结果不能改返回不可变列表需要可变时用 collect(Collectors.toList())

还有一个容易被忽略的:不要在同一个 PR 里同时升 JDK 和 Spring Boot。出问题时你无法判断是运行时变化还是框架变化。分两步,每步都跑全量测试和一次压测。


十一、行动清单

第 1 天:盘点

  • jdeps --jdk-internals、jdeprscan 跑一遍
  • 列出使用 JAXB、Nashorn、sun.misc.Unsafe、自定义 agent 的模块
  • 确认 CI 镜像和本地 JDK 能切到 21

第 1 周:JDK 17 + Boot 2.7

  • 修反射、Lombok、Mockito、字节码增强
  • 全量测试通过,记录 GC 和 p99 基线

第 2–3 周:Boot 3.x

  • OpenRewrite 处理 javax → jakarta
  • Security 配置迁移
  • 自定义 starter 改 AutoConfiguration.imports
  • 配置项迁移
  • Hibernate 6 方言和类型映射核对

第 4 周:JDK 21 + 虚拟线程

  • 开 spring.threads.virtual.enabled
  • 压测对比 QPS、p99、内存、GC
  • 用 JFR 看 pinning
  • 确认连接池和下游限流

之后:渐进式采用新语法

  • 新模块用 record / sealed / switch 模式匹配
  • 老代码只在改动时顺手重构
  • 代码评审里约定:新增的封闭分支优先用 sealed + switch

十二、结语

从 JDK 8 到 21,Spring 项目真正需要带走的不是“13 个版本的功能列表”,而是三个能力:

  1. 用类型表达封闭分支:sealed + record + switch 模式匹配,让编译器帮你找漏掉的分支。
  2. 用数据载体替代模板代码:record 把 DTO、配置、事件从 getter/setter 里解放出来。
  3. 用虚拟线程匹配 I/O 密集的现实:Spring MVC 不需要改成 WebFlux,也能获得更高的并发承载能力。

但升级的终点不是版本号,而是让团队跑在一个受支持、可观测、能承载业务增长的运行时上。JDK 21 是目前性价比很高的落点;等 JDK 25 与 Spring Boot 4 的生态成熟,再平移过去,成本会低很多。

本系列共三篇:

  1. 语言篇:语法现代化与标准库
  2. 运行时篇:JPMS、GC、字符集与启动
  3. Spring 迁移篇:Boot 2.x → 3.x → 虚拟线程

本文作者:Weee

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!