聊透Spring定时任务调度

2,425 阅读26分钟

 大家好,我是贰师兄,好久不见。由于贰师兄最近开始写聊聊Mysql这个专栏,为避免传递一些错误的知识,误导大家,所以在刷Mysql的源码。这个过程耗费的时间比较多,自然更新的速度也会慢一些,请大家见谅。

 由于新的专栏还在筹备中,本文我们还是聊一聊Spring的话题,本文我们聊一下Spring定时任务调度。这个主题,相信大家都比较熟悉,平时用的应该也比较多。不过说起实现原理,了解的小伙伴应该就不太多了,网上对应的文章虽然很多,不过基本都是使用教程。即使有少部分提及原理,说的也是含糊不清,更有甚者是胡说八道。

 其实Spring的定时任务调度,并不神秘,也不是Spring特有的,本质还是借助JDK的能力实现的,只是在使用方式上更Spring一点,也就是更简洁、更便捷一点。

关于JDK的定时任务,主要是借助 ScheduledThreadPoolExecutor实现的,感兴趣的小伙伴可以可以自行了解其实现原理。这里我们主要聊Spring的相关细节,关于JDK部分我们不会详细介绍。

当然,如果你们有需要,恰巧我也有时间的话,也可以拿出来说一说,毕竟说啥不是说呢,是吧。

1. Spring定时任务的类型

 关于Spring定时任务的使用,小伙伴们应该都比较熟悉,就是直接在方法上加上@Scheduled注解即可。Spring会根据你指定的频率,定时调度该方法的执行,这一点完全不用你关心。

 使用自然是很简单的,这是Spring一贯的风格。关于Spring是怎么做的,读过贰师兄前面文章的小伙伴应该也可以猜到,至少得先把这些标注了@Scheduled注解的定时方法找出来,然后在想办法让它定时执行。当然前面我们也说了,这部分是借助JDK的能力来实现的。

不熟悉这个路数的小伙伴,可以参考聊透Spring事件机制中@EventListener注解的解析和注册过程,套路是一样的。

 但是在使用Spring定时调度的过程,也有一些细节需要先和小伙伴们介绍清楚。首先就是Spring支持的三种类型任务的执行逻辑,这里恐怕能说清楚的小伙伴们不多,尤其是在碰到单线程模型,我们先梳理一下:

Spring支持CRON表达式类型、fixedDelay间隔执行、fixedRate间隔执行三种任务类型。关于这三种类型在任务执行上的差别,我们一一介绍一下。

1.1 CRON表达式类型任务

 关于CRON表达式含义,这里我们不再介绍,相信小伙伴们都比较熟悉,实在不熟悉自行查阅资料吧。

 我们要说的是,在单线程执行的情况下,如果CRON任务执行时间过长,以至于下次执行的时间都到了,但是上次任务还没有执行结束,下次任务要怎么办。

 这里先给结论:放弃,也就是下一次任务执行就被放弃了,也就是少执行了一次。这里拿任务设置为每五秒执行一次的表达式,说明一下:

  1. 假设10:00:00s时,任务第一次执行,但是任务执行时间很长,执行了7s。
  2. 根据任务的执行计划,10:00:05s时,应该要执行第二次任务,但是此时发现有任务在执行(上一次任务需要执行到10:00:07s),那么,此次执行计划直接放弃,也就是本次任务不执行了
  3. 根据任务的执行计划,10:00:10s时,应该要执行第三次任务,此时发现没有任务执行,本次任务正常执行。

image.png

 这里大家一定要注意,单线程模型下,由于第一次任务执行时间较长,导致第二次任务不执行,也就是少执行了一次。这里可能会影响预期、从而产生业务影响。

1.2 fixedDelay间隔类型任务

 fixedDelay是最简单的一种方式模型,间隔执行:也就是延迟指定的间隔后,再次执行下次任务。计算公式为:下次执行时间 = 上次任务执行结束时间 + 间隔时间。相同的问题:如果任务执行时间较长,下次执行时间也会晚于预期。这里以任务间隔为五秒,说明一下:

  1. 假设10:00:00s时,任务第一次执行,任务执行时间较长,执行了7s。
  2. 第一次任务执行结束后(10:00:07),等待5s后,再次执行下一次任务(10:00:12),第二次任务执行了3s。
  3. 第二次任务执行结束后(10:00:15),等待5s后,再次执行下一次任务,依此类推。

