本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
本節探討定時任務,定時任務的應用場景是非常多的,比如:
- 鬧鐘程式或任務提醒,指定時間叫床或在指定日期提醒還信用卡
- 監控系統,每隔一段時間採集下系統資料,對異常事件報警
- 統計系統,一般凌晨一定時間統計昨日的各種資料指標
在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程式設計及計算機技術的本質。用心原創,保留所有版權。