本文源码分析基于jdk17+SpringBoot3.4.0。
一.错误案例
由于SpringBoot的@Scheduled默认是使用单线程执行的,因此我们尝试写一个定时任务线程池,并行执行多个定时任务。
1.代码
(1)定时任务类
- 在定时任务类中编写了三个要执行的定时任务task1、task2、task3。
-
其中task1和task3只需要3s就能执行完,而task2要3000s才能执行完。
-
@Scheduled(cron = "*/10 * * * * ?"):表示从程序启动后每隔10s执行一遍该任务。
-
@Async("taskExecutor"):表示要使用“taskExecutor”来异步执行该类下的所有定时任务。
那么我们再来看一下“taskExecutor”的配置
(2)taskExecutor
- 我们在一个@Configuration注解下的配置类中配置taskExecutor,并交给IOC容器管理。(自己任意编写一个配置类打上@Configuration即可,此处没有截图类信息)
- 在taskExecutor()中我们创建了一个ThreadPoolTaskExecutor实例,并配置核心线程数和最大线程数都为3,即没有救急线程;配置等待队列容量为5;自定义了拒绝策略,以日志形式打出。
- 也就是说当核心线程3个线程都被占用,且等待队列5个位置也被占用时,会触发拒绝策略:以日志形式告警。
(3)启动类
- 记得加上@EnableScheduling和@EnableAsync
2.运行
- 可以看到由于task2需要3000s才能执行完毕,当线程池中3个核心线程都被分配到执行task2时,后续的其他定时任务都将进入等待队列,当等待队列满后就会出现告警。
3.问题
(1)上述的代码看似没有问题,但大家是否产生疑惑:多线程并发执行定时任务,和@Async异步执行好像没什么关系吧?异步同步是线程交互之间的事情,但是多线程并发各干各的,跟异步何干?
(2)带着这个疑问,我们进入@Scheduled的源码分析。
二.源码分析
1.@Scheduled注解
(1)翻译一下上图中标出的说明信息:
@Scheduled
注解的处理是通过注册一个 ScheduledAnnotationBeanPostProcessor
来实现的。这可以手动完成,或者更方便地,通过 <task:annotation-driven/>
XML 元素或 @EnableScheduling
注解来完成。
(2)分析:由这段说明可以得到以下两个信息
- @Scheduled注解是通过“ScheduledAnnotationBeanPostProcessor”这个类起作用的,也就是说由该类负责完成定时任务。
- “ScheduledAnnotationBeanPostProcessor”这个类可以通过XML配置或者@EnableScheduling注解的方式来导入IOC容器。
在注解横行的SpringBoot中,我们主要关心以注解的方式注入,因此接下来进入@EnableScheduling的分析,看看它是怎么将“ScheduledAnnotationBeanPostProcessor”这个类注加入IOC容器的。
2.@EnableScheduling注解
(1)@Import注解:使用@Import导入的类会被加入到SpringIOC容器中,那么我们再来看看这个导入的SchedulingConfiguration是何方神圣。
(2)SchedulingConfiguration类
- 原来这个SchedulingConfiguration类就是个配置类,其通过@Bean注解方法的方式,将“ScheduledAnnotationBeanPostProcessor”这个类交给IOC容器管理
(3)总结:@EnableScheduling这个注解用来将“SchedulingConfiguration”这个类加入IOC容器——>“SchedulingConfiguration”这个类又将“ScheduledAnnotationBeanPostProcessor”这个类加入IOC容器——>“ScheduledAnnotationBeanPostProcessor”会对打上@Scheduled注解的方法执行定时任务。
分析完“ScheduledAnnotationBeanPostProcessor”是如何被加入IOC容器的,接下来就是看看“ScheduledAnnotationBeanPostProcessor”是怎么执行定时任务的。
3.ScheduledAnnotationBeanPostProcessor类
分析“ScheduledAnnotationBeanPostProcessor”类我们要解决两个问题:
- “ScheduledAnnotationBeanPostProcessor”是怎么发现所有被打上@Scheduled注解的方法的?
- “ScheduledAnnotationBeanPostProcessor”是怎么为所有被打上@Scheduled注解的方法执行定时任务的?
(1)先来解决第一个问题——“ScheduledAnnotationBeanPostProcessor”是怎么发现所有被打上@Scheduled注解的方法的?
在“ScheduledAnnotationBeanPostProcessor”的继承图中我们发现了熟悉的“BeanPostProcessor”接口,在Bean的装配流程中,通常由“BeanPostProcessor”来负责Bean的AOP部分。
因此我们来看一下“ScheduledAnnotationBeanPostProcessor”是如何实现接口中的两个方法的:
可以看到对postProcessBeforeInitialization()只是做了默认实现,而postProcessAfterInitialization()的实现看来是大有文章,那么我们就来重点关注postProcessAfterInitialization()方法:
- 程序启动时所有Bean对象都会经过postProcessAfterInitialization(),而postProcessAfterInitialization()会将经过的所有Bean对象的所有@Scheduled方法收集起来,并且去执行processScheduled()。
- 还记得我们上面的案例中的Schedule类吗,它在作为Bean对象被装配的过程中也会经过postProcessAfterInitialization(),里面被打上@Scheduled的task1()、task2()和task3()都会被收集起来。我们debug验证一下:
首先在postProcessAfterInitialization()中打个条件断点,只有当我们自己的Bean对象“Schedule”经过到这时才停下。(注意Bean对象名称默认是类名且首字母小写)
接下来执行debug
可以看到“Schedule”中三个被打上@Scheduled的方法都被装入annotatedMethods了,接下来就是对它们执行processScheduled()。那么我们接着再进入processScheduled()方法。
processScheduled()会根据该任务是同步执行还是异步执行而进入不同的方法
但是我们发现不管是异步还是同步,最后都会进入processScheduledTask(),胜利的曙光就在眼前,我们进入processScheduledTask()中一探究竟。
- 可以看到processScheduledTask()的方法体简直就是粒粒分明啊!它分别对三种类型(即Cron、FixedDelay、FixedRate)表示的定时任务做处理,最后都将它们加入任务队列,等待被执行。
- 总结:ScheduledAnnotationBeanPostProcessor类中的postProcessAfterInitialization()方法会在所有Bean的装配过程中拦截它们,将每个Bean中被打了@Scheduled的方法收集起来,并对这些方法都执行processScheduled()——>processScheduled()会根据同步异步对方法进行处理,但最后都会对它们调用processScheduledTask()方法——>processScheduledTask()会根据定时任务的类型不同而进行处理,最后将这些定时任务都加入任务队列,等待被执行。
(2)接下来就是第二个问题——“ScheduledAnnotationBeanPostProcessor”是怎么为所有被打上@Scheduled注解的方法执行定时任务的?
在第一个问题的解决过程中我们知道此时所有定时任务都被加入了任务队列,接下来只需要被执行。那我们就来看看ScheduledAnnotationBeanPostProcessor是如何启动执行这些定时任务的。
要启动任务队列中的所有定时任务,至少也要等所有定时任务都被收集完吧;而定时任务的收集是在Bean的装配过程中执行的,也就是说至少要等到所有Bean都被处理完了,才开始启动执行定时任务;那么ScheduledAnnotationBeanPostProcessor只需要监听所有Bean何时处理完即可做出响应;基于这个想法我们再次回到ScheduledAnnotationBeanPostProcessor的继承图。
可以看到ScheduledAnnotationBeanPostProcessor还实现了ApplicationListener接口
而ApplicationListener接口中的onApplicationEvent()正是用于响应Spring事件,我们接下来看看ScheduledAnnotationBeanPostProcessor是如何实现该方法的
可以看到ScheduledAnnotationBeanPostProcessor的onApplicationEvent()方法是用来响应ContextRefreshedEvent事件的,而Spring的IOC容器启动过程中,当所有Bean都处理完了,就会发出一个ContextRefreshedEvent事件。胜利仿佛就在眼前!
但还没完,我们只是知道ScheduledAnnotationBeanPostProcessor在所有Bean处理完成后,就会执行onApplicationEvent()方法,还没到如何启动定时任务呢。我们再进入方法体中的finishRegistration()看看
emmmm,方法体内就只是在做一些配置性工作,没见到提交动作;那我们继续进入afterPropertiesSet()
可以看到afterPropertiesSet()方法调用了scheduleTasks(),而scheduleTasks()就是对各种类型的定时任务进行提交,我们选择其中一个scheduleTriggerTask()进入
这下破案了嘛,在scheduleTriggerTask()会将传入的定时任务提交给线程池去处理。其它的scheduleCronTask()、scheduleFixedRateTask()等方法内也是如此。完工!!!
做个总结:
IOC容器启动过程中,当所有Bean处理完毕时,Spring会发出一个ContextRefreshedEvent()事件——>ScheduledAnnotationBeanPostProcessor监听到此事件,并触发其onApplicationEvent()方法——>onApplicationEvent()最终会执行到scheduleTasks()方法——>scheduleTasks()就是把各类定时任务通通提交给线程池处理。
三.错误案例分析
1.发现问题
我们再次回到最后的scheduleTasks()方法
可以看到,如果我们没有配置线程池,那么会默认以单线程的方式去执行所有定时任务。在案例中我们通过@Configuration+@Bean的方式提供了一个线程池“taskExecutor”供定时任务使用,那么这个线程池真的被使用了吗?让我们打个断点,debug一探究竟
好吧,taskScheduler不为null,说明已经配置了线程池。
但是,嗯?此时的taskScheduler的beanName是“internalScheduledAnnotationProcessor”,这也不是我们交给IOC管理的“taskExecutor”啊,这是怎么回事呢?让我们进入分析。
2.分析问题
我们配置的“taskExecutor”没有被赋值个taskScheduler,说明到这之前已经在别的地方配置了线程池,那么我们再往前看看,scheduleTasks()的调用链是:finishRegistration()——>afterPropertiesSet()——>scheduleTasks(),其中afterPropertiesSet()的方法体就只是调用了scheduleTasks(),因此我们再次进入finishRegistration()
这一堆配置信息,emmmmm,发现了疑犯,之前我们进入finishRegistration()是为了看“ScheduledAnnotationBeanPostProcessor”是在何时提交定时任务,当时在里面没有看到提交动作,于是直接忽略了这一堆配置信息,进入afterPropertiesSet();现在看来,貌似就是这一堆配置信息对我们的线程池来了一波狸猫换太子,将我们的“taskExecutor”换成了“internalScheduledAnnotationProcessor”;那么我们还是打个断点,debug一探究竟
果真如此,由于我们的“taskExecutor”并没有被使用,因此Spring才会替我们创建一个“internalScheduledAnnotationProcessor”对象。
但是我们的“taskExecutor”为什么没有被赋值给taskScheduler对象呢?我们来看一下里面的taskScheduler对象可以通过什么方式被赋值
配置代码段中对三种可能的方式都进行了校验执行,其中最后一种是要实现一个SchedulingConfigurer接口的confugureTasks()方法,我们代码案例中没有实现这个接口,因此进入前两种方式this.registrar.setScheduler(this.scheduler)和this.registrar.setTaskScheduler(this.localScheduler)
可以看到这两个方法只会依据TaskScheduler类型的对象和ScheduledExecutorService类型的对象来对taskScheduler进行赋值操作。那么我们的“taskExecutor”对象呢?
满朝文武,竟无一人能言。怪不得我们的“taskExecutor”没有被赋值给taskScheduler对象,这两个对象的类型压根就对不上。
这下破案了,由于我们配置的“taskExecutor”属于ThreadPoolTaskExecutor类型,在Bean的装配过程中根本不会被赋值给负责处理定时任务的taskScheduler。
但是你可能会疑惑,不对啊,在配置“taskExecutor”时我们还配置了线程名前缀为“task-executor-”,从执行结果来看,最终也确实是由“taskExecutor”中的线程来执行定时任务啊?
这是因为用来提交任务的线程池和用来执行任务的线程池并不是同一个线程池,负责定时任务的taskScheduler其实并没有负责执行定时任务,由于加了@Async注解,因此taskScheduler其实只负责把定时任务推送到我们的异步线程池“taskExecutor”,最终由“taskExecutor”来执行定时任务。原本一个线程池就搞定的事情,现在要两个线程池来做。
3.解决问题
在上文的分析中我们知道了由于“taskExecutor”类型不匹配,因此未能成功赋值给taskScheduler。这说明我们的“taskExecutor”配置方式有问题,不应该使用传统的ThreadPoolTaskExecutor类型配置。
finishRegistration()中,Spring会校验是否存在TaskScheduler、ScheduledExecutorService、SchedulingConfigurer类型的对象,并配置给taskScheduler。这说明我们如果要为定时任务配置线程池,应该往这三个类型上面靠。
因此借此引出配置定时任务线程池的3种方式。
四.配置定时任务线程池的3种方式
在试验下述三种方式时,记得把样例代码中的@Async给删了,否则最后还是会走的异步线程池执行的逻辑。
1.ThreadPoolTaskScheduler
(1)之前我们的“taskExecutor”是ThreadPoolTaskExecutor类型的,它的继承图中并没有出现TaskScheduler。那我们再来看一下与ThreadPoolTaskExecutor名字相似的ThreadPoolTaskScheduler的继承图
出现了TaskScheduler,说明这个类才是用来给定时任务配置线程池的。
(2)配置ThreadPoolScheduler只需要将其交给SpringIOC容器管理即可,可以单独开一个类打上@Component,也可以在用@Configuration+@Bean的方式。下面采用第二种
这里要注意BeanName不能为“taskScheduler”,因为Spring在启动IOC时也会装配一个名为“taskScheduler”的Bean。若一定要将BeanName设为“taskScheduler”,可以修改配置文件allow-bean-definition-overriding: true,允许覆盖Bean。
(3)执行
可以看到确实是我们配置的“myTaskScheduler”在执行定时任务。
2.ScheduledThreadPoolExecutor
(1)ScheduledThreadPoolExecutor的继承图
可以看到其实现了ScheduledExecutorService接口,说明也可以用于配置定时任务线程池。
(2)配置ScheduledExecutorService只需要将其交给SpringIOC容器管理即可,可以单独开一个类打上@Component,也可以在用@Configuration+@Bean的方式。下面采用第二种
(3)执行
3.SchedulingConfigurer
(1)finishRegistration()最后会找到IOC容器中所有SchedulingConfigurer类型的Bean,并且去执行它们的configurerTasks()方法,因此我们只需要自己编写一个SchedulingConfigurer实现类即可。
(2)SchedulingConfigurer实现类:配置ThreadPoolTaskScheduler
既然SchedulingConfigurer的configureTasks()方法可以配置ThreadPoolTaskScheduler,那么同理,其也就能配置ScheduledThreadPoolExecutor
(3)SchedulingConfigurer实现类:配置ScheduledThreadPoolExecutor
建议使用最后一种方式,即以自定义SchedulingConfigurer实现类的方式去配置定时任务的线程池。