image.png

 这里需要注意,单线程模型下,如果存在任务执行时间较长,整体的执行计划都会往后顺延

1.3 fixedRate间隔类型任务

 fixedRate也是间隔执行的方式,只是这个间隔不是按照任务结束时间计算的,而是按照开始时间。计算公式为:下次执行时间 = 上次任务执行开始时间 + 间隔时间。当然,如果任务执行时间较长,超过间隔时间,下次执行时间也要顺延,毕竟不能强暴的直接打断吧。

 不过fixedRate会将间隔会自动缩小,尽量追赶计划执行时间,一旦赶上或者追平,继续按照指定间隔执行。这里还是以间隔为五秒的情况,说明一下:

  1. 假设10:00:00s时,任务第一次执行,任务执行时间较长,执行了7s。
  2. 根据任务的执行计划,10:00:05s时,应该要执行第二次任务,但是此时第一次任务还在执行中,所以第二次执行时间只能等待顺延。
  3. 第一次任务执行结束后(10:00:07),发现已经晚于第二次执行的计划时间了。会追赶进度,所以第二次任务立即执行。
  4. 这里假设第二次任务只需要执行2s,在10:00:09就执行结束了。计划第三次执行时间为:10:00:10,也就是第二次任务已经追平了,无需继续追赶,此时会遵循计划,在10:00:10时,正常执行第三次任务。

image.png

 这里需要注意,fixedRate会自动调整间隔,使任务尽快追平计划时间,追平后遵循计划执行。当然这里讨论的也是单线程模型下。

 好了,关于定时任务的三种类型的讨论就这么多。大家注意在单线程模型下,上面的讨论才有意义。大家清楚不同任务类型,发生任务执行时间过长,对下次执行时间的影响即可。再次强调,是单线程模型下,如果是多线程执行,影响情况需要结合线程池配置分析了,这里我们不具备讨论条件。

这里为什么执着的讨论单线程模型,因为Spring默认的就是单线程模型,而往往我们又不指定调度线程池。所以其实单线程模型才是最最常用的。

2. @Scheduled注解解析

 通过上一章节对Spring三种定时任务类型的介绍,相信小伙伴们已经很清楚他们之间的区别了。在Spring中,定时任务都是由@Scheduled标识的,三种任务类型分别对应@Scheduled的三种属性,分别是cronfixedDelayfixedRate,设置对应的值,即为开启对应类型的任务。

 我们在上面也介绍过了,Spring要执行这些定时任务,第一步就是需要先解析出来这些定时任务,然后才能交由JDK处理。那么本章节我们就来看一下解析过程。

2.1 @EnableScheduling开启任务调度功能

 在探索解析流程之前,我们先介绍一下@EnableScheduling。大家知道,在使用Spring的定时任务调度功能前,是需要在类上先添加@EnableScheduling开启的,这究竟有什么用呢。

 关于Spring的@EnableXXX,通常到时开启某种能力,比如EnableScheduling开启定时任务调度、 @EnableAsync开启异步调用等。其实原理也很简单,都是借助@Import能力导入某些BeanPostProcessor(也有可能是其他类型的),这些BeanPostProcessor,会在bean的生命周期的各个流程发挥重要作用,从而使Spring具有强大的能力。

 关键这个过程完全是可插拔的,加入某个BeanPostProcessor,就具备响应能力了,拓展能力极强。这也正是@EnableXXX的原理,可以简单理解为:开启某项功能。

