Java 定時器 Timer 原始碼分析和使用建議

亦默亦風發表於2017-12-28

Timer 定時器相信都不會陌生,之所以拿它來做原始碼分析,是發現整個控制流程可以體現很多有意思的東西。

在業務開發中經常會遇到執行一些簡單定時任務的需求,通常為了避免做一些看起來複雜的控制邏輯,一般考慮使用 Timer 來實現定時任務的執行,下面先給出一個最簡單用法的例子:

Timer timer = new Timer();
TimerTask timerTask = new TimerTask() {
    @Override
    public void run() {
        // scheduledExecutionTime() 返回此任務最近開始執行的時間
        Date date = new Date(this.scheduledExecutionTime());
        System.out.println("timeTask run " + date);
    }
};

// 從現在開始每間隔 1000 ms 計劃執行一個任務
timer.schedule(timerTask, 0, 1000);
複製程式碼

Timer 概述

Timer 可以按計劃執行重複的任務或者定時執行指定任務,這是因為 Timer 內部利用了一個後臺執行緒 TimerThread 有計劃地執行指定任務。

  • **Timer:**是一個實用工具類,該類用來排程一個執行緒(schedule a thread),使它可以在將來某一時刻執行。 Java 的 Timer 類可以排程一個任務執行一次或定期迴圈執行。 Timer tasks should complete quickly. 即定時器中的操作要儘可能花費短的時間。

  • **TimerTask:**一個抽象類,它實現了 Runnable 介面。我們需要擴充套件該類以便建立自己的 TimerTask ,這個 TimerTask 可以被 Timer 排程。

一個 Timer 物件對應的是單個後臺執行緒,其內部維護了一個 TaskQueue,用於順序執行計時器任務 TimeTask 。

Timer

Timer 中優先佇列的實現

TaskQueue 佇列,內部用一個 TimerTask[] 陣列實現優先佇列(二叉堆),預設最大任務數是 128 ,當新增定時任務超過當前最大容量時會這個陣列會擴充到原來 2 倍。

TaskQueue

優先佇列主要目的是為了找出、返回並刪除優先佇列中最小的元素,這裡優先佇列是通過陣列實現了平衡二叉堆,TimeQueue 實現的二叉堆用陣列表示時,具有最小 nextExecutionTime 的 TimerTask 在佇列中為 queue[1] ,所以堆中根節點在陣列中的位置是 queue[1] ,那麼第 n 個位置 queue[n] 的子節點分別在 queue[2n] 和 queue[2n+1] 。關於優先佇列的資料結構實現,這裡推薦一篇文章:資料結構與演算法學習筆記 - 優先佇列、二叉堆、左式堆

按照 TaskQueue 的描述:This class represents a timer task queue: a priority queue of TimerTasks, ordered on nextExecutionTime.這是一個優先佇列,佇列的優先順序按照 nextExecutionTime 進行排程。 也就說 TaskQueue 按照 TimerTask 的 nextExecutionTime 屬性界定優先順序,優先順序高的任務先出佇列,也就先執行任務排程。

佇列操作

如上圖所示,列舉了優先佇列中部分操作的實現,優先佇列插入和刪除元素的複雜度都是O(logn),所以add, removeMin 和 rescheduleMin方法的效能都是不錯的。從上圖可以知道,獲取下一個計劃執行任務時,取佇列的頭出列即可,為了減少額外效能消耗,移除佇列頭部元素的操作是先把隊尾元素賦值到隊首後,再把隊尾置空,佇列數量完成減一後進行優先權值操作。再下面看看保證優先佇列最核心的兩個方法fixUpfixDown

Java 定時器 Timer 原始碼分析和使用建議

兩個方法的核心思路都是通過向上或向下調整二叉堆中元素所在位置,保持堆的有序性: fixUp 是將元素值小於父節點的子節點與父節點交換位置,保持堆有序。交換位置後,原來的子節點可能仍然比更上層的父節點小, 所以整個過程需要迴圈進行。這樣一來,原來的子節點有可能升級為層級更高的父節點,類似於一個輕的物體從湖底往上浮直到達到其重力與浮力相平衡的過程。 fixDown 將元素值大於子節點的父節點與子節點交換位置,交換位置後, 原來的父節點仍然有可能比其下面的子節點大, 所以還需要繼續進行類相同的操作,以便保持堆的有序性。所以整個過程迴圈進行。 這類似於一個重的物體從湖面下沉到距離湖底的某個位置,直到達到其重力與浮力相平衡為止。 總的來說,就是調整大的元素下沉,小的元素上浮,反覆調整後堆頂一直保持是堆中最小的元素,父節點元素要一直小於或等於子節點。

TimerTask 的排程

