深入 Java Timer 定時排程器實現原理

老錢發表於2018-12-17

使用 Java 來排程定時任務時,我們經常會使用 Timer 類搞定。Timer 簡單易用,其原始碼閱讀起來也非常清晰,本節我們來仔細分析一下 Timer 類,來看看 JDK 原始碼的編寫者是如何實現一個穩定可靠的簡單排程器。

Timer 使用

Timer 排程任務有一次性排程和迴圈排程,迴圈排程有分為固定速率排程(fixRate)和固定時延排程(fixDelay)。固定速率就好比你今天加班到很晚,但是到了第二天還必須準點到公司上班,如果你一不小心加班到了第二天早上 9 點,你就連休息的時間都沒有了。而固定時延的意思是你必須睡夠 8 個小時再過來上班,如果你加班到凌晨 6 點,那就可以下午過來上班了。固定速率強調準點,固定時延強調間隔。

Timer timer = new Timer();

TimerTask task = new TimerTask() {
  public void run() {
    System.out.println("wtf");
  }
};

// 延遲 1s 列印 wtf 一次
timer.schedule(task, 1000)
// 延遲 1s 固定時延每隔 1s 週期列印一次 wtf
timer.schedule(task, 1000, 1000);
// 延遲 1s 固定速率每隔 1s 週期列印一次 wtf
timer.scheduleAtFixRate(task, 1000, 1000)
複製程式碼

如果你有一個任務必須每天準點排程,那就應該使用固定速率排程,並且要確保每個任務執行時間不要太長,千萬別超過了第二天這個點。如果你有一個任務需要每隔幾分鐘跑一次,那就使用固定時延排程,它不是很在乎你的單個任務要跑多長時間。

內部結構

Timer 類裡包含一個任務佇列和一個非同步輪訓執行緒。任務佇列裡容納了所有待執行的任務,所有的任務將會在這一個非同步執行緒裡執行,切記任務的執行程式碼不可以丟擲異常,否則會導致 Timer 執行緒掛掉,所有的任務都沒得執行了。單個任務也不易執行時間太長,否則會影響任務排程在時間上的精準性。比如你一個任務跑了太久,其它等著排程的任務就一直處於飢餓狀態得不到排程。所有任務的執行都是這單一的 TimerThread 執行緒。

class Timer {
  TaskQueue queue = new TaskQueue();
  TimerThread thread = new TimerThread(queue);
}
複製程式碼

圖片
Timer 的任務佇列 TaskQueue 是一個特殊的佇列,它內部是一個陣列。這個陣列會按照待執行時間進行堆排序,堆頂元素總是待執行時間最小的任務。輪訓執行緒會每次輪訓出時間點最近的並且到點的任務來執行。陣列會自動擴容,如果任務非常多。

class TaskQueue {
  TimerTask[] queue = new TimerTask[128];
  int size;
}
複製程式碼

任意執行緒都可以通過 Timer.schedule 方法將任務加入 TaskQueue,但是 TaskQueue 又並不是執行緒安全的資料結構。所在每次修改 TaskQueue 時都需要加鎖。

synchronized(queue) {
  ...
}
複製程式碼

任務狀態

TimerTask 有 4 個狀態,VIRGIN 是預設狀態,剛剛例項化還沒有被排程。SCHEDULED 表示已經將任務塞進 TaskQueue 等待被執行。EXECUTED 表示任務已經執行完成。CANCELLED 表示任務被取消了,還沒來得及執行就被人為取消了。

abstract class TimerTask {
  int state = VIRGIN;
  static final int VIRGIN = 0;
  static final int SCHEDULED = 1;
  static final int EXECUTED = 2;
  static final int CANCELLED = 3;
  
  long nextExecutionTime; // 下次執行時間
  long period = 0; // 間隔
}
複製程式碼

對於一個迴圈任務來說,它不存在 EXECUTED 狀態,因為它每次剛剛執行完成,就被重新排程了。EXECUTED 狀態僅僅存在於一次性任務,而且這個狀態其實並不是表示任務已經執行完成,它是指已經從任務佇列裡摘出來了,馬上就要執行。

任務間隔欄位 period 比較特殊,當使用固定速率時,period 為正值,當使用固定間隔時,period 為負值,當任務是一次性時,period 為零。下面是迴圈任務的下次排程時間設定

currentTime = System.currentTimeMillis();
executionTime = task.nextExecutionTime;
// 固定時延基於 currentTime 順延
// 固定速率基於 executionTime(設定時間) 順延
// next_exec_time = exec_time + period = first_delay + n * period
queue.rescheduleMin(
      task.period<0 ? currentTime   - task.period  
                    : executionTime + task.period);
複製程式碼

對於固定速率來說,如果任務執行時間太長超出了間隔,那麼它可能會持續霸佔任務佇列,因為它的排程時間將總是低於 currentTime,排在堆頂,每次輪訓取出來的都是它。執行完畢後,重新排程這個任務,它的時間依舊趕不上。持續下去你會看到這個任務的排程時間遠遠落後於當前時間,而其它任務可能會徹底餓死。這就是為什麼一定要特別注意固定速率的迴圈任務執行時間不宜過長。

任務鎖

Timer 的任務支援取消操作,取消任務的執行緒和執行任務的執行緒極有可能不是一個執行緒。有可能任務正在執行中,結果另一個執行緒表示要取消任務。這時候 Timer 是如何處理的呢?在 TimerTask 類裡看到了一把鎖。當任務屬性需要修改的時候,都會加鎖。

