拒绝黑盒!Spring @Scheduled 定时任务源码深度解析

摘要 :你真的了解那个每天在项目中默默工作的 @Scheduled 吗?本文基于 Spring Boot 3.4.3 (Spring Framework 6.2) 源码,扒开 Spring 定时任务的"外衣"。从注解扫描到 JDK 线程池底层,再到 虚拟线程 (Virtual Threads) 的最新支持,带你深入理解其运作机制,并奉上生产环境的避坑指南。


🧐 前言:一个生产事故的思考

在 Spring Boot 项目中,写一个定时任务简直太简单了:

java 复制代码
@Scheduled(cron = "0 0/5 * * * ?")
public void doSomething() {
    // 也就加个注解的事儿
}

但正是因为太简单,很多开发者往往忽略了其背后的执行模型

  • "为什么我的两个定时任务不会同时执行?"
  • "为什么这个任务报错后,下次就不跑了?"
  • "Spring 6 引入的虚拟线程对定时任务有啥影响?"

如果你对这些问题感到模棱两可,那么这篇文章就是为你准备的。我们将从源码层面,彻底搞懂 Spring 定时任务的来龙去脉。


🚀 第一部分:万物起源 ------ 启动与扫描

一切的魔法,都始于 @EnableScheduling

1.1 入口:@EnableScheduling

这个注解看似只是个开关,实际上它干了一件大事:导入配置类

java 复制代码
@Import(SchedulingConfiguration.class)
public @interface EnableScheduling { ... }

SchedulingConfiguration 并没有做太复杂的逻辑,它只是注册了一个核心的 Bean 后置处理器:ScheduledAnnotationBeanPostProcessor

划重点 :Spring 的很多魔法(AOP、事务、异步)都是通过 BeanPostProcessor 实现的,定时任务也不例外。

1.2 核心引擎:ScheduledAnnotationBeanPostProcessor

这个类是整个定时任务的"大管家"。它在 Spring 容器启动时,默默地扫描了所有的 Bean。我们来看看它的核心方法 postProcessAfterInitialization 到底做了什么:

源码深度解读 (postProcessAfterInitialization)

这个方法运行在每个 Bean 初始化之后,其核心逻辑可以凝练为以下几个步骤:

  1. 过滤无关 Bean:首先排除掉基础设施类(AOP 代理类、调度器本身等),避免死循环或无效扫描。
  2. 获取真实类型 :因为 Bean 可能被 AOP 代理(比如加了 @Transactional),所以必须用 AopProxyUtils.ultimateTargetClass(bean) 拿到原始类,否则注解就扫描不到了。
  3. 双重检查与扫描
    • 缓存检查 :先看缓存 (nonAnnotatedClasses) 里有没有这个类,如果有,说明之前扫过且没注解,直接跳过(性能优化)。
    • 方法内省 :利用 MethodIntrospector 遍历该类的所有方法,寻找带有 @Scheduled@Schedules 的方法。
  4. 任务注册与生命周期绑定
    • 如果找到了注解方法,就调用 processScheduled,将这些方法包装成任务,注册到 ScheduledTaskRegistrar 中。
    • 关键防护 :Spring 会检查该 Bean 是否为单例 (Singleton) 。如果是,它会将该 Bean 加入到 manualCancellationOnContextClose 列表中。这步操作至关重要 ------ 它确保了当 Spring 容器关闭时,这些定时任务能被优雅地取消,防止容器已死、任务未停的"僵尸任务"出现。

核心伪代码逻辑

java 复制代码
public Object postProcessAfterInitialization(Object bean, String beanName) {
    // 1. 【前置过滤】如果是 Spring 内部的基础设施 Bean (如 TaskScheduler),直接跳过
    if (isInfrastructureBean(bean)) return bean;

    // 2. 【获取本尊】剥去 AOP 代理的外壳,拿到最底层的原始类
    Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);

    // 3. 【缓存判定】如果这个类之前已经被标记为"无注解",直接放行
    if (this.nonAnnotatedClasses.contains(targetClass)) return bean;
    
    // 4. 【核心扫描】反射查找该类中所有带 @Scheduled 的方法
    Map<Method, Set<Scheduled>> annotatedMethods = findScheduledMethods(targetClass);

    if (annotatedMethods.isEmpty()) {
        // 5. 【缓存优化】没找到?加入黑名单缓存,下次别再费劲扫了
        this.nonAnnotatedClasses.add(targetClass);
    } else {
        // 6. 【注册任务】找到了!遍历每个方法,解析参数并注册成定时任务
        annotatedMethods.forEach((method, annotations) -> 
            processScheduled(annotations, method, bean));
            
        // 7. 【生命周期绑定】如果是单例 Bean,加入到"销毁管理列表"
        // 作用:确保容器关闭时,能自动 cancel 掉关联的定时任务,防止内存泄漏或僵尸任务
        if (isSingleton(beanName)) {
            this.manualCancellationOnContextClose.add(bean);
        }
    }

    return bean;
}