前面說完 Timer 原始碼中優先佇列的實現,下面我們來看看其如果操作優先佇列,實現 TimerTask 的計劃排程的:

Timer 提供了四個構造方法,每個構造方法都啟動了一個後臺執行緒(預設不是守護執行緒,除非主動指定)。所以對於每一個 Timer 物件而言,其內部都是對應著單個後臺執行緒,這個執行緒用於順序執行優先佇列中所有的計時器任務。

Timer 構造器

當初始化完成 Timer 後,我們就可以往 Timer 中新增定時任務,然後定時任務就會按照我們設定的時間交由 Timer 取排程執行。Timer 提供了 schedule 方法,該方法依靠多次過載的方式來適應不同的情況,具體如下:

  • **schedule(TimerTask task, Date time):**安排在指定的時間執行指定的任務。

  • **schedule(TimerTask task, long delay) :**安排在指定延遲後執行指定的任務。

  • **schedule(TimerTask task, Date firstTime, long period) :**安排指定的任務在指定的時間開始進行重複的固定延遲執行。

  • **schedule(TimerTask task, long delay, long period) :**安排指定的任務從指定的延遲後開始進行重複的固定延遲執行。

  • scheduleAtFixedRate :,scheduleAtFixedRate 方法與 schedule 相同,只不過他們的側重點不同,區別後面分析。

  • **scheduleAtFixedRate(TimerTask task, Date firstTime, long period):**安排指定的任務在指定的時間開始進行重複的固定速率執行。

  • **scheduleAtFixedRate(TimerTask task, long delay, long period):**安排指定的任務在指定的延遲後開始進行重複的固定速率執行。

首先來看 schedule(TimerTask task, Date time)schedule(TimerTask task, long delay) ,第一個引數傳入是定時任務的例項,區別在於方法的第二個引數,date 是在指定的時間點,delay 是當前時間延後多少毫秒。這就引出了 Timer 具有的兩個特性:定時(在指定時間點執行任務)和延遲(延遲多少秒後執行任務)。 值得大家注意的是:這裡所說時間都是跟系統時間相關的絕對時間,而不是相對時間,基於這點,Timer 對任務的排程計劃和系統時間息息相關,所以它對系統時間的改變非常敏感。

Java 定時器 Timer 原始碼分析和使用建議

下面在來看看 schedule(TimerTask task, Date time)schedule(TimerTask task, Date firstTime, long period) 的區別。對比方法中新增的 period 引數,period 作用區別在於 Timer 的另一個特性:週期性地執行任務(一次任務結束後,可以每隔個 period 豪秒後再執行任務,如此反覆)。

Java 定時器 Timer 原始碼分析和使用建議

從上面 schedule 的方法過載來看,最終都是呼叫了 sched(TimerTask task, long time, long period) 方法,只是傳入的引數不同,下面就再來看就看關於 schedule 和 scheduleAtFixedRate 的區別:

Java 定時器 Timer 原始碼分析和使用建議

從呼叫方法來看,他們的區別僅僅是傳入 sched 方法 period 引數正負數的差別,所以具體的就要看 sched 方法的實現。

Java 定時器 Timer 原始碼分析和使用建議

可以看到 sched 方法主要是設定 TimerTask 屬性和狀態,比如 nextExecutionTime 等,然後將任務新增到佇列中。能看出來,設定定時任務 task 屬性時是加了鎖的,而且在新增任務到佇列時,這裡使用 Timer 內 TaskQueue 例項作為物件鎖,並且使用 wait 和 notify 方法來通知任務排程。Timer 類可以保證多個執行緒可以共享單個 Timer 物件而無需進行外部同步,所以 Timer 類是執行緒安全的。

這裡注意區分開: 前面一個 Timer 物件中用於處理任務排程的後臺執行緒TimerThread 例項和 schedule 方法傳入後被加入到 TaskQueue 的 TimerTask 任務的例項,兩者是不一樣的。

Java 定時器 Timer 原始碼分析和使用建議

要想知道為 TimerTask 設定屬性和狀態的作用,那就得進一步看看 TimerTask 類的具體實現了。

TimerTask 類是一個抽象類,可以由 Timer 安排為一次執行或重複執行的任務。它有一個抽象方法 run() 方法,用於子類實現 Runnale 介面。可以在 run 方法中寫定時任務的具體業務邏輯。

TimerTask

可以看到下圖中 TimerTask 類中的文件描述,如果任務是按計劃執行,那麼 nextExecutionTime 屬性是指下次任務的執行時間,時間格式是按照 System.currentTimeMillis 返回的。對於需要重複進行的任務,每個任務執行之前會更新這一屬性。

而 period 屬性是用來表示以毫秒為時間單位的重複任務。period 為正值時表示固定速率執行,負值表示固定延遲執行,值 0 表示一個非重複性的任務。

