計算機程式的思維邏輯 (80) - 定時任務的那些坑

swiftma發表於2017-04-17

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (80) - 定時任務的那些坑

本節探討定時任務,定時任務的應用場景是非常多的,比如:

  • 鬧鐘程式或任務提醒,指定時間叫床或在指定日期提醒還信用卡
  • 監控系統,每隔一段時間採集下系統資料,對異常事件報警
  • 統計系統,一般凌晨一定時間統計昨日的各種資料指標

在Java中,有兩種方式實現定時任務:

  • 使用java.util包中的Timer和TimerTask
  • 使用Java併發包中的ScheduledExecutorService

它們的基本用法都是比較簡單的,但如果對它們沒有足夠的瞭解,則很容易陷入其中的一些陷阱,下面,我們就來介紹它們的用法、原理以及那些坑。

Timer和TimerTask

基本用法

TimerTask表示一個定時任務,它是一個抽象類,實現了Runnable,具體的定時任務需要繼承該類,實現run方法。

Timer是一個具體類,它負責定時任務的排程和執行,它有如下主要方法:

//在指定絕對時間time執行任務task
public void schedule(TimerTask task, Date time)
//在當前時間延時delay毫秒後執行任務task
public void schedule(TimerTask task, long delay)
//固定延時重複執行,第一次計劃執行時間為firstTime,後一次的計劃執行時間為前一次"實際"執行時間加上period
public void schedule(TimerTask task, Date firstTime, long period)
//同樣是固定延時重複執行,第一次執行時間為當前時間加上delay
public void schedule(TimerTask task, long delay, long period)
//固定頻率重複執行,第一次計劃執行時間為firstTime,後一次的計劃執行時間為前一次"計劃"執行時間加上period
public void scheduleAtFixedRate(TimerTask task, Date firstTime, long period)
//同樣是固定頻率重複執行,第一次計劃執行時間為當前時間加上delay
public void scheduleAtFixedRate(TimerTask task, long delay, long period)
複製程式碼

需要注意固定延時(fixed-delay)與固定頻率(fixed-rate)的區別,都是重複執行,但後一次任務執行相對的時間是不一樣的,對於固定延時,它是基於上次任務的"實際"執行時間來算的,如果由於某種原因,上次任務延時了,則本次任務也會延時,而固定頻率會盡量補夠執行次數

另外,需要注意的是,如果第一次計劃執行的時間firstTime是一個過去的時間,則任務會立即執行,對於固定延時的任務,下次任務會基於第一次執行時間計算,而對於固定頻率的任務,則會從firstTime開始算,有可能加上period後還是一個過去時間,從而連續執行很多次,直到時間超過當前時間。

我們通過一些簡單的例子具體來看下。

基本示例

看一個最簡單的例子:

public class BasicTimer {
    static class DelayTask extends TimerTask {
        
        @Override
        public void run() {
            System.out.println("delayed task");
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();
        timer.schedule(new DelayTask(), 1000);
        Thread.sleep(2000);
        timer.cancel();
    }
}
複製程式碼

建立一個Timer物件,1秒鐘後執行DelayTask,最後呼叫Timer的cancel方法取消所有定時任務。

看一個固定延時的簡單例子:

public class TimerFixedDelay {

    static class LongRunningTask extends TimerTask {
        @Override
        public void run() {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
            }
            System.out.println("long running finished");
        }
    }

    static class FixedDelayTask extends TimerTask {
        @Override
        public void run() {
            System.out.println(System.currentTimeMillis());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();

        timer.schedule(new LongRunningTask(), 10);
        timer.schedule(new FixedDelayTask(), 100, 1000);
    }
}
複製程式碼

有兩個定時任務,第一個執行一次,但耗時5秒,第二個是重複執行,1秒一次,第一個先執行。執行該程式,會發現,第二個任務只有在第一個任務執行結束後才會開始執行,執行後1秒一次。

如果替換上面的程式碼為固定頻率,即程式碼變為:

public class TimerFixedRate {

    static class LongRunningTask extends TimerTask {
        @Override
        public void run() {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
            }
            System.out.println("long running finished");
        }
    }

    static class FixedRateTask extends TimerTask {