BeanPostProcessor是Spring留给我们的一种拓展方式,能力非常强悍,甚至很多Spring的核心能力,比如@AutoWired@Resource属性注入,都是借助它实现的。当然,@EnableXXX+@Import的组合不仅仅能导入BeanPostProcessor,导入普通配置类也是可以的,并没有导入类型方面的限制。

 下面我们看一下@EnableScheduling的做法,实现和我们上面说的一样的,他使用@Import导入了SchedulingConfiguration这个配置类,这个配置类中使用@Bean将ScheduledAnnotationBeanPostProcessor对象放入到Spring容器中。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(SchedulingConfiguration.class) // 导入SchedulingConfiguration
@Documented
public @interface EnableScheduling {

}

@Configuration
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class SchedulingConfiguration {
   //使用@Bean将ScheduledAnnotationBeanPostProcessor对象实例放入Spring容器
   @Bean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
   @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
   public ScheduledAnnotationBeanPostProcessor scheduledAnnotationProcessor() {
      return new ScheduledAnnotationBeanPostProcessor();
   }
}

image.png

 这里的ScheduledAnnotationBeanPostProcessor一定是Spring具备定时任务调度能力的关键。正如我们前面提到的,它确实是继承了BeanPostProcessor,也就是大名鼎鼎的Bean的后置处理器。确实,这个bean的后置处理器,负责了@Scheduled标注的定时方法的解析、封装、调度执行等全部功能。没有它,这些工作没有人做,确实就不具备定时调度能力了。

读过贰师兄聊透Spring依赖注入一文的小伙伴,一定马上就能想到,支撑@AutoWired属性注入的AutowiredAnnotationBeanPostProcessor,和支撑@Resource属性注入的CommonAnnotationBeanPostProcessor,不都是BeanPostProcessor嘛。

 这里贰师兄在啰嗦一下,关于bean的后置处理器,大家真的有必要去研究一下,其实Spring的很多强大功能,都是依赖各个不同的BeanPostProcessor构筑出来的。他作用于bean的生命周期中的各个阶段,对bean进行功能增强,从而使bean强大无比,去聊透Spring bean的生命周期了解一下吧,求求你们了。

2.2 创建bean时解析@Scheduled注解方法

 现在清楚了@EnableScheduling的本质是将ScheduledAnnotationBeanPostProcessor放到Spring容器中,它会负责@Scheduled标注的定时方法的解析、封装、调度执行等全部功能。那这些操作什么时候触发呢,又是怎么做的呢,是我们接下来探究的关键。

 关于@Scheduled解析流程,贰师兄打开ScheduledAnnotationBeanPostProcessor类,大致浏览了一下就找到是在postProcessAfterInitialization()完成的。很明显,这里上来就是反射查找@Scheduled的方法,不是他是谁,我们先来简单看一下源码:

// ScheduledAnnotationBeanPostProcessor.java
public Object postProcessAfterInitialization(Object bean, String beanName) {
   
    // 1:反射解析加了@Scheduled的方法
    Map<Method, Set<Scheduled>> annotatedMethods = MethodIntrospector.selectMethods(targetClass,
        (MethodIntrospector.MetadataLookup<Set<Scheduled>>) method -> {
           Set<Scheduled> scheduledMethods = AnnotatedElementUtils.getMergedRepeatableAnnotations(
                 method, Scheduled.class, Schedules.class);
           return (!scheduledMethods.isEmpty() ? scheduledMethods : null);
        });

     // 2:处理加了@Scheduled的方法,(封装成调度任务)
     annotatedMethods.forEach((method, scheduledMethods) ->
           scheduledMethods.forEach(scheduled -> processScheduled(scheduled, method, bean)));
           
     // ...省略其他代码
}


protected void processScheduled(Scheduled scheduled, Method method, Object bean) {
    // 将bean对象、方法信息封装为Runnable对象
    Runnable runnable = createRunnable(bean, method);
    
    // 处理cron表达式
    String cron = scheduled.cron();
    if (StringUtils.hasText(cron)) {
         // 封装成ScheduledTask,保存到tasks中
         tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, new CronTrigger(cron, timeZone))));
    }

    // ...省略fixedDelay和fixedRate任务的解析
}

 这里的解析流程其实比较清晰,就是反射查找当前创建的bean,是否存在@Scheduled标注的定时任务方法。如果存在,就对定时任务方法解析、封装处理。解析无非就是对@Scheduled字段的解析;关于为什么封装,这里需要和小伙伴解释一下,后续定时调度这些方法是通过反射的方式,反射我们比较了解,需要method和对象信息的,而定时任务执行时间依赖@Scheduled注解信息,所以需要将三者封装成ScheduledTask对象,先保存起来,供后续使用。 image.png