1.3 任务解析:processScheduled 的精密逻辑

当 Spring 扫描到带有 @Scheduled 注解的方法后,并没有立马执行,而是要根据注解里的参数(Cron 还是 FixedRate?)将它**"封装"**成具体的任务对象。这部分逻辑都在 processScheduled 方法中。

源码拆解 (processScheduled)

  1. 创建执行单元 :首先,通过 createRunnable(bean, method) 将目标方法包装成一个标准的 Runnable
  2. 参数解析与互斥检查
    • 代码会依次检查 cronfixedDelayfixedRate 这三个属性。
    • 互斥性 :代码中维护了一个 processedSchedule 标志位。Spring 强制规定:一个 @Scheduled 注解只能配置一种调度方式 。如果你既配了 cron 又配了 fixedRate,这里会直接抛出异常 Assert.isTrue(!processedSchedule, errorMessage)
  3. 任务类型分发
    • Cron 任务 :如果检测到 cron 属性,会创建 CronTask,并解析时区(TimeZone)。注意 :Cron 任务不支持 initialDelay,如果强行配置会抛错。
    • FixedDelay 任务 :如果检测到 fixedDelay,会创建 FixedDelayTask
    • FixedRate 任务 :如果检测到 fixedRate,会创建 FixedRateTask
  4. 注册与收尾
    • 最终,解析出的任务会被添加到 ScheduledTaskRegistrar 中(通过 registrar.scheduleCronTask 等方法)。
    • 同时,这些任务还会被记录到 this.scheduledTasks 集合中,方便后续的管理和销毁。

核心伪代码逻辑

java 复制代码
protected void processScheduled(Scheduled scheduled, Method method, Object bean) {
    // 1. 将目标方法包装成 Runnable
    Runnable runnable = createRunnable(bean, method);
    boolean processedSchedule = false;

    // 2. 解析 initialDelay (初始延迟)
    long initialDelay = parseInitialDelay(scheduled);

    // 3. 检查 Cron 表达式
    String cron = scheduled.cron();
    if (hasText(cron)) {
        // Cron 任务不支持 initialDelay,必须为 -1
        Assert.isTrue(initialDelay == -1L, "'initialDelay' not supported for cron triggers");
        
        // 创建 CronTask 并注册
        tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, new CronTrigger(cron, timeZone))));
        processedSchedule = true;
    }

    // 4. 检查 FixedDelay (固定延迟)
    if (scheduled.fixedDelay() >= 0) {
        // 互斥检查:不能同时配置 cron 和 fixedDelay
        Assert.isTrue(!processedSchedule, "Exactly one type of schedule is required");
        
        tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, initialDelay)));
        processedSchedule = true;
    }

    // 5. 检查 FixedRate (固定频率)
    if (scheduled.fixedRate() >= 0) {
        Assert.isTrue(!processedSchedule, "Exactly one type of schedule is required");
        
        tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, initialDelay)));
        processedSchedule = true;
    }
    
    // 6. 注册到内部集合,用于销毁管理
    //     Map<Object, Set<ScheduledTask>> scheduledTasks; 其中 key 是 bean, value 是Set<ScheduledTask>
    this.scheduledTasks.put(bean, tasks);
}

1.4 注册中心:ScheduledTaskRegistrar 的双模策略

processScheduled 只是将任务"封装"好了,真正的调度动作是由 ScheduledTaskRegistrar 完成的。这里藏着一个非常实用的设计模式:双模策略(Dual-Mode Strategy)

源码拆解 (scheduleCronTask / scheduleFixedDelayTask)