abstract class TimerTask {
  final Object lock = new Object();
}

// 取消任務
public boolean cancel() {
    synchronized(lock) {
        boolean result = (state == SCHEDULED);
        state = CANCELLED;
        return result;
    }
}

// 排程任務
private void sched(TimerTask task, long time, long period) {
  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;
 }
}

// 執行任務
private void mainLoop() {
  while(true) {
    synchronized(task.lock) {
        if (task.state == TimerTask.CANCELLED) {
            queue.removeMin();
            continue;
        }
        ...
        if(task.period == 0) {
          task.state = TimerTask.EXECUTED;
        } 
        ...
    }
    task.run();
  }
}
複製程式碼

在任務執行之前會檢查任務是不是已經被取消了,如果取消了,就從佇列中移除。一旦任務開始執行 run(),對於單次任務來說它就無法被取消了,而迴圈任務將不會繼續下次排程。如果任務沒有機會得到執行(時間設定的太長),那麼即使這個任務被取消了,它也會一直持續躺在任務佇列中。設想如果你排程了一系列久遠的任務,然後都取消了,這可能會成為一個記憶體洩露點。所以 Timer 還單獨提供了一個 purge() 方法可以一次性清空所有的已取消的任務。

public int purge() {
    int result = 0;
    // 滅掉 CANCELLED 狀態的任務
    synchronized(queue) {
        for (int i = queue.size(); i > 0; i--) {
             if (queue.get(i).state == TimerTask.CANCELLED) {
                queue.quickRemove(i);
                result++;
             }
         }
    }
    // 堆調整
    if (result != 0)
         queue.heapify();
    }
    return result;
}
複製程式碼

任務佇列空了

任務佇列裡沒有任務了,排程執行緒必須按一定的策略進行睡眠。它需要睡眠一直到最先執行的任務到點時立即醒來,所以睡眠截止時間就是第一個任務將要執行的時間。同時在睡覺的時候,有可能會有新的任務被新增進來,它的排程時間可能會更加提前,所以當有新的任務到來時需要可以喚醒正在睡眠的執行緒。

private void mainLoop() {
  while(true) {
    ...
    task = queue.getMin();
    currentTime = System.currentTimeMillis();
    executionTime = task.nextExecutionTime;
    if(executionTime > currentTime) {
      // 開始睡大覺
      queue.wait(executionTime - currentTime);
    }
    ...
  }
}

// 新任務進來了
private void sched(TimerTask task, long time, long period) {
   ...
   queue.add(task);
   if (queue.getMin() == task)
        queue.notify();  // 喚醒輪訓執行緒
}
複製程式碼

程式碼中的 wait() 方法就是呼叫了 Object.wait() 來進行睡眠。當有新任務進來了,發現這個新任務的執行時間是最早的,那就呼叫 notify() 方法喚醒輪訓執行緒。

Timer 終止

Timer 提供了 cancel() 方法清空佇列,停止排程器,不允許有任何新任務進來。它會將 newTasksMayBeScheduled 欄位設定為 false 表示 Timer 即將終止。

class TimerThread {
  ...
  boolean newTasksMayBeScheduled;  // 終止的標誌
  ...
}

public void cancel() {
    synchronized(queue) {
        thread.newTasksMayBeScheduled = false;
        queue.clear();
        queue.notify();
    }
}
複製程式碼

如果 Timer 終止了,還有新任務進來就會丟擲異常。

private void sched(TimerTask task, long time, long period) {
  synchronized(queue) {
    if (!thread.newTasksMayBeScheduled)
       throw new IllegalStateException("Timer already cancelled.");
    ...
  }
}
複製程式碼

我們還注意到 Timer.cancel() 方法會喚醒輪訓執行緒,為的是可以立即停止輪訓。不過如果任務正在執行中,這之後 cancel() 就必須等到任務執行完畢才可以停止。

private void mainLoop() {
   while(true) {
      // 正常清空下,佇列空了,輪訓執行緒會休眠
      // 但是如果 newTasksMayBeScheduled 為 false
      // 那麼迴圈會退出,輪訓執行緒會終止
      while (queue.isEmpty() && newTasksMayBeScheduled)
          queue.wait();
      if (queue.isEmpty())
          break;
      ...
   }
}
複製程式碼

垃圾回收

還有一個特殊的場景需要特別注意,那就是當輪訓執行緒因為佇列裡沒有任務而睡眠的時候,Timer 物件因為不再被引用而被垃圾回收了。這時候需要主動喚醒輪訓執行緒,讓它退出。

class Timer {
  ...
  private final Object threadReaper = new Object() {
        @SuppressWarnings("deprecation")
        protected void finalize() throws Throwable {
            synchronized(queue) {
                thread.newTasksMayBeScheduled = false;
                queue.notify();
            }
        }
  };
  ...
} 
複製程式碼

當 Timer 被回收時,內部欄位 threadPeaper 指向的物件也會被回收。所以 finalize 方法將會被呼叫,喚醒並終止 Timer 輪訓執行緒。如果沒有這個 threadPeaper 物件就可能會導致 JVM 裡留下殭屍執行緒。

深入 Java Timer 定時排程器實現原理

閱讀更多精品文章,微信掃一掃上面的二維碼關注公眾號「碼洞」

相關文章