主题
Spring 异步与定时任务:@Async、@Scheduled 的线程池与坑
提出问题
Spring 的 @Async 和 @Scheduled 是两把用得最多也最容易翻车的"简单刀"。很多团队的上线故事里都有这么一段:加了个 @Async 觉得异步了、性能好了,结果内存飙到 OOM;配了个 @Scheduled(cron="0 */5 * * * ?") 发现第一个任务没跑完第二个任务压根不执行——不是定时出问题了,是默认调度器只有一个线程,全堵住了。
问题出在 Spring 对这两个注解提供的默认线程池配置上。@Async 默认走 SimpleAsyncTaskExecutor,每次执行都 new 一个线程,没有复用,没有上限;@Scheduled 默认的 ScheduledTaskRegistrar 只配一个线程,所有定时任务排队等这一根线。这两个默认值在生产环境几乎等于定时炸弹。
分析问题
@Async 的线程池默认行为
在 @EnableAsync 之后,Spring 会找一个 TaskExecutor 类型的 bean 来执行异步方法。如果你没定义,它会走到 SimpleAsyncTaskExecutor——这个类的名字里带"Simple"不是谦虚,是真的简单:每次调用 execute() 都会 new Thread()。
java
// SimpleAsyncTaskExecutor.execute() 本质
public void execute(Runnable task, long startTimeout) {
this.doExecute(task);
}
protected void doExecute(Runnable task) {
// 每次 new 一个线程,没有池化,没有上限
Thread thread = new Thread(task);
thread.start();
}高并发下,线程数失控,直接打爆操作系统的线程上限。加上 @Async 通常用在 IO 操作(发邮件、写日志、调用外部 API),突发流量进来就是几千个线程同时创建,上下文切换和内存占用同时飙升。
正确做法:自定义线程池:
java
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(200);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("async-");
// 拒绝策略:生产环境建议用 CallerRunsPolicy 或补监控告警
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Override
public Executor getAsyncExecutor() {
return taskExecutor();
}
}@Async 的另一个坑:自调用失效
@Async 和 @Transactional 一样,依赖 AOP 代理。同一个类里 A 方法调用 B 方法,B 上的 @Async 不会生效——因为调用不经过代理对象。
java
@Service
public class OrderService {
public void createOrder() {
// 直接调用 processPayment → @Async 不生效!
processPayment(123L);
}
@Async
public void processPayment(Long orderId) {
// 异步处理支付
}
}解法:注入自己(@Autowired OrderService),或者把异步方法抽到另一个 Service 里。
@Async 异常处理:被吞掉的错误
@Async 方法无返回值时,抛出的异常会被默认的 SimpleAsyncUncaughtExceptionHandler 吃掉——日志里啥都看不到。
java
// 你看到的日志
// 2026-09-02 10:00:00.123 ERROR [async-1] ???: Unexpected error occurred invoking async method
// 没了,异常堆栈不打印两种解法:
- 实现 AsyncUncaughtExceptionHandler,在
AsyncConfigurer里覆盖:
java
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("Async method [{}] failed with params: {}", method.getName(), params, ex);
// 补监控告警
};
}- 有返回值用 CompletableFuture,异常可以拿到:
java
@Async
public CompletableFuture<String> processAsync() {
// 异常会封装在返回的 CompletableFuture 里
return CompletableFuture.completedFuture(result);
}@Scheduled 的单线程陷阱
java
@Component
public class StatsTask {
@Scheduled(cron = "0 0/5 * * * ?")
public void refreshStats() {
// 耗时操作,可能跑 10 分钟
}
@Scheduled(fixedRate = 10000)
public void healthCheck() {
// 本来应该每 10 秒跑一次,实际上要等 refreshStats 跑完
}
}@Scheduled 默认的 ScheduledTaskRegistrar 只创建一个 Executors.newSingleThreadScheduledExecutor()。所有定时任务共享这一根线,前一个堵了后一个全等。
看源码就知道了:
java
// ScheduledTaskRegistrar 的默认行为
protected void scheduleTasks() {
if (this.taskScheduler == null) {
this.localExecutor = Executors.newSingleThreadScheduledExecutor();
this.taskScheduler = new ConcurrentTaskScheduler(this.localExecutor);
}
// ...
}解法:自定义 TaskScheduler bean,给足线程:
java
@Configuration
public class SchedulerConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}fixedRate 和 fixedDelay 的区别
面试常问,写代码时也容易搞混:
- fixedRate:上次任务启动时间 + 间隔,不管任务跑多久,到点就启动下一个。如果任务执行时间超过间隔,线程池不够时后续任务会排队等。
- fixedDelay:上次任务结束后 + 间隔,保证任务不重叠执行。
java
@Scheduled(fixedRate = 5000) // 每 5 秒启动一次,不管上次跑完没
@Scheduled(fixedDelay = 5000) // 上次跑完后等 5 秒再启动
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨 3 点分布式下的重复执行
@Scheduled 是单机视角的——每个实例都会执行定时任务。如果部署了多副本,定时任务会跑 N 遍,写数据库的重复数据、发消息的重复通知。
最简单的解法:ShedLock。基于数据库/Redis 的分布式锁,保证同一时间只有一个实例执行任务。
java
@Scheduled(cron = "0 0 3 * * ?")
@SchedulerLock(name = "dailyStats", lockAtMostFor = "10m", lockAtLeastFor = "5m")
public void dailyStats() {
// 只在加锁成功的那台机器执行
}更重的场景(任务编排、失败重试、日志追踪)上 XXL-Job 或 Elastic-Job。
Boot 3 虚拟线程的影响
Spring Boot 3.2+ 支持虚拟线程,配置 spring.threads.virtual.enabled=true 后,@Async 的执行器自动切换为 VirtualThreadTaskExecutor——每个任务一个虚拟线程,不再池化。
IO 密集型场景吞吐量确实上去了,但有三件事得知道:
- 虚拟线程不适合 CPU 密集任务(不会释放载体线程)
synchronized锁膨胀会把虚拟线程 pin 在载体线程上,这时候虚拟线程就是个普通线程- 老代码用
ThreadLocal传上下文的话,虚拟线程每次创建新实例,值不共享,之前那一套 MDC 透传可能要改
yaml
spring:
threads:
virtual:
enabled: true总结
@Async 和 @Scheduled 的坑,本质上是 Spring 选择了"开箱即用"的默认值,但这些默认值只适合 Hello World。生产环境必须做三件事:
- 自定义线程池:
@Async用ThreadPoolTaskExecutor,@Scheduled用ThreadPoolTaskScheduler,核心参数按业务压测结果配置 - 异常处理:
AsyncUncaughtExceptionHandler兜底,有返回值的用CompletableFuture收异常 - 分布式兜底:多副本环境用 ShedLock 或独立调度中心
记住一条原则:Spring 的默认配置是让你跑起来的,不是让你上线用的。
参考
- Spring Framework 文档:Task Execution and Scheduling
- Spring Boot 3.x 虚拟线程支持
- ShedLock GitHub Wiki