使用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()來實現的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章