使用SpringBoot自带组件实现定时任务

1.前言

定时任务的使用,在开发中可谓是家常便饭了。定时发送邮件、短信;避免数据库、数据表过大,定时将数据转储;通知、对账等等场景。

当然实现定时任务的方式也有很多,比如使用 linux下的 crontab 脚本,jdk 中自带的 Timer 类、Spring Task或是 Quartz 。

相信你也有过如下的疑问:

  • Spring Task 的 crontab 的表达式 和linux下的 crontab 有什么区别?
  • crontab 表达式记不住?
  • 定时任务阻塞会有什么影响?
  • 多个定时任务的情况下如何运行的?
  • 具有相同表达式的定时任务,他们的执行顺序如何?
  • 为什么async异步任务没有生效?

所以这篇文章,我们来介绍一下,利用SpringBoot自带组件实现定时任务的几种方式,以及在 Spring Task 中, 定时任务的执行原理及相关问题。

2.实现定时任务的5种方式

2.1 静态方式:基于注解

相信绝大部分开发者都使用过 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" />
<!-- 启用annotation方式 -->
<task:annotation-driven scheduler="scheduler" executor="executor" proxy-target-class="true" />

上述的 XML 配置 和 @Scheduled配合实现定时任务的,而我们这里的 @EnableScheduling 作用其实和它类似,主要用来发现注解了 @Scheduled 的方法,没有这个注解光有 @Scheduled 是无法执行的,大家可以做一个简单案例测试一下。

2.2 动态方式:基于接口

@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(){
        ...
    }
}

2.3 支持任务开启、关闭

上述两种方式有一个共同的问题,就是无法对任务进行动态地开启或关闭。使用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);
        }
    }
}

2.4 Timer

使用示例:

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秒执行一次。

2.5 DelayQueue

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方法,主要做了三件事:

  • 构造DelayTask,其中的延迟时间是5秒
  • 将任务放入队列
  • 从队列中取任务

3. Spring Task扩展分析

3.1 任务一直阻塞会怎么样?

介绍了3种实现方式之后,我们使用注解的方式开始做实验,简单的写一个定时执行的方法。
在这里插入图片描述
每隔 20s 输出一句话,在控制台输出几行记录后,打上了一个断点。

这样做,对后续的任务有什么影响呢?
在这里插入图片描述
可以看到,断点时的后续任务是阻塞着的,从图上,我们还可以看出初始化的名为pool-1-thread-1 的线程池同样证实了我们的想法,线程池中只有一个线程,创建方法是:

Executors.newSingleThreadScheduledExecutor();

从这个例子来看,断点时,任务会一直阻塞。当阻塞恢复后,会立马执行阻塞的任务。线程池内部时采用 DelayQueue延迟队列实现的,它的特点是:无界、延迟、阻塞的一种队列,能按一定的顺序对工作队列中的元素进行排列。
在这里插入图片描述

3.2 多个定时任务的执行

通过上面的实验,我们知道,默认情况下,任务的线程池,只会有一个线程来执行任务,因此如果有多个定时任务,它们也应该是串行执行的。
在这里插入图片描述
从上图可以看出,一旦线程执行任务1后,就会睡眠2分钟。线程在死循环内部一直处于Running 状态。

通过观察日志,根本没有任务2的输出,所以得知,这种情况下,多个定时任务是串行执行的,类似于多辆车通过单行道的桥,如果一辆车出现阻塞,其他的车辆都会受到影响。

3.3 @Async异步注解原理及作用

Spring task中和异步相关的注解有两个,一个是@EnableAsync ,另一个就是@Async
在这里插入图片描述
首先我们单纯的在方法上引入 @Async 异步注解,并且打印当前线程的名称,实验后发现,方法仍然是由一个线程来同步执行的。

和 @schedule 类似 还是通过 @Enable 开头的注解来控制执行的。我们在启动类上加入@EnableAsync后再观察输出内容。
在这里插入图片描述
默认情况下,其内部是使用的名为SimpleAsyncTaskExecutor的线程池来执行任务,而且每一次任务调度,都会新建一个线程。

使用 @EnableAsync 注解开启了 Spring 的异步功能,Spring 会按照如下的方式查找相应的线程池用于执行异步方法:

  • 查找实现了TaskExecutor接口的Bean实例。
  • 如果上面没有找到,则查找名称为taskExecutor并且实现了Executor接口的Bean实例。
  • 如果还是没有找到,则使用SimpleAsyncTaskExecutor,该实现每次都会创建一个新的线程执行任务。

3.4 并发执行任务如何配置?

方式一,我们可以将默认的线程池替换为我们自定义的线程池。通过 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;
  }

3.5 相同表达式的定时任务,执行顺序如何?

从上面的实验同样能知道,具有相同表达式的定时任务,还是和调度有关,如果是默认的线程池,那么会串行执行,首先获取到 cpu 时间片的先执行。在多线程情况下,具体的先后执行顺序和线程池线程数和所用线程池所用队列等等因素有关。

3.6 Spring Task和linux crontab的cron语法区别?

两者的 cron 表达式其实很相似,需要注意的是 linux 的 crontab 只为我们提供了最小颗粒度为分钟级的任务,而 java 中最小的粒度是从秒开始的。具体细节如下图:
在这里插入图片描述

4. java.util.Timer分析

示例代码中较为简洁,能看出控制执行时间的方法应该是 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);
}

  • task 表示要执行的任务逻辑
  • firstTime 表示第一次执行的时间
  • 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();
    }
}

这里其实做了两个事情

  • 给task设定了一些参数,类似于初始化task。这里还给它加了把锁,可以思考一下为甚要在此初始化?为何要加锁?(不是本文范畴,各位伙伴自行思考)
  • 把初始化后的task加入到queue中。

读到这里,我们还是没有看到到底是如何实现定时的?别着急,继续。进入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()实现的

5. java.util.concurrent.DelayQueue分析

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字眼:

  • 第一次是当队列为空时,等待;
  • 第二次等待是因为,发现有任务,没有到执行时间,并且有准备执行的线程(leader)。
  • 第三次是真正延时的地方了,available.awaitNanos(delay),此时也没有别的线程要执行,也就是我将要执行,所有等待剩下的延迟时间即可。

这里咱们明白了,DelayQueue的等待是通过Condition.await()来实现的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值