Java SpringBoot @Scheduled
使用@Scheduled注解做定时任务需求需要格外小心,避免踩入不必要的坑。
比如,有一个需求:一是每隔5s做一次业务处理,另一个则是每隔10s做相应的业务处理,在Springboot项目中,代码如下:

  1. @EnableScheduling
  2. @Component
  3. public class ScheduleTask {
  4. @Scheduled(cron = "0/5 * * * * ?")
  5. public void taskA() {
  6. System.out.println("执行了ScheduleTask类中的taskA方法");
  7. }
  8. @Scheduled(cron = "0/10 * * * * ?")
  9. public void taskB() {
  10. System.out.println("执行了ScheduleTask类中的taskB方法");
  11. }
  12. }

@Component:是将ScheduleTask类注入到Spring容器中。
@Scheduled:表示这个方法是个定时任务
@EnableScheduling:开启定时任务
cron表达式:是一个字符串,字符串以5或6个空格隔开,分开共6或7个域,每一个域代表一个含义,分别为 [秒] [分] [小时] [日] [月] [周] [年]
如果对cron表达式不太了解,可以在 https://cron.qqe2.com/网站按照自己的需求生成相应的cron表达式。

产生的问题

1、定时器的任务默认是按照顺序执行的

创建定时器执行任务目的是为了让它多线程执行任务,但是后来才发现,@Scheduled注解的方法默认是按照顺序执行的,这会导致当一个任务挂死的情况下,其它任务都在等待,无法执行。
那么这是为什么呢?
首先说明一下@Scheduled注解加载的过程,以及它是如何执行的。
谨慎使用SpringBoot中的@Scheduled注解 - 图1
解析@Scheduled注解

  1. ScheduledAnnotationBeanPostProcessor类处理器解析带有@Scheduled注解的方法

谨慎使用SpringBoot中的@Scheduled注解 - 图2

  1. processScheduled方法处理@Scheduled注解后面的参数,并将其添加到任务列表中

谨慎使用SpringBoot中的@Scheduled注解 - 图3

  1. 执行任务。ScheduledTaskRegistrar类为Spring容器的定时任务注册中心。Spring容器通过线程处理注册的定时任务

首先,调用scheduleCronTask初始化定时任务。
谨慎使用SpringBoot中的@Scheduled注解 - 图4
然后,在ThreadPoolTaskShcedule类中,会对线程池进行初始化,线程池的核心线程数量为1,
谨慎使用SpringBoot中的@Scheduled注解 - 图5
阻塞队列为DelayedWorkQueue
谨慎使用SpringBoot中的@Scheduled注解 - 图6
因此,原因就找到了,当有多个方法使用@Scheduled注解时,就会创建多个定时任务到任务列表中,当其中一个任务没执行完时,其它任务在阻塞队列当中等待,因此,所有的任务都是按照顺序执行的,只不过由于任务执行的速度相当快,感觉任务都是多线程执行的。
下面举例来验证一下,将上述的某个定时任务添加睡眠时间,观察另一个定时任务是否输出。

  1. @Slf4j
  2. @EnableScheduling
  3. @Component
  4. public class ScheduleTask {
  5. private static final ThreadLocal<Integer> threadLocalA = new ThreadLocal<>();
  6. @Scheduled(cron = "0/2 * * * * ?")
  7. public void taskA() {
  8. try {
  9. log.info("执行了ScheduleTask类中的taskA方法");
  10. Thread.sleep(TimeUnit.SECONDS.toMillis(10));
  11. } catch (InterruptedException e) {
  12. e.printStackTrace();
  13. }
  14. }
  15. @Scheduled(cron = "0/1 * * * * ?")
  16. public void taskB() {
  17. int num = threadLocalA.get() == null ? 0 : threadLocalA.get();
  18. log.info("taskB方法执行次数:{}", ++num);
  19. threadLocalA.set(num);
  20. }
  21. }

输出结果:
谨慎使用SpringBoot中的@Scheduled注解 - 图7
那么如何解决顺序执行呢?答案是配置定时任务线程池:

  1. @Configuration
  2. public class ScheduleConfig implements SchedulingConfigurer {
  3. @Override
  4. public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
  5. taskRegistrar.setScheduler(getExecutor());
  6. }
  7. @Bean
  8. public Executor getExecutor(){
  9. return new ScheduledThreadPoolExecutor(5);
  10. }
  11. }

再次启动观察输出结果:
谨慎使用SpringBoot中的@Scheduled注解 - 图8
从输出结果可以看到,即使testA休眠,但是testB仍然正常执行,并且其还复用了其它线程,导致执行次数发生了变化。


2、当系统时间发生改变时,@Scheduled注解失效

另外一种情况就是在配置完线程池之后,当你手动修改服务器时间时,目前做的测试就是服务器时间调前,则会导致注解失效,而服务器时间调后,则不会影响注解的作用。
那么原因是什么呢?
在查询资料后得出:

JVM启动之后会记录当前系统时间,然后JVM根据CPU ticks自己来算时间,此时获取的是定时任务的基准时间。如果此时将系统时间进行了修改,当Spring将之前获取的基准时间与当下获取的系统时间进行比对不一致,就会造成Spring内部定时任务失效。因为此时系统时间发生变化了,不会触发定时任务。

那么这时候怎么解决呢?
1. 重启项目。这在生产环境中肯定是不允许的,所以Pass
2. 无奈之举,改方案。怎么改呢?就是不适用@Scheduled注解,改成
ScheduledThreadPoolExecutor进行替代。
举例说明:下面是项目中所写的部分定时任务
谨慎使用SpringBoot中的@Scheduled注解 - 图9
ScheduledThreadPoolExecutor 执行流程:

  1. 当调用scheduleAtFixedRate()方法或者scheduleWithFixedDelay()方法时,会向ScheduledThreadPoolExecutorDelayQueue添加一个实现了RunnableScheduledFuture接口的ScheduleFutureTask
  2. 线程池中的线程从DelayQueue中获取ScheduleFutureTask,然后执行任务。

谨慎使用SpringBoot中的@Scheduled注解 - 图10
方法说明:

  1. public ScheduledFuture scheduleAtFixedRate(Runnable command,long initialDelay,long period,TimeUnit unit)

scheduleAtFixedRate方法的作用是预定在初始的延迟结束后,周期性地执行给定的任务,周期长度为period,其中initialDelay为初始延迟。

  1. public ScheduledFuture scheduleWithFixedDelay(Runnable command,long initialDelay,long delay,TimeUnit unit);

scheduleWithFixedDelay方法的作用是预定在初始的延迟结束后周期性地执行给定任务,在一次调用完成和下一次调用开始之间有长度为delay的延迟,其中initialDelay为初始延迟。