        @Override
        public void run() {
            System.out.println(System.currentTimeMillis());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();

        timer.schedule(new LongRunningTask(), 10);
        timer.scheduleAtFixedRate(new FixedRateTask(), 100, 1000);
    }
}
複製程式碼

執行該程式,第二個任務同樣只有在第一個任務執行結束後才會執行,但它會把之前沒有執行的次數補過來,一下子執行5次,輸出類似下面這樣:

long running finished
1489467662330
1489467662330
1489467662330
1489467662330
1489467662330
1489467662419
1489467663418
複製程式碼

基本原理

Timer內部主要由兩部分組成,任務佇列和Timer執行緒。任務佇列是一個基於堆實現的優先順序佇列,按照下次執行的時間排優先順序。Timer執行緒負責執行所有的定時任務,需要強調的是,一個Timer物件只有一個Timer執行緒,所以,對於上面的例子,任務才會被延遲。

Timer執行緒主體是一個迴圈,從佇列中拿任務,如果佇列中有任務且計劃執行時間小於等於當前時間,就執行它,如果佇列中沒有任務或第一個任務延時還沒到,就睡眠。如果睡眠過程中佇列上新增了新任務且新任務是第一個任務,Timer執行緒會被喚醒,重新進行檢查。

在執行任務之前,Timer執行緒判斷任務是否為週期任務,如果是,就設定下次執行的時間並新增到優先順序佇列中,對於固定延時的任務,下次執行時間為當前時間加上period,對於固定頻率的任務,下次執行時間為上次計劃執行時間加上period。

需要強調是,下次任務的計劃是在執行當前任務之前就做出了的,對於固定延時的任務,延時相對的是任務執行前的當前時間,而不是任務執行後,這與後面講到的ScheduledExecutorService的固定延時計算方法是不同的,後者的計算方法更合乎一般的期望。

另一方面,對於固定頻率的任務,它總是基於最先的計劃計劃的,所以,很有可能會出現前面例子中一下子執行很多次任務的情況。

死迴圈

一個Timer物件只有一個Timer執行緒,這意味著,定時任務不能耗時太長,更不能是無限迴圈,看個例子:

public class EndlessLoopTimer {
    static class LoopTask extends TimerTask {

        @Override
        public void run() {
            while (true) {
                try {
                    // ... 執行任務
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    // 永遠也沒有機會執行
    static class ExampleTask extends TimerTask {
        @Override
        public void run() {

            System.out.println("hello");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();
        timer.schedule(new LoopTask(), 10);
        timer.schedule(new ExampleTask(), 100);
    }
}
複製程式碼

第一個定時任務是一個無限迴圈,其後的定時任務ExampleTask將永遠沒有機會執行。

異常處理

關於Timer執行緒,還需要強調非常重要的一點,在執行任何一個任務的run方法時,一旦run丟擲異常,Timer執行緒就會退出,從而所有定時任務都會被取消。我們看個簡單的示例:

public class TimerException {

    static class TaskA extends TimerTask {
        
        @Override
        public void run() {
            System.out.println("task A");
        }
    }
    
    static class TaskB extends TimerTask {
        
        @Override
        public void run() {
            System.out.println("task B");
            throw new RuntimeException();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();
        timer.schedule(new TaskA(), 1, 1000);
        timer.schedule(new TaskB(), 2000, 1000);
    }
}
複製程式碼

期望TaskA每秒執行一次,但TaskB會丟擲異常,導致整個定時任務被取消,程式終止,螢幕輸出為:

task A
task A
task B
Exception in thread "Timer-0" java.lang.RuntimeException
    at laoma.demo.timer.TimerException$TaskB.run(TimerException.java:21)
    at java.util.TimerThread.mainLoop(Timer.java:555)
    at java.util.TimerThread.run(Timer.java:505)
複製程式碼

所以,如果希望各個定時任務不互相干擾,一定要在run方法內捕獲所有異常

小結

可以看到,Timer/TimerTask的基本使用是比較簡單的,但我們需要注意:

  • 背後只有一個執行緒在執行
  • 固定頻率的任務被延遲後,可能會立即執行多次,將次數補夠
  • 固定延時任務的延時相對的是任務執行前的時間
  • 不要在定時任務中使用無限迴圈
  • 一個定時任務的未處理異常會導致所有定時任務被取消

ScheduledExecutorService

介面和類定義

由於Timer/TimerTask的一些問題,Java併發包引入了ScheduledExecutorService,它是一個介面,其定義為:

public interface ScheduledExecutorService extends ExecutorService {
    //單次執行,在指定延時delay後執行command
    public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);
    //單次執行,在指定延時delay後執行callable
    public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);
    //固定頻率重複執行
    public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit);
    //固定延時重複執行
    public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);
}
複製程式碼

它們的返回型別都是ScheduledFuture,它是一個介面,擴充套件了Future和Delayed,沒有定義額外方法。這些方法的大部分語義與Timer中的基本是類似的。對於固定頻率的任務,第一次執行時間為initialDelay後,第二次為initialDelay+period,第三次initialDelay+2*period,依次類推。不過,對於固定延時的任務,它是從任務執行後開始算的,第一次為initialDelay後,第二次為第一次任務執行結束後再加上delay。與Timer不同,它不支援以絕對時間作為首次執行的時間。

ScheduledExecutorService的主要實現類是ScheduledThreadPoolExecutor,它是執行緒池ThreadPoolExecutor的子類,是基於執行緒池實現的,它的主要構造方法是:

public ScheduledThreadPoolExecutor(int corePoolSize) 
複製程式碼

此外,還有構造方法可以接受引數ThreadFactory和RejectedExecutionHandler,含義與ThreadPoolExecutor一樣,我們就不贅述了。

它的任務佇列是一個無界的優先順序佇列,所以最大執行緒數對它沒有作用,即使corePoolSize設為0,它也會至少執行一個執行緒。

工廠類Executors也提供了一些方便的方法,以方便建立ScheduledThreadPoolExecutor,如下所示:

//單執行緒的定時任務執行服務
public static ScheduledExecutorService newSingleThreadScheduledExecutor()
public static ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory threadFactory)
//多執行緒的定時任務執行服務
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize)
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize, ThreadFactory threadFactory)
複製程式碼

