Skip to content

Spring 6 / Spring Boot 3 新特性:AOT 编译、虚拟线程、HTTP Interface 实战

提出问题

2026 年,Spring Boot 3 和 Spring 6 已经发布一年半,你应该已经看到了不少关于"AOT 编译启动速度 100ms"、"虚拟线程上下文切换零开销"的吹嘘文章。但升级到 Spring Boot 3 + Java 21 之后,你的项目真的变好了吗?

现实是:AOT 编译在 CI 里编译失败,报了一堆 reflect-config.json 缺失;虚拟线程开了之后,看起来一切正常,但压测时出现了诡异的"线程挂死"问题;HTTP Interface 写起来很爽,但集成测试不知道怎么写。这些坑如果不提前踩过,线上翻车就是迟早的事。

本文不讲概念,只讲怎么用踩过的坑

分析问题

AOT 编译:启动快是真的,但编译慢也是真的

AOT 编译(Ahead-of-Time)通过 GraalVM native-image 把 Spring 应用编译成原生可执行文件。启动时间确实从秒级降到毫秒级——一个正常的 Spring Boot 3 应用,传统 JVM 启动大概 3-5 秒,AOT 编译后 100-200ms。内存占用也低了,从 300MB 降到 100MB 左右。

但代价是什么?编译时间

一个中等规模的 Spring Boot 应用(10 个模块、200+ Bean),GraalVM native-image 编译需要 5-10 分钟。CI 流水线里,这 5 分钟是实打实的等待。而且如果编译失败,排查问题的时间比写代码还长。

常见编译失败原因:

  1. 反射缺失——Spring 框架本身大量使用反射(@Autowired@Value、JPA 的 entity 映射),GraalVM 默认不知道哪些类会被反射调用,需要显式配置 hint 文件

  2. 动态代理失败——Spring Boot 3 默认改用 JDK 代理,但老项目可能还在用 CGLIB。AOT 模式下 CGLIB 不可用,必须显式切换

  3. 第三方库不兼容——很多库没有提供 GraalVM 适配,比如旧版 Jackson 的某些序列化器、MyBatis 的动态 SQL 解析

GraalVM 的解决方案是提供 hint 文件来告诉 native-image 哪些类需要反射、哪些需要序列化。Spring Boot 3 内置了 @RegisterReflectionForBinding 注解和自动生成 hints 的机制,但自动生成的覆盖范围有限,手写 hint 文件几乎是必修课。

一个典型的 reflect-config.json 长这样:

json
[
  {
    "name": "com.example.Order",
    "allDeclaredFields": true,
    "allDeclaredMethods": true,
    "allDeclaredConstructors": true
  },
  {
    "name": "com.example.OrderRepository",
    "methods": [
      {"name": "findById", "parameterTypes": ["java.lang.Long"]}
    ]
  }
]

Spring Boot 3 的官方做法是推荐用 @RegisterReflectionForBinding 注解来代替手写 JSON,但实际项目中,很多场景(比如第三方库的 entity)你改不了源码,只能手动维护 hint 文件。

生产建议:不要一股脑全量 AOT 编译。对非核心服务(比如内部工具、定时任务、批处理)做 AOT 试点,核心 API 服务等团队积累了足够经验再上。Spring 官方自己也说了:AOT 编译目前适合延迟敏感、内存受限的场景,比如 Serverless 函数、边缘计算、IoT 设备。

虚拟线程:IO 密集型的天选,CPU 密集型的大坑

Spring Boot 3.2+ 通过 spring.threads.virtual.enabled=true 一键开启虚拟线程,效果是:Tomcat 的请求处理线程池从平台线程池换成虚拟线程池。

虚拟线程是 JDK 21 的正式特性(Project Loom)。它的核心思想很简单:操作系统线程很贵(栈空间 1MB+),JVM 管理的虚拟线程很便宜(栈空间只有几 KB)。一个 16 核的机器,传统平台线程池配 200 个线程已经是极限,但虚拟线程可以轻松创建几万个。

