定时任务的使用,在开发中可谓是家常便饭了。定时发送邮件、短信;避免数据库、数据表过大,定时将数据转储;通知、对账等等场景。
当然实现定时任务的方式也有很多,比如使用 linux下的 crontab 脚本,jdk 中自带的 Timer 类、Spring Task或是 Quartz 。
相信你也有过如下的疑问:
所以这篇文章,我们来介绍一下,利用SpringBoot自带组件实现定时任务的几种方式,以及在 Spring Task 中, 定时任务的执行原理及相关问题。
相信绝大部分开发者都使用过 Spring Boot ,它为我们提供的 Starter 包含了定时任务的注解。
Spring 在 3.0版本后通过 @Scheduled 注解来完成对定时任务的支持。
/**
* ...
* @since 3.0
* @see EnableScheduling
* @see ScheduledAnnotationBeanPostProcessor
* @see Schedules
*/
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(Schedules.class)
public @interface Scheduled {
...
}
通过在定时执行的方法上使用@Scheduled
即可添加定时任务,可以基于cron表达式配置,也可以直接指定时间间隔、频率。使用方式如下
@Configuration //主要用于标记配置类,兼备Component的效果。
public class ScheduleTask {
//添加定时任务
@Scheduled(cron = "0/5 * * * * ?")
//或直接指定时间间隔,例如:5秒
//@Scheduled(fixedRate=5000)
private void configureTasks() {
System.out.println("执行定时任务时间: " + LocalDateTime.now());
}
}
在使用时,需要在Application 启动类上加上 @EnableScheduling 注解,它是从Spring 3.1后开始提供的。
/**
* ...
* @since 3.1
* @see Scheduled
* @see SchedulingConfiguration
* @see SchedulingConfigurer
* @see ScheduledTaskRegistrar
* @see Trigger
* @see ScheduledAnnotationBeanPostProcessor
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(SchedulingConfiguration.class)
@Documented
public @interface EnableScheduling {}
由于Spring3 版本较低,使用得比较少了,我们使用高版本可能并不会考虑太多细节,大多只需要关注目标实现,所以我们在配套使用两个注解的时候,并不会出现什么问题。
在3.0 中 ,是通过
<task:executor id="executor" pool-size="3" />
<task:scheduler id="scheduler" pool-size="3" />
<task:annotation-driven scheduler="scheduler" executor="executor" proxy-target-class="true" />
上述的 XML 配置 和 @Scheduled
配合实现定时任务的,而我们这里的 @EnableScheduling
作用其实和它类似,主要用来发现注解了 @Scheduled
的方法,没有这个注解光有 @Scheduled
是无法执行的,大家可以做一个简单案例测试一下。
@Schedule
注解有一个缺点,其定时的时间不能动态的改变,而基于 SchedulingConfigurer
接口的方式可以做到。SchedulingConfigurer
接口可以实现在@Configuration
类上。同时不要忘了,还需要@EnableScheduling
注解的支持。
实现该接口需要实现public void configureTasks(ScheduledTaskRegistrar taskRegistrar)
方法,其中ScheduledTaskRegistrar
类的方法有下列几种:
从方法的命名上可以猜到,方法包含定时任务,延时任务,基于 Cron 表达式的任务,以及 Trigger 触发的任务。
我们使用基于cron表达式的CronTrigger进行演示。
@Configuration //主要用于标记配置类,兼备Component的效果。
@EnableScheduling
public class ScheduleTask implements SchedulingConfigurer {
/**
* 执行定时任务.
*/
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.addTriggerTask(
//实现Runnable接口,具体业务代码
() -> System.out.println("执行定时任务: " + LocalDateTime.now().toLocalTime()),
//实现Trigger接口,设置执行周期
triggerContext -> {
// 获取cron表达式
String cron = getCron();
//返回执行周期(Date)
return new CronTrigger(cron).nextExecutionTime(triggerContext);
}
);
}
private String getCron(){
...
}
}
上述两种方式有一个共同的问题,就是无法对任务进行动态地开启或关闭。使用ThreadPoolTaskScheduler
任务调度器可以解决这个问题。ThreadPoolTaskScheduler
可以很方便的对重复执行的任务进行调度管理;相比于周期性任务线程池ScheduleThreadPoolExecutor
,此bean对象支持根据cron表达式创建周期性任务。
@Configuration
@EnableScheduling
public class MailScheduledTask {
private String taskCron = "0 0 16 28 * ?";
@Autowired
private ThreadPoolTaskScheduler threadPoolTaskScheduler;
private ScheduledFuture<?> future;
public void startTask() {
future = threadPoolTaskScheduler.schedule(
//实现Runnable接口
() -> {
// 业务实现
},
//实现Trigger接口,设置执行周期
triggerContext -> {
// 返回执行周期(Date)
return new CronTrigger(getTaskCron()).nextExecutionTime(triggerContext);
}
);
}
public void stopTask() {
if (future != null) {
future.cancel(true);
}
}
}
使用示例:
public class TimerDemo {
public static void main(String[] args) {
Timer timer = new Timer();
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, 12);//控制小时
calendar.set(Calendar.MINUTE, 0);//控制分钟
calendar.set(Calendar.SECOND, 0);//控制秒
Date time = calendar.getTime();//执行任务时间为12:00:00
//每天定时12:00执行操作,每隔2秒执行一次
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println(new Date() + "执行任务。。。");
}
}, time, 1000 * 2);
}
}
Demo中使用了Timer实现了一个定时任务,该任务在每天12点开始执行,并且每隔2秒执行一次。
DelayQueue它本质上是一个队列,而这个队列里也只有存放Delayed的子类才有意义,所有定义了DelayTask:
public class DelayTask implements Delayed {
private Date startDate = new Date();
public DelayTask(Long delayMillions) {
this.startDate.setTime(new Date().getTime() + delayMillions);
}
@Override
public int compareTo(Delayed o) {
long result = this.getDelay(TimeUnit.NANOSECONDS)
- o.getDelay(TimeUnit.NANOSECONDS);
if (result < 0) {
return -1;
} else if (result > 0) {
return 1;
} else {
return 0;
}
}
@Override
public long getDelay(TimeUnit unit) {
Date now = new Date();
long diff = startDate.getTime() - now.getTime();
return unit.convert(diff, TimeUnit.MILLISECONDS);
}
}
public static void main(String[] args) throws Exception {
BlockingQueue<DelayTask> queue = new DelayQueue<>();
DelayTask delayTask = new DelayTask(1000 * 5L);
queue.put(delayTask);
while (queue.size()>0){
queue.take();
}
}
看main方法,主要做了三件事:
介绍了3种实现方式之后,我们使用注解的方式开始做实验,简单的写一个定时执行的方法。
每隔 20s 输出一句话,在控制台输出几行记录后,打上了一个断点。
这样做,对后续的任务有什么影响呢?
可以看到,断点时的后续任务是阻塞着的,从图上,我们还可以看出初始化的名为pool-1-thread-1 的线程池同样证实了我们的想法,线程池中只有一个线程,创建方法是:
Executors.newSingleThreadScheduledExecutor();
从这个例子来看,断点时,任务会一直阻塞。当阻塞恢复后,会立马执行阻塞的任务。线程池内部时采用 DelayQueue
延迟队列实现的,它的特点是:无界、延迟、阻塞的一种队列,能按一定的顺序对工作队列中的元素进行排列。
通过上面的实验,我们知道,默认情况下,任务的线程池,只会有一个线程来执行任务,因此如果有多个定时任务,它们也应该是串行执行的。
从上图可以看出,一旦线程执行任务1后,就会睡眠2分钟。线程在死循环内部一直处于Running 状态。
通过观察日志,根本没有任务2的输出,所以得知,这种情况下,多个定时任务是串行执行的,类似于多辆车通过单行道的桥,如果一辆车出现阻塞,其他的车辆都会受到影响。
Spring task中和异步相关的注解有两个,一个是@EnableAsync
,另一个就是@Async
。
首先我们单纯的在方法上引入 @Async
异步注解,并且打印当前线程的名称,实验后发现,方法仍然是由一个线程来同步执行的。
和 @schedule 类似 还是通过 @Enable 开头的注解来控制执行的。我们在启动类上加入@EnableAsync
后再观察输出内容。
默认情况下,其内部是使用的名为SimpleAsyncTaskExecutor
的线程池来执行任务,而且每一次任务调度,都会新建一个线程。
使用 @EnableAsync
注解开启了 Spring 的异步功能,Spring 会按照如下的方式查找相应的线程池用于执行异步方法:
TaskExecutor
接口的Bean实例。taskExecutor
并且实现了Executor
接口的Bean实例。SimpleAsyncTaskExecutor
,该实现每次都会创建一个新的线程执行任务。方式一,我们可以将默认的线程池替换为我们自定义的线程池。通过 ScheduleConfig
配置文件实现 SchedulingConfigurer
接口,并重写 setSchedulerfang
方法。
可实现 AsyncConfigurer
接口复写 getAsyncExecutor
获取异步执行器,getAsyncUncaughtExceptionHandler
获取异步未捕获异常处理器
@Configurationpublic
class ScheduleConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(Executors.newScheduledThreadPool(5));
}
}
方式二:不改变任务调度器默认使用的线程池,而是把当前任务交给一个异步线程池去执行。
@Scheduled(fixedRate = 1000*10,initialDelay = 1000*20)
@Async("hyqThreadPoolTaskExecutor")
public void test(){
System.out.println(Thread.currentThread().getName()+"--->xxxxx--->"+Thread.currentThread().getId());
}
//自定义线程池
@Bean(name = "hyqThreadPoolTaskExecutor")
public TaskExecutor getMyThreadPoolTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(20);
taskExecutor.setMaxPoolSize(200);
taskExecutor.setQueueCapacity(25);
taskExecutor.setKeepAliveSeconds(200);
taskExecutor.setThreadNamePrefix("hyq-threadPool-");
taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
taskExecutor.setAwaitTerminationSeconds(60);
taskExecutor.initialize();
return taskExecutor;
}
从上面的实验同样能知道,具有相同表达式的定时任务,还是和调度有关,如果是默认的线程池,那么会串行执行,首先获取到 cpu 时间片的先执行。在多线程情况下,具体的先后执行顺序和线程池线程数和所用线程池所用队列等等因素有关。
两者的 cron 表达式其实很相似,需要注意的是 linux 的 crontab 只为我们提供了最小颗粒度为分钟级的任务,而 java 中最小的粒度是从秒开始的。具体细节如下图:
示例代码中较为简洁,能看出控制执行时间的方法应该是 timer.schedule(),跟进去看源码:
public void schedule(TimerTask task, Date firstTime, long period) {
if (period <= 0)
throw new IllegalArgumentException("Non-positive period.");
sched(task, firstTime.getTime(), -period);
}
继续跟进:
private void sched(TimerTask task, long time, long period) {
//省略非重点代码
synchronized(queue) {
if (!thread.newTasksMayBeScheduled)
throw new IllegalStateException("Timer already cancelled.");
synchronized(task.lock) {
if (task.state != TimerTask.VIRGIN)
throw new IllegalStateException(
"Task already scheduled or cancelled");
task.nextExecutionTime = time;
task.period = period;
task.state = TimerTask.SCHEDULED;
}
queue.add(task);
if (queue.getMin() == task)
queue.notify();
}
}
这里其实做了两个事情
读到这里,我们还是没有看到到底是如何实现定时的?别着急,继续。进入queu.add(task)
/**
* Adds a new task to the priority queue.
*/
void add(TimerTask task) {
// Grow backing store if necessary
if (size + 1 == queue.length)
queue = Arrays.copyOf(queue, 2*queue.length);
queue[++size] = task;
fixUp(size);
}
这里注释提到,加入一个新任务到优先级队列中去。其实这里的TimerTask[]是一个优先级队列,使用数组存储方式。并且它的数据结构是heap。包括从fixUp()我们也能看出来,它是在保持堆属性,即堆化(heapify)。
那么能分析的都分析完了,还是没能看到定时是如何实现的?再次静下来想一想,定时任务如果想执行,首先得启动定时器。所有咱们再次关注构造方法。
Timer一共有4个构造方法,看最底层的:
public Timer(String name) {
thread.setName(name);
thread.start();
}
可以看到,这里在启动一个thread,那么既然是一个Thread,那肯定就得关注它的 run()方法了。进入:
public void run() {
try {
mainLoop();
} finally {
// Someone killed this Thread, behave as if Timer cancelled
synchronized(queue) {
newTasksMayBeScheduled = false;
queue.clear(); // Eliminate obsolete references
}
}
}
继续进入mainLoop():
/**
* The main timer loop. (See class comment.)
*/
private void mainLoop() {
while (true) {
try {
TimerTask task;
boolean taskFired;
synchronized(queue) {
//省略
long currentTime, executionTime;
task = queue.getMin();
synchronized(task.lock) {
if (task.state == TimerTask.CANCELLED) {
queue.removeMin();
continue; // No action required, poll queue again
}
currentTime = System.currentTimeMillis();
executionTime = task.nextExecutionTime;
if (taskFired = (executionTime<=currentTime)) {
if (task.period == 0) { // Non-repeating, remove
queue.removeMin();
task.state = TimerTask.EXECUTED;
} else { // Repeating task, reschedule
queue.rescheduleMin(
task.period<0 ? currentTime - task.period
: executionTime + task.period);
}
}
}
if (!taskFired) // Task hasn't yet fired; wait
queue.wait(executionTime - currentTime);
}
if (taskFired) // Task fired; run it, holding no locks
task.run();
} catch(InterruptedException e) {
}
}
}
从上述源码中,可以看出有两个重要的if
if (taskFired = (executionTime<=currentTime))
,表示已经到了执行时间,那么下面执行任务就好了;if (!taskFired)
,表示未到执行时间,那么等待就好了。那么是如何等待的呢?再仔细一看,原来是调用了Object.wait(long timeout)
。到这里我们知道了,等待是使用最简单的Object.wait()实现的
DelayQueue跟刚才的Timer.TaskQueue是比较相似的,都是优先级队列,放入元素时,都得堆化(DelayQueue.put()如果元素满了,会阻塞)。重点看queue.take()。
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
for (;;) {
E first = q.peek();
if (first == null)
available.await();
else {
long delay = first.getDelay(NANOSECONDS);
if (delay <= 0)
return q.poll();
first = null; // don't retain ref while waiting
if (leader != null)
available.await();
else {
Thread thisThread = Thread.currentThread();
leader = thisThread;
try {
available.awaitNanos(delay);
} finally {
if (leader == thisThread)
leader = null;
}
}
}
}
} finally {
if (leader == null && q.peek() != null)
available.signal();
lock.unlock();
}
}
源码中出现了三次await字眼:
这里咱们明白了,DelayQueue的等待是通过Condition.await()来实现的。