基本示例

由於可以有多個執行緒執行定時任務,一般任務就不會被某個長時間執行的任務所延遲了,比如,對於前面的TimerFixedDelay,如果改為:

public class ScheduledFixedDelay {
    static class LongRunningTask implements Runnable {
        @Override
        public void run() {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
            }
            System.out.println("long running finished");
        }
    }

    static class FixedDelayTask implements Runnable {
        @Override
        public void run() {
            System.out.println(System.currentTimeMillis());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ScheduledExecutorService timer = Executors.newScheduledThreadPool(10);
        timer.schedule(new LongRunningTask(), 10, TimeUnit.MILLISECONDS);
        timer.scheduleWithFixedDelay(new FixedDelayTask(), 100, 1000,
                TimeUnit.MILLISECONDS);
    }
}
複製程式碼

再次執行,第二個任務就不會被第一個任務延遲了。

另外,與Timer不同,單個定時任務的異常不會再導致整個定時任務被取消了,即使背後只有一個執行緒執行任務,我們看個例子:

public class ScheduledException {

    static class TaskA implements Runnable {

        @Override
        public void run() {
            System.out.println("task A");
        }
    }

    static class TaskB implements Runnable {

        @Override
        public void run() {
            System.out.println("task B");
            throw new RuntimeException();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ScheduledExecutorService timer = Executors
                .newSingleThreadScheduledExecutor();
        timer.scheduleWithFixedDelay(new TaskA(), 0, 1, TimeUnit.SECONDS);
        timer.scheduleWithFixedDelay(new TaskB(), 2, 1, TimeUnit.SECONDS);
    }
}
複製程式碼

TaskA和TaskB都是每秒執行一次,TaskB兩秒後執行,但一執行就丟擲異常,螢幕的輸出類似如下:

task A
task A
task B
task A
task A
...
複製程式碼

這說明,定時任務TaskB被取消了,但TaskA不受影響,即使它們是由同一個執行緒執行的。不過,需要強調的是,與Timer不同,沒有異常被丟擲來,TaskB的異常沒有在任何地方體現。所以,與Timer中的任務類似,應該捕獲所有異常

基本原理

ScheduledThreadPoolExecutor的實現思路與Timer基本是類似的,都有一個基於堆的優先順序佇列,儲存待執行的定時任務,它的主要不同是:

  • 它的背後是執行緒池,可以有多個執行緒執行任務
  • 它在任務執行後再設定下次執行的時間,對於固定延時的任務更為合理
  • 任務執行執行緒會捕獲任務執行過程中的所有異常,一個定時任務的異常不會影響其他定時任務,但發生異常的任務也不再被重新排程,即使它是一個重複任務

小結

本節介紹了Java中定時任務的兩種實現方式,Timer和ScheduledExecutorService,需要特別注意Timer的一些陷阱,實踐中建議使用ScheduledExecutorService。

它們的共同侷限是,不太勝任複雜的定時任務排程,比如,每週一和週三晚上18:00到22:00,每半小時執行一次。對於類似這種需求,可以利用我們之前在32節33節介紹的日期和時間處理方法,或者利用更為強大的第三方類庫,比如Quartz

在併發應用程式中,一般我們應該儘量利用高層次的服務,比如前面章節介紹的各種併發容器、任務執行服務執行緒池等,避免自己管理執行緒和它們之間的同步,但在個別情況下,自己管理執行緒及同步是必需的,這時,除了利用前面章節介紹的synchronized, wait/notify, 顯示鎖條件等基本工具,Java併發包還提供了一些高階的同步和協作工具,以方便實現併發應用,讓我們下一節來了解它們。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (80) - 定時任務的那些坑

相關文章