什么时候该用虚拟线程?

java
// 这是虚拟线程的完美场景
@RestController
public class OrderController {
    @GetMapping("/orders/{id}")
    public Order getOrder(@PathVariable Long id) {
        // 3 次 IO 等待:数据库查询 + Redis 缓存 + 远程调用
        Order order = orderRepository.findById(id).orElseThrow();
        // 每个 IO 等待都会让线程阻塞,传统线程池的线程被浪费在等待上
        // 虚拟线程下,阻塞时 JVM 会把虚拟线程挂起,平台线程被释放去做其他事
        return order;
    }
}

IO 密集型场景,每个请求涉及多次网络调用、数据库查询、外部 API 调用,线程大部分时间在等待 IO 而非计算。传统线程池在这种场景下,线程数 = 并发数的上限,多出来的请求只能排队。虚拟线程下,每个请求分配一个虚拟线程,阻塞时自动 yield,平台线程数量不再是瓶颈。

什么时候不该用虚拟线程?

java
// 这是虚拟线程的错误场景
@RestController
public class CryptoController {
    @PostMapping("/encrypt")
    public String encrypt(@RequestBody String data) {
        // CPU 密集型计算:RSA 加密 + 哈希计算 + 多次迭代
        // 虚拟线程跑这个,得不到任何好处,因为不涉及阻塞
        // 反而因为虚拟线程的调度开销,性能比平台线程差
        return cryptoService.encrypt(data);
    }
}

CPU 密集型任务(加密、压缩、图像处理、复杂计算),虚拟线程不但没有优势,反而有额外调度开销。因为虚拟线程根本不阻塞,JVM 的 scheduler 不需要做上下文切换挂起,但虚拟线程本身的调度器介入就是额外开销。

虚拟线程的坑:synchronized 和 pinning

这是最容易被忽视的坑。当虚拟线程执行到 synchronized 块时,JVM 会钉住(pinning)底层平台线程,导致虚拟线程无法 yield。如果 synchronized 块内再发生阻塞,整个平台线程都被卡住。

java
// 坑:synchronized 内的阻塞会钉住平台线程
public class OrderService {
    private final Object lock = new Object();

    public void processOrder(Long orderId) {
        synchronized (lock) {  // 虚拟线程被钉住
            // 这里如果发生阻塞,平台线程也被阻塞
            Order order = orderRepository.findById(orderId).orElseThrow();
            // 其他虚拟线程无法复用到这个平台线程
        }
    }
}

解决方案:用 ReentrantLock 替代 synchronized,或者将 synchronized 块内的阻塞操作移到块外。

java
// 正确做法:用 ReentrantLock 替代 synchronized
public class OrderService {
    private final Lock lock = new ReentrantLock();

    public void processOrder(Long orderId) {
        lock.lock();  // 虚拟线程在此处阻塞时,可以 yield
        try {
            // 阻塞操作不会钉住平台线程
        } finally {
            lock.unlock();
        }
    }
}

生产建议:新项目直接用 Spring Boot 3.2+ + Java 21 + 虚拟线程,但要做一个全局的 synchronized 扫描,把 synchronized 全部换成 ReentrantLock。已有的老项目迁移,建议先灰度 10% 流量观察线程池行为和 GC 情况,别直接全量切。

HTTP Interface:声明式 HTTP 客户端,Feign 的平替还是下位?

Spring 6 引入的 HTTP Interface,本质上是让开发者用声明式接口来定义 HTTP 请求,类似 Feign 但不需要额外依赖。

java
// 1. 定义接口
@HttpExchange("/api/users")
public interface UserClient {
    @GetExchange("/{id}")
    User getUser(@PathVariable Long id);

    @PostExchange
    User createUser(@RequestBody User user);

    @GetExchange
    List<User> listUsers(@RequestParam int page, @RequestParam int size);
}