Java 定時器 Timer 原始碼分析和使用建議

所謂固定速率執行和固定延遲執行,固定延遲指的是定時任務會因為前一個任務的延遲而導致其後面的定時任務延時,而固定速率執行則不會有這個問題,它是直接按照計劃的速率重複執行,不會考慮前面任務是否執行完。

這也是 scheduleAtFixedRate 與 schedule 方法的區別,兩者側重點不同,schedule 方法側重儲存間隔時間的穩定,而 scheduleAtFixedRate 方法更加側重於保持執行頻率的穩定。

另外 TimerTask 還有兩個非抽象方法:

  • **boolean cancel():**取消此計時器任務。
  • **long scheduledExecutionTime():**返回此任務最近實際執行的安排執行時間。

Java 定時器 Timer 原始碼分析和使用建議

說完這些,下面就來看看 Timer 的後臺執行緒具體是如何排程佇列中的定時任務,可以看到 TimerThread 是持有任務佇列進行操作的,也就具有了任務排程功能了。

Java 定時器 Timer 原始碼分析和使用建議

下面就來看看後臺執行緒的 run 方法呼叫 mainLoop 具體做了什麼:

Java 定時器 Timer 原始碼分析和使用建議

前面說到每個 Timer 物件內部包含一個 TaskQueue 例項,在執行定時任務時,TimerThread 中將這個 taskqueue 物件作為鎖,在任何時刻只能有一個執行緒執行 TimerTask 。Timer 類為了保證執行緒安全的,是不需要外部同步機制就可以共享同一個 Timer 物件。

可以看到 Timer 是不會捕獲異常的,如果 TimerTask 丟擲的了未檢查異常則會導致 Timer 執行緒終止,同時 Timer 也不會重新恢復執行緒的執行,它會錯誤的認為整個 Timer 執行緒都會取消。同時,已經被安排但尚未執行的 TimerTask 也不會再執行了,新的任務也不能被排程。所以,如果 TimerTask 丟擲未檢查的異常,Timer 將會產生無法預料的行為。

注意看計劃安排任務的核心程式碼,包括任務計劃執行時間的設定,也有優先佇列保持二叉堆序性地操作。下面程式碼很好地體現了 period 屬性作用,period 為正值時表示固定速率執行,負值表示固定延遲執行,值 0 表示一個非重複性的任務。

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);
    }
}
複製程式碼

前面提過 Timer 使用 schedule (TimerTask task, Date firstTime, long period) 方法執行的計時器任務可能會因為前一個任務執行時間較長而延時。每一次執行的 task 的計劃時間會隨著前一個 task 的實際時間而發生改變,也就是 scheduledExecutionTime(n+1) = realExecutionTime(n) + periodTime。也就是說如果第 n 個 task 由於某種情況導致這次的執行時間過程,最後導致 systemCurrentTime>= scheduledExecutionTime(n+1),這是第 n+1 個 task 並不會因為到時了而執行,他會等待第 n 個 task 執行完之後再執行,那麼這樣勢必會導致 n+2 個的執行時間 scheduledExecutionTime 發生改變。所以 schedule 方法更加註重儲存間隔時間的穩定。

而對於 scheduleAtFixedRate(TimerTask task, Date firstTime, long period),在前面也提過 scheduleAtFixedRate 與 schedule 方法的側重點不同,schedule 方法側重儲存間隔時間的穩定,而 scheduleAtFixedRate 方法更加側重於保持執行頻率的穩定。在 schedule 方法中會因為前一個任務的延遲而導致其後面的定時任務延時,而 scheduleAtFixedRate 方法則不會,如果第 n 個 task 執行時間過長導致 systemCurrentTime >= scheduledExecutionTime(n+1),則不會做任何等待他會立即執行第 n+1 個 task,所以 scheduleAtFixedRate 方法執行時間的計算方法不同於 schedule,而是 scheduledExecutionTime(n)=firstExecuteTime +n*periodTime,該計算方法永遠保持不變。所以 scheduleAtFixedRate 更加側重於保持執行頻率的穩定。

Java 定時器 Timer 原始碼分析和使用建議

說完了 Timer 的原始碼分析,相信大致上也能明白定時集整個流程是怎樣的。下面根據上面這些內容,說一些實際使用建議。

使用建議

最近使用阿里 Java 開發編碼規約外掛,可以看到提示是建議使用 ScheduledExecutorService 代替 Timer :

Java 定時器 Timer 原始碼分析和使用建議