Spring 考虑到 ScheduledTaskRegistrar 初始化时,底层的 TaskScheduler 可能还没准备好(比如还在依赖注入中),所以设计了两种处理路径:

  1. 即时调度模式 (this.taskScheduler != null)
    • 如果调度器已经就位,直接调用 taskScheduler.schedule(...),任务立马生效。
    • 对于 FixedDelayFixedRate 任务,这里会根据 initialDelay 计算出首次执行的 Date startTime
  2. 延迟注册模式 (this.taskScheduler == null)
    • 如果调度器还没好,Spring 不会报错,而是将任务暂时存入 unresolvedTasks 列表,并添加到 cronTasksfixedDelayTasks 集合中。
    • 等到 ScheduledTaskRegistrarafterPropertiesSet 方法被调用时(即 Bean 初始化完成),再统一将这些积压的任务取出来进行调度。

核心伪代码逻辑

java 复制代码
public ScheduledTask scheduleCronTask(CronTask task) {
    // 1. 尝试从"待解决任务列表"中移除(如果之前加过)
    ScheduledTask scheduledTask = this.unresolvedTasks.remove(task);
    
    // 2. 如果调度器 (TaskScheduler) 已经就位,直接调度!
    if (this.taskScheduler != null) {
        scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
    } 
    // 3. 调度器还没好?先存起来,等会儿初始化完了再统一跑
    else {
        this.addCronTask(task);
        this.unresolvedTasks.put(task, scheduledTask);
    }

    return scheduledTask;
}

1.5 流程图解:从启动到注册


⚙️ 第二部分:核心机制 ------ 任务是如何被调度的?

第一部分的最后,我们提到了 ScheduledTaskRegistrar 会调用 TaskScheduler 进行真正的任务调度。这一章我们将深入这一过程,看看三种不同类型的任务(Cron、FixedRate、FixedDelay)究竟是如何被启动的,以及底层 JDK 线程池是如何高效运转的。

2.1 调度入口:TaskScheduler 的分发

ScheduledTaskRegistrar 就像是一个指挥官,它根据任务类型的不同,分别调用 TaskScheduler 的不同方法。

核心源码 (ScheduledTaskRegistrar.scheduleCronTask)

以最复杂的 Cron 任务为例,我们来看看 scheduleCronTask 到底做了什么:

java 复制代码
public ScheduledTask scheduleCronTask(CronTask task) {
    // 1. 尝试从"待解决任务列表"中移除(如果之前加过)
    ScheduledTask scheduledTask = this.unresolvedTasks.remove(task);
    boolean newTask = false;
    if (scheduledTask == null) {
        scheduledTask = new ScheduledTask(task);
        newTask = true;
    }
    
    // 2. 关键点:调用 TaskScheduler 的 schedule 方法
    if (this.taskScheduler != null) {
        scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
    }
    else {
        // 如果调度器还没好,先存起来
        addCronTask(task);
        this.unresolvedTasks.put(task, scheduledTask);
    }
    return (newTask ? scheduledTask : null);
}

这里有三个核心的调度分支:

  1. Cron 任务 :调用 schedule(Runnable task, Trigger trigger)。这是最灵活的模式,依赖 Trigger 计算下次执行时间。
  2. 固定频率 (FixedRate) :调用 scheduleAtFixedRate(Runnable task, long period)。不关心任务执行多久,每隔固定时间就触发一次。
  3. 固定延迟 (FixedDelay) :调用 scheduleWithFixedDelay(Runnable task, long delay)。上一次任务结束后,休息固定时间再执行下一次。

2.2 核心适配器:ReschedulingRunnable

ThreadPoolTaskScheduler 底层使用的是 JDK 的 ScheduledExecutorService。但 JDK 原生只支持 FixedRate 和 FixedDelay,根本不支持 Cron 表达式

Spring 是如何让 JDK 线程池支持 Cron 的呢?答案是 ReschedulingRunnable

这一关键的封装动作发生在 ThreadPoolTaskScheduler.schedule 方法中:

java 复制代码
@Override
public ScheduledFuture<?> schedule(Runnable task, Trigger trigger) {
    // ... 省略错误处理
    ScheduledExecutorService executor = getScheduledExecutor();
    try {
        // 【核心封装】将 Runnable 和 Trigger 打包成 ReschedulingRunnable
        return new ReschedulingRunnable(task, trigger, this.clock, executor, errorHandler).schedule();
    }
    catch (RejectedExecutionException ex) {
        throw new TaskRejectedException(executor, task, ex);
    }
}

这是一个适配器模式的经典应用。它将"动态计算时间的任务"转换成了 JDK 能理解的"一次性延时任务",并在执行完后自动重新调度自己

核心逻辑 (ReschedulingRunnable.run)