这里之所以先保存起来,也是Spring一贯的做法都是先解析暂存,后续再使用。之所以这样是因为Spring依赖关系复杂,容器启动过程和bean创建过程中,每个流程都做了很多事情,通常没有办法在一个时机做完某个功能所需的所有事情,所以会分散在各个流程里,这也是Spring源码复杂最主要的原因。

 关于@Scheduled解析时机,解析方法都找到了,看一下调用关系就可以了,发现是在创建bean时,初始化回调时进行的,这也合理,创建bean时,解析一下bean中@Scheduled标注的定时方法。

3. @Scheduled定时任务调度执行

 现在Spring已经将加了@Scheduled的调度任务解析出来了,那下一步就是调度任务的执行了。之前我们也说过,这部分借助的是JDK的定时任务调度能力,Spring只是做了融合翻译的工作,也就是把@Scheduled标注的定时方法,翻译成符合DJK规定的定时调度任务,再交由JDK的ScheduledThreadPoolExecutor执行

 这里可以看到,Spring所做的并不复杂,不过还是有一些细节需要注意,第一个问题就是:执行任务的调度线程池从何而来。我们知道,对JDK定时任务调度而言,调度线程池至关重要,通常第一步就是先创建一个ScheduledThreadPoolExecutor,然后对这个调度线程池提交任务的。我们先来看一下原生的做法:

这里贰师兄没有自己写,而是从RocketMQ摘取了部分相关源码。rocketMQ中有大量的定时任务使用,也是使用JDK的能力,这里我们参考一下:。

protected void initializeResources() {
    // 1: 创建ScheduledThreadPoolExecutor
    this.scheduledExecutorService = new ScheduledThreadPoolExecutor(1,
        new ThreadFactoryImpl("BrokerControllerScheduledThread", true, getBrokerIdentity()));
}

protected void initializeBrokerScheduledTasks() {
    // 2:提交任务到scheduledExecutorService,定时进行broker统计的任务
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            try {
                BrokerController.this.getBrokerStats().record();
            } catch (Throwable e) {
                LOG.error("BrokerController: failed to record broker stats", e);
            }
        }
    }, initialDelay, period, TimeUnit.MILLISECONDS);
}

 之所以选择RocketMQ源码,主要是更具有代表性,免得大家说我使用不规范。当然,另外一个原因也是贰师兄比较熟悉rocketMQ的源码,找起来比较快。

3.1 选择合适的线程池执行任务

 相对于原生使用的直接创建调度线程池,Spring会有一些小小的麻烦,那就是选择合适的调度线程池。如果用户指定了调度线程池还好,如果没有指定呢,不然执行了,还是直接创建一个默认的,如果使用默认兜底,那线程数设置多少合适呢,毕竟线程数的设置要参考:任务数和执行频率啊,这两个值每个项目又都不一样。

这里贰师兄需要重点强调一下,给定时任务设置合适的线程池非常重要,第一章节就分析过了,线程池设置的过小,会导致有些调度任务久久不能执行,从而影响数据的准确性,这一点小伙伴们一定要特别注意。

 这里Spring的做法是先看用户是否配置了调度线程池,如果配置了,使用用户配置的;如果没有配置,创建一个默认的,但是,Spring创建的默认调度线程池,是单线程的,是单线程,是单线程!!!,重要的事情说三遍,贰师兄就在这个上面吃过亏,我们一会看一下具体场景,加深一下小伙伴们的印象。

3.1.1 Spring查找调度线程池

 我们先来看一下Spring选择调度线程池的逻辑,逻辑我们已经说清楚了,直接在源码中验证一下。

