一、概述

在Java环境下创建定时任务有多种方式:

  • 使用while循环配合 Thread.sleep(),虽然稍嫌粗陋但也勉强可用
  • 使用 Timer和 TimerTask
  • 使用 ScheduledExecutorService
  • 定时任务框架,如Quartz

    二、SpringBoot定时任务

    1、同步任务

    在SpringBoot中仅通过注解就可以实现(同步任务和异步)常用的定时任务。步骤就两步:
    1、在启动类中添加 @EnableScheduling注解
    2、在目标方法中添加 @Scheduled注解,同时在 @Scheduled注解中添加触发定时任务的元数据。

    1. @Scheduled(fixedRate = 1000)
    2. public void job() {
    3. System.out.println(Thread.currentThread().getId() + " ----- job1 ----- " + System.currentTimeMillis());
    4. }

    注意: 目标方法需要没有任何参数,并且返回类型为 void 。
    这里的定时任务元数据是“fixRate=1000”,意思是固定间隔每1000毫秒即执行一次该任务。
    再来看几个 @Schedule注解的参数:

  • fixedRate:设置定时任务执行的时间间隔,该值为当前任务启动时间与下次任务启动时间之差;

  • fixedDelay:设置定时任务执行的时间间隔,该值为当前任务结束时间与下次任务启动时间之差;
  • cron:通过cron表达式来设置定时任务启动时间,在Cron Generator网站可以直接生成cron表达式。

这样创建的定时任务存在一个问题:如存在多个定时任务,这些任务会同步执行,也就是说所有的定时任务都是在一个线程中执行。
再添几个定时任务来执行下看看:

  1. @Scheduled(fixedRate = 1000)
  2. public void job1() {
  3. System.out.println(Thread.currentThread().getId() + " ----- job1 ----- " + System.currentTimeMillis());
  4. }
  5. @Scheduled(fixedRate = 1000)
  6. public void job2() {
  7. System.out.println(Thread.currentThread().getId() + " ----- job2 ----- " + System.currentTimeMillis());
  8. }
  9. @Scheduled(fixedRate = 1000)
  10. public void job3() {
  11. System.out.println(Thread.currentThread().getId() + " ----- job3 ----- " + System.currentTimeMillis());
  12. }

代码中一共创建了三个定时任务,每个定时任务的执行间隔都是1000毫秒,在任务体中输出了执行任务的线程ID和执行时间。
看下执行结果:

  1. 20 ----- job3 ----- 1573120568263
  2. 20 ----- job1 ----- 1573120568263
  3. 20 ----- job2 ----- 1573120568263
  4. 20 ----- job3 ----- 1573120569264
  5. 20 ----- job1 ----- 1573120569264
  6. 20 ----- job2 ----- 1573120569264
  7. 20 ----- job3 ----- 1573120570263
  8. 20 ----- job1 ----- 1573120570263
  9. 20 ----- job2 ----- 1573120570263

可以看到这三个定时任务的执行有如下的特点:

  • 所有的定时任务每次都是在同一个线程上执行;
  • 虽然未必是job1第一个开始执行,但是每批任务的执行次序是固定的——这是由fixRate参数决定的

这样的定时任务已经能够覆盖绝大部分的使用场景了,但是它的缺点也很明显:前面的任务执行时间过长必然会影响之后的任务的执行。为了解决这个问题,我们需要异步执行定时任务。接下来的部分我们将主要着眼于如何实现异步执行定时任务。

2、异步任务:

通过@Async注解实现异步定时任务
最常用的方式是使用 @Async注解来实现异步执行定时任务。启用 @Async注解的步骤如下:
在启动类中添加 @EnableAsync注解:

  1. EnableAsync
  2. @EnableScheduling
  3. @SpringBootApplication
  4. public class MyApplication {
  5. public static void main(String[] args) {
  6. SpringApplication.run(MyApplication.class, args);
  7. }
  8. }

在定时任务方法上添加 @Async注解

  1. @Async
  2. @Scheduled(fixedRate = 1000)
  3. public void job1() {
  4. System.out.println(Thread.currentThread().getId() + " ----- job1 ----- " + System.currentTimeMillis());
  5. }

