语言篇讲语法,运行时篇讲 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 Boot | Java 兼容范围 | Spring Framework | 备注 |
|---|---|---|---|
| 2.7 | 8–21 | 5.3 | 最后一个 javax 时代,可用于过渡验证 |
| 3.0 | 17–19 | 6.0 | javax → jakarta,AOT 基础 |
| 3.1 | 17–20 | 6.0 | 过渡版本 |
| 3.2 | 17–21 | 6.1 | 虚拟线程开关、RestClient、JdbcClient、CDS |
| 3.3 | 17–22 | 6.1 | CDS 支持增强 |
| 3.4 | 17–23 | 6.2 | @MockitoBean、结构化日志 |
| 3.5 | 17–24 | 6.2 | 3.x 收尾 |
关键结论:
spring.threads.virtual.enabled。同时改 JDK 和 Spring Boot,出问题时你无法判断是运行时变化还是框架变化:
InaccessibleObjectException 可能是 JDK 强封装,也可能是某个库还没适配。javax 没换干净,也可能是配置键在 Boot 3 被重命名。分两步,每步都能独立回滚、独立压测。
jdeps、jdeprscan、依赖树、自定义 agent、字节码增强、反射调用、JAXB/Nashorn 使用点。InaccessibleObjectException、Lombok/Mockito 版本、反射问题。这一步不引入 Boot 3 的变量。javax → jakarta,再用 spring-boot-properties-migrator 找失效配置。OpenRewrite 的配方按目标版本选,例如:
textorg.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2
同系列还有 3.0、3.1、3.3、3.4 等版本配方,按你的目标版本选择。
使用 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 时要自己加。kotlinjava {
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 2.x | Boot 3.x |
|---|---|---|
| 命名空间 | javax.servlet、javax.persistence、javax.validation | jakarta.* |
| Servlet 容器 | Tomcat 9 | Tomcat 10.1(Servlet 6) |
| ORM | Hibernate 5 | Hibernate 6 |
| Security | WebSecurityConfigurerAdapter、antMatchers | SecurityFilterChain、requestMatchers |
| 自动配置注册 | spring.factories | AutoConfiguration.imports |
| 路径匹配 | 可选 AntPathMatcher | PathPatternParser 语义 |
| 测试 | @MockBean | @MockitoBean(3.4+) |
| JDK | 8+ | 17+ |
javax → jakarta 对照| 旧命名空间 | 新命名空间 |
|---|---|
javax.servlet | jakarta.servlet |
javax.persistence | jakarta.persistence |
javax.validation | jakarta.validation |
javax.annotation | jakarta.annotation |
javax.transaction | jakarta.transaction |
javax.mail | jakarta.mail |
javax.xml.bind | jakarta.xml.bind |
不要只改 import 就完事:
ClassNotFoundException。spring.factories 要换成新文件,否则自动配置不会生效。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。
新文件:
textMETA-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
如果你有自定义 starter,这一步不能漏;旧文件不会被读取。
临时加:
xml<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-properties-migrator</artifactId>
<scope>runtime</scope>
</dependency>
它会在启动时报告失效的配置键,迁移完成后删掉。
jakarta.persistence.*。@Type(type = "json") 的地方,很多要改成 @JdbcTypeCode(SqlTypes.JSON)。确认 starter 的 Jakarta 版本:
mybatis-plus-spring-boot3-startersa-token-spring-boot3-starterdruid-spring-boot-3-starter不要直接把旧 starter 换个名字,里面可能还引用着 javax。
PathPatternParser 的语义比 AntPathMatcher 严格:
** 只能出现在模式末尾。/orders 和 /orders/ 不再等价。检查一遍 Controller 映射和网关转发规则,不要依赖 Ant 的宽松行为。
@ConstructorBindingBoot 3 里,单构造器的 @ConfigurationProperties 不再需要 @ConstructorBinding。多构造器场景仍然需要显式标注。
语法细节见语言篇,这里只讲落地方式。
javapublic 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 {}
注意:
@Valid 触发。javapublic 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 能自动推导所有子类型。
java@Query("""
select o from Order o
where o.status = :status
and o.createdAt >= :from
""")
List<Order> findRecent(Status status, Instant from);
javavar body = """
{
"sku": "A-1",
"qty": 2
}
""";
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated());
文本块只解决可读性,不解决 SQL 注入。参数仍然要用绑定变量。
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;
}
javavar order = repository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
var skus = orders.stream().map(Order::sku).toList();
orElseThrow() 无参版本抛 NoSuchElementException;需要 404 语义时,仍然抛自己的领域异常。
yamlspring:
threads:
virtual:
enabled: true
要求:Spring Boot 3.2+、JDK 21+。
Boot 会为 Tomcat 请求处理和 applicationTaskExecutor 配置虚拟线程;@Async 默认也会跟着走,除非你自己定义了 executor。
@Scheduled 不受这个开关影响,仍然是独立的任务调度器。
javatry (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。不要池化虚拟线程,每个任务一个就是正确用法。
javatry (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,后续版本有调整,生产环境慎用。
适合 I/O 密集、阻塞等待多 的 Spring MVC 服务:查数据库、调下游 HTTP、读文件、等消息。线程在阻塞时会让出载体线程,同样的硬件可以承载更多并发请求。
不适合:
| 场景 | 原因 | 建议 |
|---|---|---|
| CPU 密集计算 | 载体线程数约等于 CPU 核数,虚拟线程不增加算力 | 平台线程池 / 并行流 / 调整并行度 |
大量 synchronized 的旧库 | JDK 21 会 pin 住载体线程 | 升 JDK 24(JEP 491)或改 ReentrantLock |
| 需要背压的流式处理 | 虚拟线程没有背压语义 | WebFlux / Reactor |
依赖 ThreadLocal 缓存大对象 | 每个虚拟线程一份,内存放大 | ScopedValue / 减小缓存 |
| 数据库连接池瓶颈 | 线程不是瓶颈,连接才是 | 调连接池、SQL、分库分表 |
| 长事务 | 并发上来后连接被占用更久 | 缩短事务、设置超时 |
还要注意:虚拟线程不会降低单个请求的延迟。它提升的是并发承载能力;单次调用该等多久还是等多久。
1. synchronized 导致 pinning
JDK 21 里,虚拟线程在 synchronized 块中阻塞会钉住载体线程。可以先用 JFR 的 jdk.VirtualThreadPinned 事件或诊断参数确认:
bash-Djdk.tracePinnedThreads=full
这个参数只用于诊断,有性能开销,不要常开。把热点路径改成 ReentrantLock:
javaprivate 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 的连接数没变:
yamlspring:
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 加速。
压测时至少看:
jdk.VirtualThreadPinned 事件如果 QPS 不涨,先看连接池和下游,不要继续加线程。如果 p99 变差,先看 pinning、锁竞争和 GC。
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();
}
javavar result = paymentClient.get()
.uri("/orders/{id}", id)
.retrieve()
.body(OrderView.class);
同步阻塞调用配合虚拟线程很自然;如果是流式或高并发扇出,仍然优先 WebClient。
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:
bashjava -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 扫一遍 |
| 接口突然 404 | PathPatternParser 语义、尾斜杠匹配变化 | 检查映射,不要依赖 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 跑一遍sun.misc.Unsafe、自定义 agent 的模块第 1 周:JDK 17 + Boot 2.7
第 2–3 周:Boot 3.x
javax → jakartaAutoConfiguration.imports第 4 周:JDK 21 + 虚拟线程
spring.threads.virtual.enabled之后:渐进式采用新语法
从 JDK 8 到 21,Spring 项目真正需要带走的不是“13 个版本的功能列表”,而是三个能力:
但升级的终点不是版本号,而是让团队跑在一个受支持、可观测、能承载业务增长的运行时上。JDK 21 是目前性价比很高的落点;等 JDK 25 与 Spring Boot 4 的生态成熟,再平移过去,成本会低很多。
本系列共三篇:
本文作者:Weee
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!