private void finishRegistration() {
      try {
         // 2.1: 获取容器中配置的TaskScheduler,没有或存在多个,都会抛出异常
         this.registrar.setTaskScheduler(resolveSchedulerBean(this.beanFactory, TaskScheduler.class, false));
      }
      catch (NoUniqueBeanDefinitionException ex) {
         try {
            //2.2 存在多个的话,再通过名称确定一个
            this.registrar.setTaskScheduler(resolveSchedulerBean(this.beanFactory, TaskScheduler.class, true));
         }
      }
      catch (NoSuchBeanDefinitionException ex) {
         try {
            // 2.3: 不存在TaskScheduler类型,获取ScheduledExecutorService类型
            this.registrar.setScheduler(resolveSchedulerBean(this.beanFactory, ScheduledExecutorService.class, false));
         }
         catch (NoUniqueBeanDefinitionException ex2) {
            try {
               // 2.4: 获取多个ScheduledExecutorService,通过名字确定一个
               this.registrar.setScheduler(resolveSchedulerBean(this.beanFactory, ScheduledExecutorService.class, true));
            }
            catch (NoSuchBeanDefinitionException ex3) {
            // 2.5: 没有打印日志即可
         }
         catch (NoSuchBeanDefinitionException ex2) {
            // 2.5: 没有打印日志即可
      }
   }
   // 调度任务执行,如果容器中不存在调度线程池,会创建默认线程池
   this.registrar.afterPropertiesSet();
}

  这部分的源码比较简单,通过resolveSchedulerBean()去Spring容器查找特定类型的bean,查找不到会抛出NoSuchBeanDefinitionException异常,查找到多个会抛出NoUniqueBeanDefinitionException异常,这里都进行了异常捕捉,再次处理对应的逻辑。

  按照源码的逻辑,那就是:先查找TaskScheduler类型的bean,如果不存在该类型的bean,再次尝试查找ScheduledExecutorService类型的bean,实在查找不到,也就是打印一下了一下日志,而且还是debug级别的。找到多个再次根据名称过滤一下,选定一个。

image.png

这里可以看到,使用用户指定的调度线程池,是看容器中有没有,所以想要指定,直接将想要使用的调度线程池放入Spring容器即可。

还有一点需要解释一下,这一步并没有构建默认的线程池,构建默认线程池的流程在下一步哦。

3.1.2 Spring构建的默认调度线程池

  在Spring容器中无法找到调度线程池时,Spring会创建默认的调度线程池,我们也看一下这部分的逻辑。

protected void scheduleTasks() {
   if (this.taskScheduler == null) {
      // 重点:没有设置taskScheduler,默认才用单线程
      this.localExecutor = Executors.newSingleThreadScheduledExecutor();
      this.taskScheduler = new ConcurrentTaskScheduler(this.localExecutor);
   }
}

// 构建默认单线程的调度线程池
public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
    return new DelegatedScheduledExecutorService
        (new ScheduledThreadPoolExecutor(1));
}

// 构建corePoolSize为1的调度线程池
public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}

image.png

3.1.3 使用默认单线程的调度线程池,导致心跳丢失的案例分析

这里简单介绍一下业务场景,项目有两个定时任务,一个是计算任务,定时凌晨2点执行,执行大概时间5分钟;还有一个心跳发送的定时任务,每10s执行一下上报,服务端有检测逻辑,如果实例超过30s未上报心跳,进行实例摘除。当时的场景就是:项目没有设置调度线程池,故自动使用了Spring默认的单线程调度线程池。

  这里我们用代码简单模拟一下:

@Component
public class ScheduledJob {

   /** 计算任务,凌晨2点执行,耗时五分钟 */
   @Scheduled(cron = "0 0 2 * * ?")
   void calculation() throws InterruptedException {
      System.out.println("任务1,"+Thread.currentThread().getName()+"开始执行:"+new Date());
      Thread.sleep(5 * 60 * 1000);
      System.out.println("任务1,"+Thread.currentThread().getName()+"执行结束:"+new Date());
   }

