原文地址:crossoverjie.topgit
節前有更新一篇定時任務的相關文章《延時消息之時間輪》,有朋友提出但願能夠完整的介紹下常見的定時任務方案,因而便有了這篇文章。github
本次會主要討論你們使用較多的方案,首先第一個就是 Timer
定時器,它能夠在指定時間後運行或週期性運行任務;使用方法也很是簡單:數組
這樣即可建立兩個簡單的定時任務,分別在 3s/5s
以後運行。markdown
使用起來確實很簡單,但也有很多毛病,想要搞清楚它所存在的問題首先就要理解其實現原理。數據結構
定時任務要想作到按照咱們給定的時間進行調度,那就得須要一個能夠排序的容器來存放這些任務。多線程
在 Timer
中內置了一個 TaskQueue
隊列,用於存放全部的定時任務。併發
其實本質上是用數組來實現的一個最小堆
,它可讓每次寫入的定時任務都按照執行時間進行排序,保證在堆頂的任務執行時間是最小的。負載均衡
這樣在須要執行任務時,每次只須要取出堆頂的任務運行便可,因此它取出任務的效率很高爲。分佈式
結合代碼會比較容易理解:函數
在寫入任務的時候會將一些基本屬性存放起來(任務的調度時間、週期、初始化任務狀態等),最後就是要將任務寫入這個內置隊列中。
在任務寫入過程當中最核心的方法即是這個 fixUp()
,它會將寫入的任務從隊列的中部經過執行時間與前一個任務作比對,一直不斷的向前比較。
若是這個時間是最先執行的,那最後將會被移動到堆頂。
經過這個過程能夠看出 Timer
新增一個任務的時間複雜度爲。
再來看看它執行任務的過程,其實在初始化 Timer
的時候它就會在後臺啓動一個線程用於從 TaskQueue
隊列中獲取任務進行調度。
因此咱們只須要看他的 run()
便可。
從這段代碼中很明顯能夠看出這個線程是一直不斷的在調用
task = queue.getMin();複製代碼
來獲取任務,最後使用 task.run()
來執行任務。
從 getMin()
方法中能夠看出和咱們以前說的一致,每次都是取出堆頂的任務執行。
一旦取出來的任務執行時間知足要求即可運行,同時須要將它從這個最小堆實現的隊列中刪除;也就是調用的 queue.removeMin()
方法。
其實它的核心原理和寫入任務相似,只不過是把堆尾的任務提到堆頂,而後再依次比較將任務日後移,直到到達合適的位置。
從剛纔的寫入和刪除任務的過程當中其實也能看出,這個最小堆只是相對有序並非絕對的有序。
源碼看完了,天然也能得出它所存在的問題了。
Timer
自己沒有捕獲其餘異常(只捕獲了 InterruptedException
),一旦任務出現異常(好比空指針)將致使後續任務不會被執行。 既然 Timer
存在一些問題,因而在 JDK1.5
中的併發包中推出了 ScheduledThreadPoolExecutor
來替代 Timer
,從它所在包路徑也能看出它自己是支持任務併發執行的。
先來看看它的類繼承圖:
能夠看到他自己也是一個線程池,繼承了 ThreadPoolExecutor
。
從他的構造函數中也能看出,本質上也是建立了一個線程池,只是這個線程池中的阻塞隊列是一個自定義的延遲隊列 DelayedWorkQueue
(與 Timer
中的 TaskQueue
做用一致)
當咱們寫入一個定時任務時,首先會將任務寫入到 DelayedWorkQueue
中,其實這個隊列本質上也是使用數組實現的最小堆。
新建任務時最終會調用到 offer()
方法,在這裏也會使用 siftUp()
將寫入的任務移動到堆頂。
原理就和以前的 Timer
相似,只不過這裏是經過自定義比較器來排序的,很明顯它是經過任務的執行時間進行比較的。
因此這樣就能將任務按照執行時間的順序排好放入到線程池中的阻塞隊列中。
這時就得須要回顧一下以前線程池的知識點了:
在線程池中會利用初始化時候的後臺線程從阻塞隊列中獲取任務,只不過在這裏這個阻塞隊列變爲了
DelayedWorkQueue
,因此每次取出來的必定是按照執行時間排序在前的任務。
和 Timer
相似,要在任務取出後調用 finishPoll()
進行刪除,也是將最後一個任務提到堆頂,而後挨個對比移動到合適的位置。
而觸發消費這個 DelayedWorkQueue
隊列的地方則是在寫入任務的時候。
本質上是調用 ThreadPoolExecutor
的 addWorker()
來寫入任務的,因此消費 DelayedWorkQueue
也是在其中觸發的。
這裏更多的是關於線程池的知識點,不太清楚的能夠先看看以前總結的線程池篇,這裏就再也不贅述。
原理看完了想必也知道和 Timer
的優點在哪兒了。
Timer | ScheduledThreadPoolExecutor |
---|---|
單線程阻塞 | 多線程任務互不影響 |
異常時任務中止 | 依賴於線程池,單個任務出現異常不影響其餘任務 |
因此有定時任務的需求時很明顯應當淘汰 Timer
了。
最後一個是基於時間輪的定時任務,這個我在上一篇《延時消息之時間輪》有過詳細介紹。
經過源碼分析咱們也能夠來作一個對比:
ScheduledThreadPoolExecutor | 基於時間輪 | |
---|---|---|
寫入效率 | 基於最小堆,任務越多效率越低 | 與 HashMap 的寫入相似,效率很高。 |
執行效率 | 每次取出第一個,效率很高 | 每秒撥動一個指針取出任務 |
因此當寫入的任務較多時,推薦使用時間輪,它的寫入效率更高。
但任務不多時其實 ScheduledThreadPoolExecutor
也不錯,畢竟它不會每秒都去撥動指針消耗 CPU
,而是一旦沒有任務線程會阻塞直到有新的任務寫入進來。
在以前的《延時消息之時間輪》中自定義了一個基於時間輪的定時任務工具 RingBufferWheel
,在網友的建議下此次順便也作了一些調整,優化了 API 也新增了取消任務的 API。
在以前的 API 中,每當新增一個任務都要調用一下 start()
,感受很怪異;此次直接將啓動函數合併到 addTask
中,使用起來更加合理。
同時任務的寫入也支持併發了。
不過這裏須要注意的是 start()
在併發執行的時候只能執行一次,因而就利用了 CAS
來保證同時只有一個線程能夠執行成功。
同時在新增任務的時候會返回一個 taskId
,利用此 ID 即可實現取消任務的需求(雖然是比較少見),使用方法以下:
感興趣的朋友能夠看下源碼也很容易理解。
最後再擴展一下,上文咱們所提到的全部方案都是單機版的,只能在單個進程中使用。
一旦咱們須要在分佈式場景下實現定時任務的高可用、可維護之類的需求就得須要一個完善的分佈式調度平臺的支持。
目前市面上流行的開源解決方案也很多:
我我的在工做中只使用過前面二者,都能很好的解決分佈式調度的需求;好比高可用、統一管理、日誌報警等。
固然這些開源工具其實在定時調度這個功能上和上文中所提到的一些方案是分不開的,只是須要結合一些分佈式相關的知識;比遠程調用、統一協調、分佈式鎖、負載均衡之類的。
感興趣的朋友能夠自行查看下他們的源碼或官方文檔。
一個小小的定時器其實涉及到的知識點還很多,包括數據結構、多線程等,但願你們看完多少有些幫助,順便幫忙點贊轉發搞起🥳。
本文所涉及到的全部源碼:
你的點贊與分享是對我最大的支持