那為什麼要使用 ScheduledExecutorService 代替 Timer :

  1. 前面我們也有提到,Timer 是基於絕對時間的,對系統時間比較敏感,而 ScheduledThreadPoolExecutor 則是基於相對時間;

  2. Timer 是內部是單一執行緒,而 ScheduledThreadPoolExecutor 內部是個執行緒池,所以可以支援多個任務併發執行。

  3. Timer 執行多個 TimeTask 時,只要其中之一沒有捕獲丟擲的異常,其它任務便會自動終止執行,使用 ScheduledExecutorService 則沒有這個問題。

  4. 使用 ScheduledExecutorService 更容易明確任務實際執行策略,更方便自行控制。

  5. 預設 Timer 執行執行緒不是 daemon 執行緒, 任務執行完,主執行緒(或其他啟動定時器的執行緒)結束時,task 執行緒並沒有結束。需要注意潛在記憶體洩漏問題

下面給出一個實際使用 ScheduledExecutorService 代替 Timer 的例子:

import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * ImprovedTimer 改進過的定時器
 * 多執行緒並行處理定時任務時,Timer執行多個TimeTask時,只要其中之一沒有捕獲丟擲的異常,其它任務便會自動終止執行,
 * 使用ScheduledExecutorService則沒有這個問題。
 *
 * @author baishixian
 * @date 2017/10/16
 *
 */

public class ImprovedTimer {


    /**
     * 執行緒池不允許使用Executors去建立,而是通過ThreadPoolExecutor的方式,這樣的處理方式讓寫的同學更加明確執行緒池的執行規則,規避資源耗盡的風險。 說明:Executors各個方法的弊端:
     *  1)newFixedThreadPool和newSingleThreadExecutor:
     *   主要問題是堆積的請求處理佇列可能會耗費非常大的記憶體,甚至OOM。
     * 2)newCachedThreadPool和newScheduledThreadPool:
     *   主要問題是執行緒數最大數是Integer.MAX_VALUE,可能會建立數量非常多的執行緒,甚至OOM。
     *
     *  執行緒池能按時間計劃來執行任務,允許使用者設定計劃執行任務的時間,int型別的引數是設定
     *  執行緒池中執行緒的最小數目。當任務較多時,執行緒池可能會自動建立更多的工作執行緒來執行任務
     */
    private final ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1, new ImprovedTimer.DaemonThreadFactory());
    private ScheduledFuture<?> improvedTimerFuture = null;

    public ImprovedTimer() {
    }

    /**
     * 週期性重複執行定時任務
     * @param command 執行 Runnable
     * @param initialDelay 單位 MILLISECONDS
     * @param period 單位 MILLISECONDS
     */
    public void schedule(Runnable command, long initialDelay, long period){
        // initialDelay 毫秒後開始執行任務,以後每隔 period 毫秒執行一次

        // schedule方法被用來延遲指定時間來執行某個指定任務。
        // 如果你需要週期性重複執行定時任務可以使用scheduleAtFixedRate或者scheduleWithFixedDelay方法,它們不同的是前者以固定頻率執行,後者以相對固定頻率執行。
        // 不管任務執行耗時是否大於間隔時間,scheduleAtFixedRate和scheduleWithFixedDelay都不會導致同一個任務併發地被執行。
        // 唯一不同的是scheduleWithFixedDelay是當前一個任務結束的時刻,開始結算間隔時間,如0秒開始執行第一次任務,任務耗時5秒,任務間隔時間3秒,那麼第二次任務執行的時間是在第8秒開始。

        improvedTimerFuture = executorService.scheduleAtFixedRate(command, initialDelay, period, TimeUnit.MILLISECONDS);
    }

    /**
     * 週期性重複執行定時任務
     * @param command 執行 Runnable
     * @param initialDelay 單位 MILLISECONDS
     */
    public void schedule(Runnable command, long initialDelay){
        // initialDelay 毫秒後開始執行任務

        improvedTimerFuture = executorService.schedule(command, initialDelay, TimeUnit.MILLISECONDS);
    }


    private void cancel() {
        if (improvedTimerFuture != null) {
            improvedTimerFuture.cancel(true);
            improvedTimerFuture = null;
        }
    }

    public void shutdown() {
        cancel();
        executorService.shutdown();
    }


    /**
     * 守護執行緒工廠類,用於生產後臺執行執行緒
     */
    private static final class DaemonThreadFactory implements ThreadFactory {
        private AtomicInteger atoInteger = new AtomicInteger(0);

        @Override
        public Thread newThread(Runnable runnable) {
            Thread thread = new Thread(runnable);
            thread.setName("schedule-pool-Thread-" + atoInteger.getAndIncrement());
            thread.setDaemon(true);
            return thread;
        }
    }
}

複製程式碼

參考: 詳解 Java 定時任務 Java多執行緒總結(3)— Timer 和 TimerTask深入分析

OVER...

相關文章