   /** 心跳任务,每10s上报一次,耗时1s */
   @Scheduled(cron = "*/5 * * * * ?")
   void heartbeat() throws InterruptedException {
      System.out.println("任务2,"+Thread.currentThread().getName()+"开始执行:"+new Date());
      Thread.sleep(1000);
      System.out.println("任务2,"+Thread.currentThread().getName()+"执行结束:"+new Date());
   }
}

 代码如上所示,后来项目发现:每天夜里2:00-2:05期间,实例没有心跳上报了,时间演远远超过30s,最终导致实例被摘除,进而导致其他一系列问题。后来排查下来,就是因为没有配置调度线程池,使用了Spring默认单线程调度线程池导致的。我们分析一下当时的场景:

  1. 在2:00前,只有心跳任务执行,因为执行时间短,任务不会阻塞,每次心跳都能正常上报。
  2. 在2:00时左右,计算任务启动,唯一的线程资源被占用,执行需要持续五分钟,唯一的执行线程资源在2:05才能被释放。
  3. 2:00:10 心跳任务应该被执行,但是由于没有可以线程,任务只能被放弃。直到2:05前,都是如此,导致近五分钟不能上报心跳
  4. 2:05时左右,计算任务结束,心跳任务才有资源执行,继续上报心跳。

 上述案例就是调度线程池设置的不合理,导致实例摘除的真实情况。通过这个案例,希望加深小伙伴们对设置合适的调度线程池有多么重要的深刻意识。赶紧检查一下自己项目调度线程池的设置吧,尤其是连设置都没有设置的小伙伴,更要小心了。

3.1.4 配置合适的调度线程池

  现在,我们已经知道配置合适的调度线程池有多么的重要。关于如何创建,连Spring如何查找的底裤都被我们扒出来了,如何配置还不是小菜一碟。为了完整性,我们还是给大家展示一下吧。

@EnableScheduling
@Configuration
public class ScheduleConfig {
   @Bean("threadPoolTaskScheduler")
   public TaskScheduler threadPoolTaskScheduler(){
      ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
      scheduler.setPoolSize(10);
      return scheduler;
   }
}

  是不是很简单,直接放入Spring容器中就可以了。关于你是想用@Bean、还是想用@Component、或者想用@Import亦或是自定义BeanPostProcessor这些奇技淫巧,那就随你便了。

3.2 定时任务调度执行

  现在定时任务也查找出来了,调度线程池也有了,可谓是万事俱备,只欠东风了。我们也终于来到任务调度执行得最后一关了。关于调度执行,当然是先把之前解析封装出来ScheduledTask任务取出来,转换翻译一下,交给JDK的调度线程池了。不过三种任务类型还不完全一样,我们逐个来看一下。

image.png

3.2.1 cron表达式类型的任务执行

  其实JDK的ScheduledThreadPoolExecutor本身是不支持cron表达式类型的,这部分能力是Spring赋予的,当然,底层借助的是ScheduledThreadPoolExecutor#schedule()单次任务调度,spring只是玩了一些小花样,从而使其具备了cron可以重复执行的能力。

  这里的具体实现是:Spring先进行cron表达式的解析,计算出下一次任务的具体执行时间,然后交由ScheduledThreadPoolExecutor#schedule()进行下次调度。不过这还是单次的啊,不具备重复执行的能力啊,这里Spring的小把戏就来了,在执行时间到,ScheduledThreadPoolExecutor执行了先前提交的任务后,会再次计算出下次任务的执行时间,再次提交给ScheduledThreadPoolExecutor。哦,原来是通过本次执行后,会提交下次任务的方式,使其具备了CRON重复执行的能力,不得不说,Spring很聪明。

  我们直接看一下源码:

public ScheduledTask scheduleCronTask(CronTask task) {

   // 重点:2:任务调度执行阶段,将任务提交给调度线程池
   if (this.taskScheduler != null) {
      scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
   }
   // 1:@Schedule解析时机,taskScheduler为null,仅仅只是将任务包装保存起来即可
   else {
      addCronTask(task);
      this.unresolvedTasks.put(task, scheduledTask);
   }
}