// 2. 创建客户端实例
@Configuration
public class ClientConfig {
    @Bean
    public UserClient userClient() {
        WebClient webClient = WebClient.builder()
            .baseUrl("https://api.example.com")
            .defaultHeader("Authorization", "Bearer " + getToken())
            .build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builder(WebClientAdapter.forClient(webClient))
            .build();
        return factory.createClient(UserClient.class);
    }
}

// 3. 注入使用
@Service
public class UserService {
    private final UserClient userClient;

    public UserService(UserClient userClient) {
        this.userClient = userClient;
    }

    public User getUser(Long id) {
        return userClient.getUser(id);
    }
}

和 Feign 的对比:

特性HTTP InterfaceFeign (OpenFeign)
额外依赖无(Spring Web 自带)spring-cloud-starter-openfeign
底层WebClient可切换(OkHttp、HttpClient)
负载均衡手动集成内置 Spring Cloud LoadBalancer
服务发现手动集成内置(Nacos、Eureka)
断路器可集成 Sentinel/Resilience4j
拦截器ExchangeFilterFunctionRequestInterceptor
重试手动实现可集成 spring-retry
响应式原生支持需额外配置

结论很清晰:HTTP Interface 适合不需要注册中心、不需要负载均衡的简单场景,比如调用第三方 API、内部工具服务。如果项目已经用了 Spring Cloud 全家桶,Feign 还是更成熟的选择。

集成测试怎么写?

java
@SpringBootTest
class UserClientTest {
    private UserClient userClient;

    @BeforeEach
    void setUp() {
        // 用 MockWebServer 模拟 HTTP 服务
        var mockServer = new MockWebServer();
        mockServer.start();

        WebClient webClient = WebClient.builder()
            .baseUrl(mockServer.url("/").toString())
            .build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
            .builder(WebClientAdapter.forClient(webClient))
            .build();
        userClient = factory.createClient(UserClient.class);

        // 模拟返回数据
        mockServer.enqueue(new MockResponse()
            .setBody("{\"id\": 1, \"name\": \"test\"}")
            .setHeader("Content-Type", "application/json"));
    }

    @Test
    void shouldGetUser() {
        User user = userClient.getUser(1L);
        assertThat(user.getId()).isEqualTo(1);
        assertThat(user.getName()).isEqualTo("test");
    }
}

okhttp3.mockwebserver 来模拟 HTTP 服务,不需要启动真实的外部服务,速度快、结果可控。

其他值得关注的特性

Problem Details (RFC 9457):Spring Boot 3 默认支持 RFC 9457 标准化的错误响应格式。以前异常返回的 JSON 五花八门,现在统一了:

json
// 传统格式
{
  "code": 400,
  "message": "参数校验失败",
  "data": null
}

// RFC 9457 格式(Spring Boot 3 默认)
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "参数校验失败: id 不能为空",
  "instance": "/api/users",
  "timestamp": "2026-07-20T14:00:00Z"
}

只需要在 @ExceptionHandler 中返回 ProblemDetail 对象:

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("参数校验失败");
        // 收集所有错误信息
        String detail = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining(", "));
        problem.setDetail(detail);
        return problem;
    }
}

总结

Spring 6 / Spring Boot 3 的四个核心新特性,各有各的适用场景和坑:

  • AOT 编译:适合 Serverless、边缘计算、IoT 等启动敏感场景,但不是万金油。项目初期别急着上,先用传统方式跑起来,稳定后再评估是否需要 AOT。记住 5-10 分钟的编译时间和 hint 文件维护成本。

  • 虚拟线程:IO 密集型的天选,一开就起飞。但必须扫清 synchronized 的路障,CPU 密集型任务不要碰。新项目推荐直接上,老项目建议灰度。

  • HTTP Interface:轻量级的声明式 HTTP 客户端,适合不需要注册中心的简单场景。项目已经用了 Spring Cloud 全家桶的,继续用 Feign。

  • Problem Details:推荐直接开,统一错误响应格式,前后端联调省心。不需要额外配置,天然支持。

三句话总结迁移策略:虚拟线程直接开,Problem Details 顺手打开,AOT 等半年再说,HTTP Interface 看场景。

参考资料:

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。