17- [并发与调度] 异步任务和虚拟线程

17- [并发与调度] 异步任务和虚拟线程
Prorise5. [并发与调度] 异步任务和虚拟线程
摘要: 并非所有任务都需要在一次 HTTP 请求的生命周期内同步完成。本章我们将深入 Spring Boot 的并发编程模型,学习如何将耗时操作(如发送邮件、生成报表)异步化以提升用户体验。我们将掌握传统的 @Async
异步任务和 @Scheduled
定时任务,并重点探索 Spring Boot 3.2+ 对 JDK 21+ 虚拟线程 (Virtual Threads) 的革命性支持,体验新一代高并发编程的魅力。
开始本节前的准备工作:清理认证代码
为了让我们的学习焦点更集中,我们将暂时移除之前添加的 Token 认证逻辑。请按照以下清单,删除对应的文件和代码:
1. 删除以下文件:
- 文件路径:
demo-framework/src/main/java/com/example/demoframework/interceptor/AuthInterceptor.java
- 文件路径:
demo-framework/src/main/java/com/example/demoframework/config/SpringDocConfig.java
- 文件路径:
demo-system/src/main/java/com/example/demosystem/controller/AuthController.java
- 文件路径:
demo-system/src/main/java/com/example/demosystem/dto/auth/LoginDTO.java
2. 修改 WebConfig.java
:
打开 WebConfig.java
文件,删除或注释掉注册 AuthInterceptor
的相关代码。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/WebConfig.java
1 | package com.example.demoframework.config; |
完成以上清理工作后,请重新加载 Maven 项目,确保项目可以正常编译和启动。
5.1. 经典异步编程:@Async
与自定义线程池
5.1.1. 痛点分析与异步启用
场景故事 (痛点)
想象一下我们
UserService
中的saveUser
方法。在成功创建用户后,产品经理提出了一个新需求:需要给新用户发送一封欢迎邮件。如果我们直接在saveUser
方法里加入邮件发送逻辑,而这个邮件服务可能因为网络等原因需要耗时3秒,那么用户在前端点击“注册”按钮后,必须在页面上“转圈”等待3秒以上,才能收到“注册成功”的提示。这是一种糟糕的用户体验。
对于这类非核心、可延迟处理的耗时任务,最佳解决方案就是将其异步化:主线程(处理 HTTP 请求的线程)在触发邮件发送任务后,不等待其完成,而是立即返回响应,将真正的发送操作交给后台的另一个线程去处理。@Async
正是 Spring 为此提供的优雅实现。
第一步:启用异步 (@EnableAsync
)
首先,我们需要在项目中显式地开启对异步任务的支持。最佳实践是在 demo-framework
模块中创建一个专门的配置类。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/AsyncConfig.java
(新增)
1 | package com.example.demoframework.config; |
5.1.2. 实战:创建并调用异步方法
第二步:创建异步服务
我们在 demo-system
模块中创建一个专门处理通知的服务,并在其中定义一个异步方法。
文件路径: demo-system/src/main/java/com/example/demosystem/service/NotificationService.java
(新增)
1 | package com.example.demosystem.service; |
第三步:集成到业务流程
现在,我们在 UserServiceImpl
中调用这个新的异步方法。
文件路径: demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.java
(修改)
1 | // ... imports ... |
第四步:验证效果
- 重启
demo-admin
应用。 - 调用
POST /users
接口新增一个用户。 - 观察现象:
- API 响应: 您会发现接口几乎是立即返回了
201 Created
的成功响应。 - 控制台日志: 日志的打印顺序将完美地展示异步执行流程。
- API 响应: 您会发现接口几乎是立即返回了
1 | // Tomcat HTTP 线程(例如 TID:48)立即完成并返回响应 |
结论: @Async
成功地将耗时3秒的邮件发送任务从主请求流程中剥离,极大地优化了用户体验。
5.1.3. 生产级配置:自定义线程池
严重警告: Spring Boot 在默认情况下使用的 SimpleAsyncTaskExecutor
是一个极其危险的线程池。它不会复用线程,而是为每一个 @Async
调用都创建一个全新的线程。在高并发场景下,这将迅速耗尽服务器的内存和线程资源,导致应用崩溃。在生产环境中,必须自定义线程池!
我们在 AsyncConfig
中创建一个 ThreadPoolTaskExecutor
Bean 来覆盖默认配置。
文件路径: demo-framework/src/main/java/com/example/demoframework/config/AsyncConfig.java
(修改)
1 | package com.example.demoframework.config; |
重启应用并再次测试,您会发现异步任务日志中的线程名已经变成了 MyAsync-1
,证明我们的自定义线程池已成功生效。
5.1.4. [进阶] 处理异步方法的返回值
如果我们需要获取异步任务的执行结果怎么办?@Async
方法可以通过返回 java.util.concurrent.CompletableFuture;
的实现类来做到这一点。
实践步骤
- 修改
NotificationService
:
1 | package com.example.demosystem.service; |
5.1.4. [进阶] 处理异步方法的返回值
如果我们需要获取异步任务的执行结果怎么办?@Async
方法可以通过返回 java.util.concurrent.Future
的实现类来做到这一点。
实践步骤
- 修改
NotificationService
:
1 | // NotificationService.java |
- 创建测试 Controller:
文件路径: demo-system/src/main/java/com/example/demosystem/controller/AsyncTestController.java
(新增)
重要信息: 注意,为了方便测试,我们在SpringMVC定义的Token验证的
1 | package com.example.demosystem.controller; |
验证效果: 调用 GET /async-result
接口。您会观察到,浏览器会等待约2秒后才收到响应。控制台日志会显示,Controller 先打印了“调用完毕”,然后才在调用 thenApply()
后打印“成功获取结果”,这清晰地展示了主线程被阻塞以等待异步结果的过程。
5.2. 定时任务调度:@Scheduled
场景故事 (痛点)
随着应用的运行,数据库中的操作日志表越来越大,影响查询性能。运维团队希望我们能开发一个功能,在每天流量最低的凌晨3点,自动将30天前的旧日志数据迁移到归档表中。这就需要一个无需人工干预、能按预定时间自动触发的机制。
@Scheduled
注解正是 Spring 提供的解决此类需求的标准方案。
5.2.1. 核心语法与启用
在动手之前,我们首先需要掌握 @Scheduled
注解的几种核心调度策略。
属性 | 核心作用 | 计时基准 |
---|---|---|
fixedRate | 按固定速率执行 | 从上一次任务的开始时间计算 |
fixedDelay | 按固定延迟执行 | 从上一次任务的结束时间计算 |
cron | 按 Cron 表达式指定的时间点执行 | 由表达式定义(如每天凌晨3点) |
启用调度 (@EnableScheduling
)
与 @EnableAsync
类似,我们需要先在项目中开启对定时任务的支持。
文件路径: demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java
(修改)
1 | package com.example.demoadmin; |
5.2.2. 实战:创建定时任务
现在,我们在 demo-system
模块中创建一个专门存放定时任务的组件,并一次性实现多种调度策略。
文件路径: demo-system/src/main/java/com/example/demosystem/tasks/SystemTasks.java
(新增)
1 | package com.example.demosystem.tasks; |
5.2.3. 验证与对比
重启应用并观察控制台日志,您会清晰地看到 fixedRate
和 fixedDelay
的区别。
fixedRate
(固定速率) 验证
fixedRate
强调的是按固定频率触发。它以任务的上一次开始执行的时间点为基准,来计算下一次的开始时间。
1 | // 以下任务代码,其执行日志如下所示 |
1
2
3
4
5
6
FixedRate Task - 开始 @ 11:56:02
FixedRate Task - 结束 @ 11:56:04
FixedRate Task - 开始 @ 11:56:07 <-- 距离上一次【开始】正好 5 秒
FixedRate Task - 结束 @ 11:56:09
FixedRate Task - 开始 @ 11:56:12 <-- 距离上一次【开始】正好 5 秒
FixedRate Task - 结束 @ 11:56:14
fixedDelay
(固定延迟) 验证
fixedDelay
强调的是在上一次任务结束后,再等待一个固定的延迟时间。
1 | // 以下任务代码,其执行日志如下所示 |
1
2
3
4
5
6
FixedDelay Task - 开始 @ 11:56:02
FixedDelay Task - 结束 @ 11:56:04
FixedDelay Task - 开始 @ 11:56:07 <-- 距离上一次【结束】正好 3 秒
FixedDelay Task - 结束 @ 11:56:09
FixedDelay Task - 开始 @ 11:56:12 <-- 距离上一次【结束】正好 3 秒
FixedDelay Task - 结束 @ 11:56:14
总结: fixedRate
适合对执行频率要求严格的任务(如每分钟的心跳检测),而 fixedDelay
适合不希望任务并发执行、需要保证执行间隔的场景(如轮询数据库)。
5.3. [前沿] JDK 21+ 虚拟线程:新时代的并发模型
5.3.1. 痛点:传统平台线程的瓶颈分析
在 5.1
节,我们配置了自定义线程池,但其底层使用的仍是传统的 平台线程。平台线程是 Java 线程的经典实现,其核心特征是与操作系统内核线程存在一对一的直接映射关系。
这种 1:1 的映射模型带来了两个固有的限制:
- 资源成本高: 每个平台线程都对应一个操作系统内核线程。创建和管理内核线程对操作系统而言是高成本操作,需要分配独立的栈内存并涉及昂贵的上下文切换。
- 数量有限: 由于其资源消耗,一台服务器能同时有效运行的平台线程数量通常被限制在几百到几千的规模。
在 Spring Boot 的传统阻塞式 Web 模型中,每个 HTTP 请求在处理期间都会独占一个平台线程。如果该请求中包含一个耗时 200ms 的数据库查询,那么在这 200ms 的 I/O 等待期间,这个平台线程会进入阻塞 (Blocking) 状态。尽管此时 CPU 可能处于空闲,但该线程资源被完全占用,无法执行任何其他任务。
核心瓶颈
当大量并发请求涌入,特别是当这些请求大多是 I/O 密集型(如等待数据库、外部 API 响应)时,有限的平台线程池会迅速被占满。后续的请求不得不进入队列等待,导致应用程序的吞吐量(TPS)达到上限,无法进一步扩展。
解决方案:虚拟线程
虚拟线程 是 JDK 引入的一种轻量级线程实现,由 JVM 直接调度和管理,而非操作系统。
它与平台线程的核心区别在于,虚拟线程与操作系统内核线程之间不再是 1:1 的映射关系。相反,大量的虚拟线程可以运行在一个由少量平台线程构成的池(这些平台线程被称为载体线程, Carrier Threads)之上。
虚拟线程的革命性机制在于其处理阻塞 I/O 的方式:
当一个虚拟线程执行一个阻塞操作时,JVM 会自动将其挂起,并从其载体平台线程上**“卸载” (unmount)**。这使得该平台线程可以立即被释放,去执行其他准备就绪的虚拟线程。当 I/O 操作完成后,JVM 会将原来的虚拟线程重新调度到任意一个可用的载体线程上继续执行。
这种机制带来了显著优势:
- 创建成本极低: 虚拟线程是 JVM 管理的轻量级实体,本质上是 Java 堆上的对象,不直接消耗宝贵的操作系统线程资源。
- 支持海量并发: 由于成本低廉,单个 JVM 实例可以轻松创建和管理数百万个虚拟线程。
通过这种高效的协作和调度机制,极少数的平台线程便能支撑起海量虚拟线程的并发执行。对于 I/O 密集型应用而言,这意味着线程不再是瓶颈,应用的吞吐能力和资源利用率得到极大提升。
5.3.2. [实践] 一键开启:在 Spring Boot 中启用虚拟线程
Spring Boot 3.2+ 对 JDK 21+ 的虚拟线程提供了无与伦比的、一等公民级的支持。开启它,简单到令人难以置信。
前置条件:
- 确保您的项目使用 JDK 21 或更高版本。
- 确保您的 Spring Boot 版本为 3.2 或更高。
由于我们的设置版本是17,这里就不测试了,核心也只是一个配置项而已
1. 修改配置文件
我们只需在 application.yml
中添加一行配置。
文件路径: demo-admin/src/main/resources/application.yml
(修改)
1 | spring: |
就这么简单! 加上这行配置后,Spring Boot 会自动将内部的 Tomcat
Web 服务器切换到使用虚拟线程来处理每一个进来的 HTTP 请求。
5.3.3. 性能对比:虚拟线程 vs. 传统线程池的适用场景
既然虚拟线程如此强大且易于开启,是否意味着我们之前配置的 @Async
传统线程池就过时了呢?答案是:并非如此。它们是为解决不同问题而设计的。
对比维度 | 平台线程 (我们自定义的线程池) | 虚拟线程 (Spring Boot 自动管理) |
---|---|---|
核心本质 | 珍贵的操作系统内核线程的直接映射 | JVM 管理的、轻量级的用户态线程 |
资源成本 | 高 (创建和上下文切换开销大) | 极低 (几乎没有额外开销) |
最佳场景 | CPU 密集型任务 (如:复杂计算、图像处理、数据加密) | I/O 密集型任务 (如:等待数据库、调用外部API、读写文件) |
数量 | 有限 (通常几十到几百) | 海量 (可轻松达到数百万) |
使用方式 | 通过 @Async 注解,用于后台异步计算 | 通过配置开启,用于处理海量并发的 Web 请求 |
既然 Spring Boot 3.2+ 开启虚拟线程如此简单,那我们为 @Async
自定义的 ThreadPoolTaskExecutor
线程池还有存在的必要吗?
非常有必要!它们解决的是不同维度的问题。
虚拟线程 的核心优势是解决海量并发 I/O 密集型 任务的等待问题,比如成千上万的用户同时请求我们的 API。它的设计目标是“不阻塞平台线程”,从而提高系统吞吐量。
而我们为 @Async
自定义的 平台线程池,其核心优势是处理后台的 CPU 密集型 任务。这类任务需要持续占用 CPU 进行计算,我们通过一个固定大小(比如等于 CPU 核心数)的线程池来执行它们,可以避免频繁的线程创建和销毁,并防止过多的线程因争抢 CPU 而导致性能下降。
明白了。所以可以总结为:用虚拟线程处理前端进来的海量 I/O 请求,用平台线程池处理后台的重量级计算任务。
完全正确。这正是 Spring Boot 3.2+ 所倡导的现代并发编程模型,两者相得益彰,而非互相替代。
总结: 虚拟线程是 Java 和 Spring Boot 在高并发领域的一项革命性进步。它以极低的成本,极大地提升了传统 I/O 密集型应用的性能和可伸缩性。在您的下一个项目中,如果使用了 JDK 21+ 和 Spring Boot 3.2+,请毫不犹豫地开启它。