Skip to content

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
// 没了,异常堆栈不打印

两种解法:

  1. 实现 AsyncUncaughtExceptionHandler,在 AsyncConfigurer 里覆盖:
java
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
    return (ex, method, params) -> {
        log.error("Async method [{}] failed with params: {}", method.getName(), params, ex);
        // 补监控告警
    };
}
  1. 有返回值用 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。生产环境必须做三件事:

  1. 自定义线程池@AsyncThreadPoolTaskExecutor@ScheduledThreadPoolTaskScheduler,核心参数按业务压测结果配置
  2. 异常处理AsyncUncaughtExceptionHandler 兜底,有返回值的用 CompletableFuture 收异常
  3. 分布式兜底:多副本环境用 ShedLock 或独立调度中心

记住一条原则:Spring 的默认配置是让你跑起来的,不是让你上线用的。

参考

  • Spring Framework 文档:Task Execution and Scheduling
  • Spring Boot 3.x 虚拟线程支持
  • ShedLock GitHub Wiki

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1