运行结果:

  1. 25 ----- job1 ----- 1573121781415
  2. 24 ----- job3 ----- 1573121781415
  3. 26 ----- job2 ----- 1573121781415
  4. 30 ----- job3 ----- 1573121782298
  5. 31 ----- job1 ----- 1573121782299
  6. 32 ----- job2 ----- 1573121782299
  7. 25 ----- job2 ----- 1573121783304
  8. 35 ----- job3 ----- 1573121783306
  9. 36 ----- job1 ----- 1573121783306

3、Cron 表达式

Cron 表达式是一个字符串,分为6 或7 个域,每一个域代表一个含义
Cron 有如下两种语法格式:
(1) Seconds Minutes Hours Day Month Week Year
(2)Seconds Minutes Hours Day Month Week
corn 从左到右(用空格隔开):秒 分 时 日期 月份 星期 年份

位置 时间单位 允许值 允许的特殊字符
1 0-59 , - * /
2 0-59 , - * /
3 0-23 , - * /
4 1-31 , - * ?/ L W C
5 1-12 , - * /
6 星期 1-7 , - * ? / L C #
7 1970-2099 , - * /

Cron 表达式的时间字段除允许设置数值外,还可使用一些特殊的字符,提供列表、范围、通配符等功
能,细说如下:
●星号():可用在所有字段中,表示对应时间域的每一个时刻,例如,在分钟字段时,表示“每分钟”;
●问号(?):该字符只在日期和星期字段中使用,它通常指定为“无意义的值”,相当于占位符;
●减号(-):表达一个范围,如在小时字段中使用“10-12”,则表示从10 到12 点,即10,11,12;
●逗号(,):表达一个列表值,如在星期字段中使用“MON,WED,FRI”,则表示星期一,星期三和星期五;
●斜杠(/):x/y 表达一个等步长序列,x 为起始值,y 为增量步长值。如在分钟字段中使用0/15,则
表示为0,15,30 和45 秒,而5/15 在分钟字段中表示5,20,35,50,你也可以使用/y,它等同于0/y;
●L:该字符只在日期和星期字段中使用,代表“Last”的意思,但它在两个字段中意思不同。L 在日期字段中,表示这个月份的最后一天,如一月的31 号,非闰年二月的28 号;如果L 用在星期中,则表示星期六,等同于7。但是,如果L 出现在星期字段里,而且在前面有一个数值X,则表示“这个月的最后X 天”,例如,6L 表示该月的最后星期五;
●W:该字符只能出现在日期字段里,是对前导日期的修饰,表示离该日期最近的工作日。例如15W
表示离该月15 号最近的工作日,如果该月15 号是星期六,则匹配14 号星期五;如果15 日是星期日,则匹配16 号星期一;如果15 号是星期二,那结果就是15 号星期二。但必须注意关联的匹配日期不能够跨月,如你指定1W,如果1 号是星期六,结果匹配的是3 号星期一,而非上个月最后的那天。W 字符串只能指定单一日期,而不能指定日期范围;
●LW 组合:在日期字段可以组合使用LW,它的意思是当月的最后一个工作日;
●井号(#):该字符只能在星期字段中使用,表示当月某个工作日。如6#3 表示当月的第三个星期五(6
表示星期五,#3 表示当前的第三个),而4#5 表示当月的第五个星期三,假设当月没有第五个星期三,忽略不触发;
● C:该字符只在日期和星期字段中使用,代表“Calendar”的意思。它的意思是计划所关联的日期,如果日期没有被关联,则相当于日历中所有日期。例如5C 在日期字段中就相当于日历5 日以后的第一天。
1C 在星期字段中相当于星期日后的第一天。
Cron 表达式对特殊字符的大小写不敏感,对代表星期的缩写英文大小写也不敏感。
例子:
@Scheduled(cron = “0 0 1 1 1 ?”)//每年一月的一号的1:00:00 执行一次
@Scheduled(cron = “0 0 1 1 1,6 ?”) //一月和六月的一号的1:00:00 执行一次
@Scheduled(cron = “0 0 1 1 1,4,7,10 ?”) //每个季度的第一个月的一号的1:00:00 执行一次
@Scheduled(cron = “0 0 1 1
?”)//每月一号凌晨1 点执行一次
@Scheduled(cron=”0 0 1 *”) //每天凌晨1 点执行一次

三、深入底层

1、通过配置实现异步定时任务

现在我们有必要稍稍深入了解下springboot定时任务的执行机制了。
springboot的定时任务主要涉及到两个接口: TaskScheduler和 TaskExecutor。在springboot的默认定时任务实现中,这两个接口的实现类是 ThreadPoolTaskScheduler和 ThreadPoolTaskExecutor。
ThreadPoolTaskScheduler负责实现任务的定时执行机制,而 ThreadPoolTaskExecutor则负责实现任务的异步执行机制。二者中, ThreadPoolTaskScheduler执行栈更偏底层一些。
尽管在职责上有些区别,但是两者在底层上都是依赖java的线程池机制实现的: ThreadPoolTaskScheduler依赖的底层线程池是 ScheduledExecutorService,springboot默认为其提供的coreSize是1,所以默认的定时任务都是在一个线程中执行; ThreadPoolTaskExecutor依赖的底层线程池是 ThreadPoolExecutor,springboot默认为其提供的corePoolSize是8。
说到这里应该清楚了:我们可以不添加 @Async注解,仅通过调整 ThreadPoolTaskScheduler依赖的线程池的coreSize也能实现多线程异步执行;同样的,即使添加了 @Async注解,将 ThreadPoolTaskExecutor依赖的线程池的corePoolSize设置为1,那定时任务还是只能在一个线程上同步执行。看下springboot的相关配置项

  1. spring:
  2. task:
  3. scheduling:
  4. pool:
  5. size: 1
  6. execution:
  7. pool:
  8. core-size: 2

其中spring.task.scheduling是 ThreadPoolTaskScheduler的线程池配置项,spring.task.execution是 ThreadPoolExecutor的线程池配置项。
再稍稍扩展下: @Async注解的value属性就是用来指明使用的 TaskExecutor实例的。默认值是空字符串,表示使用的是springboot自启动的 TaskExecutor实例。如有需要,也可以使用自定义的 TaskExecutor实例,如下:

  1. /**
  2. * 配置线程池
  3. * @return
  4. */
  5. @Bean(name = "scheduledPoolTaskExecutor")
  6. public ThreadPoolTaskExecutor getAsyncThreadPoolTaskExecutor() {
  7. ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
  8. taskExecutor.setCorePoolSize(20);
  9. taskExecutor.setMaxPoolSize(200);
  10. taskExecutor.setQueueCapacity(25);
  11. taskExecutor.setKeepAliveSeconds(200);
  12. taskExecutor.setThreadNamePrefix("my-task-executor-");
  13. // 线程池对拒绝任务(无线程可用)的处理策略,目前只支持AbortPolicy、CallerRunsPolicy;默认为后者
  14. taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
  15. //调度器shutdown被调用时等待当前被调度的任务完成
  16. taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
  17. //等待时长
  18. taskExecutor.setAwaitTerminationSeconds(60);
  19. taskExecutor.initialize();
  20. return taskExecutor;
  21. }

此外,还有一种做法是通过提供自定义的 TaskScheduler Bean实例来实现异步执行。要提供提供自定义的 TaskScheduler 实例,可以直接通过 @Bean注解声明创建,也可以在 SchedulingConfigurer接口中配置。这些在后面我们会提到。

2、调用SpringBoot接口实现定时任务

有时候会需要将定时任务的定时元数据写在数据库或其他配置中心以便统一维护。这种情况就不是通过注解能够搞定的了,此时我们需要使用springboot定时任务一些组件来自行编程实现。常用的组件包括 TaskScheduler、 Triger接口和 SchedulingConfigurer接口。
注意:因为我们用到了springboot的定时任务组件,所以仍然需要在启动类上添加 @EnableScheduling注解。

3、Trigger接口

Trigger接口主要用来设置定时元数据。要通过程序实现定时任务就不能不用到这个接口。这个接口有两个实现类:

  • PeriodicTrigger用来配置固定时长的定时元数据
  • CronTrigger用来配置cron表达式定时元数据

    4、使用TaskScheduler接口

    TaskScheduler接口前面我们提过,这个接口需要配合 Trigger接口一起使用来实现定时任务,看个例子:

    1. @Autowired
    2. private TaskScheduler taskScheduler;
    3. public void job() {
    4. int fixRate = 10;
    5. taskScheduler.schedule(() -> System.out.println(" job4 ----- " + System.currentTimeMillis()),
    6. new PeriodicTrigger(fixRate, TimeUnit.SECONDS));
    7. }

    在上面的代码里,我们使用 @Autowired注解获取了springbootr容器里默认的 TaskScheduler实例,然后通过 PeriodicTrigger设置了定时元数据,定时任务的任务体则是一个 Runable接口的实现(在这里只是输出一行信息)。
    因为默认的 TaskScheduler实例的线程池coreSize是1,所以如有多个并发任务,这些任务的执行仍然是同步的。要调整为异步可以在配置文件中配置,也可以通过提供一个自定义的 TaskScheduler实例来设置:

    5、使用SchedulingConfigurer接口

    SchedulingConfigurer接口的主要用处是注册基于 Trigger接口自定义实现的定时任务。
    在实现 SchedulingConfigurer接口后,通常还需要使用 @Configuration注解(当然启动类上的 @EnableScheduling注解也不能少)来声明它实现类。
    这个接口唯一的一个方法就是configureTasks,字面意思是配置定时任务。这个方法最重要的参数是一个 ScheduledTaskRegistrar定时任务注册类实例,该类有8个方法,允许我们以不同的方式注册定时任务。
    简单做了个实现:

    1. Configuration
    2. public class MyTaskConfigurer implements SchedulingConfigurer {
    3. @Override
    4. public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
    5. taskRegistrar
    6. .addCronTask(
    7. () -> System.out.println(Thread.currentThread().getId() + " --- job5 ----- " + System.currentTimeMillis()),
    8. "0/1 * * * * ?"
    9. );
    10. taskRegistrar
    11. .addFixedDelayTask(
    12. () -> System.out.println(Thread.currentThread().getId() + " --- job6 ----- " + System.currentTimeMillis()),
    13. 1000
    14. );
    15. taskRegistrar
    16. .addFixedRateTask(
    17. () -> System.out.println(Thread.currentThread().getId() + " --- job7 ----- " + System.currentTimeMillis()),
    18. 1000
    19. );
    20. }
    21. }

    这里我们只使用了三种注册任务的方法,分别尝试注册了fixDelay、fixRate以及cron触发的定时任务。
    springboot会自动启动注册的定时任务。看下执行结果:

    1. 22 --- job7 ----- 1573613616349
    2. 22 --- job6 ----- 1573613616350
    3. 22 --- job5 ----- 1573613617001
    4. 22 --- job7 ----- 1573613617352
    5. 22 --- job6 ----- 1573613617353
    6. 22 --- job5 ----- 1573613618065
    7. 22 --- job7 ----- 1573613618350
    8. 22 --- job6 ----- 1573613618355
    9. 22 --- job5 ----- 1573613619002

    在执行结果中可以看到这里的任务也是在单一线程同步执行的。要设置为异步执行也简单,因为 SchedulingConfigurer接口的另一个作用就是为定时任务提供自定义的 TaskScheduler实例。来看下:

    1. @Override
    2. public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
    3. ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    4. scheduler.setThreadNamePrefix("my-task-scheduler");
    5. scheduler.setPoolSize(10);
    6. scheduler.initialize();
    7. taskRegistrar.setTaskScheduler(scheduler);
    8. }

    在这里,我将之前注册的定时任务去掉了,目的是想验证下这里的配置是否对注解实现的定时任务有效。经检验是可行的。当然对在configureTasks方法中配置的定时任务肯定也是有效的。我就不一一贴结果了。
    另外,需要注意:如 SchedulingConfigurer接口实例已经注入,将无法再获取到springboot默认提供的 TaskScheduler接口实例。

    四、通过Quartz实现定时任务

    Quartz是一个非常强大的定时任务管理框架。短短的一篇文章未必能介绍清楚Quartz的全部用法。所以这里只是简单地演示下如何在springboot中是如何使用Quartz的。更多的用法建议优先参考Quartz官方文档。
    在spring-boot-web 2.0及之后的版本,已经自动集成了quartz,如果不使用spring-boot-web或使用较早的版本的话我们还需要加一些依赖:

    1. <!-- quartz -->
    2. <dependency>
    3. <groupId>org.quartz-scheduler</groupId>
    4. <artifactId>quartz</artifactId>
    5. </dependency>
    6. <!-- spring集成quartz -->
    7. <dependency>
    8. <groupId>org.springframework</groupId>
    9. <artifactId>spring-context-support</artifactId>
    10. </dependency>
    11. <!-- SchedulerFactoryBean依赖了tx包中的PlatformTransactionManager类,因为quartz的分布式功能是基于数据库完成的 -->
    12. <dependency>
    13. <groupId>org.springframework</groupId>
    14. <artifactId>spring-tx</artifactId>
    15. </dependency>

    添加完成这些依赖后,springboot服务在启动时也会自启动内部的quartz。事实上springboot已经为我们准备好了几乎全部的quartz的配置。我们要做的只是把自定义的任务填进去。
    首先我们需要创建一个Job实例,来实现Job的具体行为。 ```java @Component public class MyQuartzJob extends QuartzJobBean {

    @Override protected void executeInternal(JobExecutionContext context) {

    1. JobDataMap map = context.getMergedJobDataMap();
    2. // 从作业上下文中取出Key
    3. String key = map.getString("key");
    4. System.out.println(Thread.currentThread().getId() + " -- job8 ---------------------->>>>" + key);

    }

}

  1. QuartzJobBeanSpring提供的Quartz Job抽象类。在实现这个类的时候我们可以获取注入到spring中的其他Bean。<br />配置Job
  2. ```java
  3. @Configuration
  4. public class QuartzConfig implements InitializingBean {
  5. @Autowired
  6. private SchedulerFactoryBean schedulerFactoryBean;
  7. @Override
  8. public void afterPropertiesSet() throws Exception {
  9. config();
  10. }
  11. private void config() throws SchedulerException {
  12. Scheduler scheduler = schedulerFactoryBean.getScheduler();
  13. JobDetail jobDetail = buildJobDetail();
  14. Trigger trigger = buildJobTrigger(jobDetail);
  15. scheduler.scheduleJob(jobDetail, trigger);
  16. }
  17. private JobDetail buildJobDetail() {
  18. // 用来存储交互信息
  19. JobDataMap dataMap = new JobDataMap();
  20. dataMap.put("key", "zhyea.com");
  21. return JobBuilder.newJob(MyQuartzJob.class)
  22. .withIdentity(UUID.randomUUID().toString(), "chobit-job")
  23. .usingJobData(dataMap)
  24. .build();
  25. }
  26. private Trigger buildJobTrigger(JobDetail jobDetail) {
  27. return TriggerBuilder.newTrigger()
  28. .forJob(jobDetail)
  29. .withIdentity(jobDetail.getKey().getName(), "chobit-trigger")
  30. .withSchedule(CronScheduleBuilder.cronSchedule("0/1 * * * * ?"))
  31. .build();
  32. }
  33. }

在创建 QuartzConfig类的时候实现了 InitializingBean接口,目的是在 QuartzConfig实例及依赖类都完成注入后可以立即执行配置组装操作。
这里面有几个关键接口需要说明下:

  • SchedulerFactoryBean,Quartz Scheduler工厂类,springboot自动化配置实现;
  • Scheduer,负责Quartz Job调度,可从工厂类实例获取;
  • JobDetail,执行Quartz Job封装;
  • Trigger,完成Quartz Job启动。

还可以在配置文件中添加Quartz的配置:

  1. spring:
  2. quartz:
  3. startupDelay: 180000 #这里是毫秒值

这里配置了让Quartz默认延迟启动3分钟。
看下执行结果:

  1. 30 -- job8 ---------------------->>>>zhyea.com
  2. 31 -- job8 ---------------------->>>>zhyea.com
  3. 32 -- job8 ---------------------->>>>zhyea.com
  4. 33 -- job8 ---------------------->>>>zhyea.com
  5. 34 -- job8 ---------------------->>>>zhyea.com