java 复制代码
public void run() {
    // 1. 记录实际执行时间
    Instant actualExecutionTime = this.triggerContext.getClock().instant();
    
    // 2. 执行用户的业务逻辑 (super.run())
    super.run(); 
    
    // 3. 记录完成时间
    Instant completionTime = this.triggerContext.getClock().instant();
    
    // 4. 更新上下文,用于计算下次执行时间
    this.triggerContext.update(this.scheduledExecutionTime, actualExecutionTime, completionTime);
    
    // 5. 【递归调度】只要没被取消,就计算下次时间,并将自己再次提交给线程池!
    schedule();
}

核心逻辑 (ReschedulingRunnable.schedule)

run() 方法最后调用的 schedule() 才是实现"永动"的关键:

java 复制代码
@Nullable
public ScheduledFuture<?> schedule() {
    synchronized(this.triggerContextMonitor) {
        // 1. 根据 Trigger 计算下一次执行时间
        this.scheduledExecutionTime = this.trigger.nextExecutionTime(this.triggerContext);
        if (this.scheduledExecutionTime == null) {
            return null;
        }

        // 2. 计算当前时间到下一次执行时间的间隔 (Delay)
        long initialDelay = this.scheduledExecutionTime.getTime() - this.triggerContext.getClock().millis();

        // 3. 将自己 (this) 再次提交给 JDK 线程池,设置为延迟执行
        this.currentFuture = this.executor.schedule(this, initialDelay, TimeUnit.MILLISECONDS);
        return this;
    }
}

📊 流程图 :ReschedulingRunnable 运行机制

sequenceDiagram participant ThreadPool as 线程池 (TaskScheduler) participant Wrapper as ReschedulingRunnable participant UserTask as 用户业务代码 participant Trigger as 触发器 (CronExpression) ThreadPool->>Wrapper: 到点执行 run() activate Wrapper Wrapper->>UserTask: 执行业务逻辑 activate UserTask UserTask-->>Wrapper: 执行完毕 deactivate UserTask Wrapper->>Trigger: nextExecution() 计算下次时间 Trigger-->>Wrapper: 返回下次时间点 T2 Wrapper->>ThreadPool: schedule(this, delay) Note right of Wrapper:把自己再次提交给线程池
实现循环调度 deactivate Wrapper

2.3 底层探秘:JDK 线程池的智慧

最终,所有的任务都会交给 JDK 的 ScheduledThreadPoolExecutor 执行。这里有一个面试常问的硬核知识点:线程池是怎么知道哪个任务先执行的?是每秒轮询一次吗?

答案是:DelayedWorkQueue + Leader-Follower 模式

2.3.1 优先级队列 (DelayedWorkQueue)

这是一个基于堆 (Heap) 结构实现的队列。

  • 特点:最快要过期的任务,永远在堆顶(队列头部)。
  • 优势:插入和删除的时间复杂度都是 O(logN),非常高效。

2.3.2 Leader-Follower 模式 (避免 CPU 空转)

如果堆顶的任务还有 10 秒才执行,线程池里的线程会干嘛?傻傻地循环判断吗? 当然不是!JDK 使用了 Condition.awaitNanos() 机制。我们通过 DelayedWorkQueue.take() 的源码来一探究竟:

源码深度解析 (DelayedWorkQueue.take())

java 复制代码
// ScheduledThreadPoolExecutor 内部类 DelayedWorkQueue
public RunnableScheduledFuture<?> take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            // 1. 获取堆顶任务 (最早执行的任务)
            RunnableScheduledFuture<?> first = queue[0];
            
            // 2. 队列为空,等待新任务 (available.await())
            if (first == null)
                available.await();
            else {
                // 3. 计算还要等多久
                long delay = first.getDelay(NANOSECONDS);
                
                // 4. 如果任务已到期,直接出队并返回执行!
                if (delay <= 0)
                    return finishPoll(first);
                    
                first = null; // 释放引用
                
                // 5. 【关键】Leader-Follower 逻辑
                // 如果已经有 Leader 线程在等待了,那我就当 Follower,无限期等待
                if (leader != null)
                    available.await();
                else {
                    // 6. 还没 Leader?那我来当 Leader!
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        // 7. 精确睡眠 delay 时间 (只睡到任务启动的那一刻)
                        available.awaitNanos(delay);
                    } finally {
                        // 醒来后,卸任 Leader
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        // 8. 只要 Leader 没了,且队列里还有任务,就唤醒下一个线程去接班
        if (leader == null && queue[0] != null)
            available.signal();
        lock.unlock();
    }
}