// # ConcurrentTaskScheduler.java
public ScheduledFuture<?> schedule(Runnable task, Trigger trigger) {
    //封装ReschedulingRunnable,并调度任务
    return new ReschedulingRunnable(task, trigger, this.scheduledExecutor, errorHandler).schedule();
}

  我们发现,在任务执行的时候,先将任务封装成了ReschedulingRunnable,然后调用schedule()进行调用,貌似离核心秘密不远了,我们继续跟踪一下。

public ScheduledFuture<?> schedule() {
   synchronized (this.triggerContextMonitor) {
      // 1:根据cron表达式,计算下次执行时间
      this.scheduledExecutionTime = this.trigger.nextExecutionTime(this.triggerContext);
      if (this.scheduledExecutionTime == null) {
         return null;
      }
      //2:计算下次执行还有多少时间
      long initialDelay = this.scheduledExecutionTime.getTime() - System.currentTimeMillis();
      //3: 将自己作为任务提交给调度线程池执行。
      this.currentFuture = this.executor.schedule(this, initialDelay, TimeUnit.MILLISECONDS);
      return this;
   }
}

  这里终于发现我们想要的地方了,第一步就解析cron表达式,计算出任务执行时间,然后交给ScheduledThreadPoolExecutor#schedule()执行,这里是第一次执行任务。

关于ScheduledThreadPoolExecutor的调度原理,本质上是将定时任务,按照执行时间,有序的维护在内部队列里,然后循环从队列获取执行时间符合的任务,交由线程池执行。只是在正常线程池的任务执行的基础上,引入了时间的概念,感兴趣的小伙伴可以自行查阅资料了解一下。

  另外,关于把this传递给schedule()去执行,可能也有点懵,什么鬼,把自己提交给调度线程池了,这啥啊。小伙伴们冷静想一想,调度线程池本质是个线程池,根据JAVA规范,我们向线程池提交的什么,是不是Runnable实例,然后线程池执行的是Runnable#run()

  那巧了,ReschedulingRunnable就实现了Runnable,所以,把自己提交过去,到时候执行的就是ReschedulingRunnable#run()。这就需要我们去看一下run()的具体实现,看一下实现逻辑是不是我们之前分析的:先反射执行@Schedule标注的定时方法,然后再提交CRON表达式对应的下一次任务

public void run() {
   Date actualExecutionTime = new Date();
   //1: 执行我们定义的@Schedule方法
   super.run();
   Date completionTime = new Date();
   synchronized (this.triggerContextMonitor) {
      Assert.state(this.scheduledExecutionTime != null, "No scheduled execution");
      //2: 更新执行时间信息
      this.triggerContext.update(this.scheduledExecutionTime, actualExecutionTime, completionTime);
      if (!obtainCurrentFuture().isCancelled()) {
         //3:再次调用schedule方法,提交下一次任务
         schedule();
      }
   }
}

  果然不出所料,就是执行了我们定义的@Schedule定时方法,然后提交一下车任务,不过还有一些其他的工作,比如记录一下执行时间信息,目的是:方便进行下一次执行时间的计算。

 这里就从源码的角度,带大家窥探了Spring CRON表达式实现的奥秘,总结起来就是:还是借助ScheduledThreadPoolExecutor#schedule()实现的,对于其不支持的循环执行的问题,Spring采用了执行完一次任务后,回调schedule(),计算下一次执行时间,重新提交新的任务的方式,使其具备了循环调用的逻辑。

这里有小伙伴对于super.run()是调用我们定义的@Schedule定时方法有所不解,这里之所以可以这样调用,是因为在任务解析时,已经将反射需要的method信息和对象信息封装成ScheduledMethodRunnable了,其对应的run()就是反射执行该方法。在任务解析时,保存在了CronTask中。

再创建ReschedulingRunnable时,又把ScheduledMethodRunnable传递了过来,最后在父类DelegatingErrorHandlingRunnable的run()调用了ScheduledMethodRunnable的run(),所以这里的super.run(),其实就是ScheduledMethodRunnable#run(),也就是反射执行@Schedule标注的定时方法。

 我们总结一下cron任务的调度流程:

image.png

3.2.2 FixedDelay任务执行

 关于FixedDelay任务的执行,则是直接借助ScheduledThreadPoolExecutor#scheduleWithFixedDelay()完成的,这里我们简单看一下:

public ScheduledTask scheduleFixedDelayTask(IntervalTask task) {
   // 转换任务类型为FixedDelayTask
   FixedDelayTask taskToUse = (task instanceof FixedDelayTask ? (FixedDelayTask) task :
         new FixedDelayTask(task.getRunnable(), task.getInterval(), task.getInitialDelay()));
   return scheduleFixedDelayTask(taskToUse);
}

public ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, Date startTime, long delay) {
    long initialDelay = startTime.getTime() - System.currentTimeMillis();
    //直接使用ScheduledThreadPoolExecutor#scheduleWithFixedDelay()执行,
    // 但是先构建提交的Runnable对象,构建的DelegatingErrorHandlingRunnable类型
    return this.scheduledExecutor.scheduleWithFixedDelay(decorateTask(task, true), initialDelay, delay, TimeUnit.MILLISECONDS);
    // ...省略非核心代码
}

 这里通过源码可以很清楚的看到,在计算了相关参数后,也是先构建了任务提交需要的Runnable对象,然后就直接交由ScheduledThreadPoolExecutor#scheduleWithFixedDelay()调度执行了。

 这里我们就不看构建的DelegatingErrorHandlingRunnable的run()方法了,这里也是直接反射执行@Schedule标注的定时方法,并没有在做其他的事情。关于根据间隔循环调度执行,ScheduledThreadPoolExecutor本身就支持啊,这里是不需要再做什么的。

image.png

3.2.3 FixedRate任务执行

 关于FixedRate的执行,和FixedDelay完全一样,都是借助ScheduledThreadPoolExecutor本身的能力实现的,这里只是做转交而已,只是FixedRate调用的是scheduleWithFixedRate()而已。

image.png

另外关于FixedRate和FixedDelay,在单线程模型下,任务执行时间过长,对于下次任务执行时间的影响,本身也是JDK的能力和逻辑,和Spring本身无关哦。

image.png

3.3 定时任务触发执行的时机

 现在关于定时任务调度执行的细节,都已经说清楚了。还有一个问题在给小伙伴补充下,就是这些定时任务是什么时候开始调度的。

 其实这个问题不讨论也完全无伤大雅,不过贰师兄之前在使用一贯自研的、深度融合Spring的工具时,在碰到定时调度任务时,遇到了问题,后来排查下来发现:定时任务触发时机,和自研工具初始化时机重合,导致在定时任务中使用自研工具,出现了问题。所以这里和大家简单介绍一下:

&emsp,首先我们得先知道,Spring定时任务调度执行,是在接收到ContextRefreshedEvent事件后。而自研工具也是接收到这个事件后做核心类创建,然后注入Spring容器中的。

 由于是同一时机触发的,先后完成情况无法保证,出现了调度任务已经开始执行,但是核心工具类初始化还没有完成的情况,进而出现了,在调度任务中,使用自研工具出现空指针的情况。故这里和大家简单介绍一下,给大家避个坑。

// 接收ContextRefreshedEvent事件,调度定时任务
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
   if (event.getApplicationContext() == this.applicationContext) {
      // 查找调度线程池,提交调度任务
      finishRegistration();
   }
}

13_@Scheduled执行流程分析.png

这里需要注意,从代码上看,在所有bean实例化后回调阶段(也就是afterSingletonsInstantiated()被调用时),也有可能会触发定时调度任务的执行,因为代码中也会调用finishRegistration()

不过时机分析下来,却没有在这个时机调用,因为此时applicationContext已经有值了,这就涉及到ApplicationContextAware的回调时机了,贰师兄在聊透Spring bean的生命周期文章有介绍,小伙伴们根据这篇文章,自行分析一下吧,相信一定会有所收获的,这里不再赘述了。