设计哲学

  • Leader 线程 :全村的希望。只有它在进行限时等待(等待堆顶任务到期)。
  • Follower 线程 :吃瓜群众。它们进行无限期等待,直到被唤醒。
  • 极致性能 :这种模式保证了在任何时刻,只有一个线程 在通过 awaitNanos 倒计时,其他线程都在 await 挂起。避免了惊群效应(Thundering Herd),也极大减少了 CPU 的空转和上下文切换。

⚠️ 第三部分:生产避坑指南 (重点!)

看完源码,我们必须回归实战。Spring Boot 3.4 虽然强大,但默认配置依然可能让你"踩雷"。

💣 坑点一:默认的单线程陷阱

如果你直接使用 @Scheduled 而不配置线程池:

java 复制代码
// ScheduledTaskRegistrar.java 源码实锤
// scheduleTasks(); 在 afterPropertiesSet() 中调用
if (this.taskScheduler == null) {
    this.localExecutor = Executors.newSingleThreadScheduledExecutor();
    this.taskScheduler = new ConcurrentTaskScheduler(this.localExecutor);
}

后果 :Spring 会创建一个单线程 的调度器。 场景 :你有任务 A(每秒执行)和任务 B(每秒执行)。如果任务 A 卡住了(比如死循环或慢 SQL),任务 B 也会直接停止执行,因为唯一的线程被 A 占用了!

✅ 解决方案 1:传统线程池 (Classic) 配置 ThreadPoolTaskScheduler

java 复制代码
@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(10); // 根据业务量设定
        taskScheduler.setThreadNamePrefix("my-scheduled-task-");
        taskScheduler.initialize();
        taskRegistrar.setTaskScheduler(taskScheduler);
    }
}

✅ 解决方案 2:虚拟线程 (New in Spring 6) 在 Spring Boot 3.2+ (含 3.4) 中,你可以利用 Java 21 虚拟线程 来轻松解决并发问题,而无需关心线程池大小!

java 复制代码
@Bean
public TaskScheduler taskScheduler() {
    // SimpleAsyncTaskScheduler 在 Spring 6.1+ 支持虚拟线程
    SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
    scheduler.setVirtualThreads(true); // 开启魔法!
    scheduler.setTaskTerminationTimeout(30_000);
    return scheduler;
}

注意:使用虚拟线程时,不再需要设置 poolSize,因为虚拟线程是廉价且无限的。

💣 坑点二:异常吞噬导致任务终止

虽然 ReschedulingRunnable 内部捕获了异常,但在某些极端配置或自定义线程池场景下,未捕获的 RuntimeException 可能会导致任务不再被重新调度。

✅ 解决方案 : 在你的定时任务方法内部,务必 进行 try-catch 包裹。

java 复制代码
@Scheduled(cron = "...")
public void safeTask() {
    try {
        // 业务逻辑
    } catch (Exception e) {
        log.error("定时任务执行异常", e);
        // 甚至可以加告警逻辑
    }
}

📝 总结

Spring Boot 3.4 下的 @Scheduled 依然是一套优秀的组合拳,并且有了新的进化:

  1. 前端 :利用 BeanPostProcessor 实现无感知的注解扫描。
  2. 中端 :利用 ReschedulingRunnableCronExpression 实现精准调度。
  3. 后端
    • 经典模式 :依赖 JDK ScheduledThreadPoolExecutor 的堆结构和 Leader-Follower 模式。
    • 新潮模式 :结合 SimpleAsyncTaskScheduler 和虚拟线程,实现高并发轻量级调度。

最后的建议 :升级到 Spring Boot 3.4 后,强烈建议尝试 虚拟线程 方案,它能彻底解决"线程池配多少合适"这个千古难题。

相关推荐
Seven971 小时前
剑指offer-47、求1+2+3...+n
java
ZePingPingZe1 小时前
Spring boot2.x-第05讲番外篇:常用端点说明
java·spring boot·后端
用户93761147581611 小时前
分布式ID自增策略(二)
后端
Macbethad1 小时前
WPF 工业设备管理程序技术方案
java·大数据·hadoop
Hello.Reader1 小时前
Flink SQL 窗口函数从 OVER 到 TopN 的完整套路
java·sql·flink
她说彩礼65万1 小时前
C# ConcurrentDictionary详解
java·服务器·c#
陌上倾城落蝶雨1 小时前
django基础命令
后端·python·django
侠客在xingkeit家top1 小时前
SpringCloudAlibaba高并发仿斗鱼直播平台实战(完结)
后端