ScheduledThreadPoolExecutor

N1ce2cu發表於2024-07-27

定時任務 ScheduledThreadPoolExecutor 類有兩個用途:指定時間延遲後執行任務;週期性重複執行任務。

JDK 1.5 之前,主要使用Timer類來完成定時任務,但是Timer有以下缺陷:

  • Timer 是單執行緒模式;
  • 如果在執行任務期間某個 TimerTask 耗時較久,就會影響其它任務的排程;
  • Timer 的任務排程是基於絕對時間的,對系統時間敏感;
  • Timer 不會捕獲執行 TimerTask 時所丟擲的異常,由於 Timer 是單執行緒的,所以一旦出現異常,執行緒就會終止,其他任務無法執行。

JDK 1.5 之後,開發者就拋棄了 Timer,開始使用ScheduledThreadPoolExecutor

public class Main {
    private static final ScheduledExecutorService executor =
            new ScheduledThreadPoolExecutor(1, Executors.defaultThreadFactory());

    private static final SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        // 新建一個固定延遲時間的計劃任務
        executor.scheduleWithFixedDelay(new Runnable() {
            @Override
            public void run() {
                if (haveMsgAtCurrentTime()) {
                    System.out.println(df.format(new Date()));
                    System.out.println("大家注意了,我要發訊息了");
                }
            }
        }, 1, 1, TimeUnit.SECONDS);
    }

    public static boolean haveMsgAtCurrentTime() {
        // 查詢資料庫,有沒有當前時間需要傳送的訊息
        // 這裡省略實現,直接返回true
        return true;
    }
}

類結構


public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor
	implements ScheduledExecutorService {

    public ScheduledThreadPoolExecutor(int corePoolSize,ThreadFactory threadFactory) {
         super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue(), threadFactory);
    }
    //……
}

// 繼承了 ExecutorService 介面,並增加了幾個定時相關的介面方法。前兩個方法用於單次排程執行任務,區別是有沒有返回值。
public interface ScheduledExecutorService extends ExecutorService {

    /**
     * 安排一個Runnable任務在給定的延遲後執行。
     *
     * @param command 需要執行的任務
     * @param delay 延遲時間
     * @param unit 時間單位
     * @return 可用於提取結果或取消的ScheduledFuture
     */
    public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);

    /**
     * 安排一個Callable任務在給定的延遲後執行。
     *
     * @param callable 需要執行的任務
     * @param delay 延遲時間
     * @param unit 時間單位
     * @return 可用於提取結果或取消的ScheduledFuture
     */
    public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);

    /**
     * 安排一個Runnable任務在給定的初始延遲後首次執行,隨後每個period時間間隔執行一次。
     *
     * @param command 需要執行的任務
     * @param initialDelay 首次執行的初始延遲
     * @param period 連續執行之間的時間間隔
     * @param unit 時間單位
     * @return 可用於提取結果或取消的ScheduledFuture
     */
    public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay,
                                                  long period,
                                                  TimeUnit unit);

    /**
     * 安排一個Runnable任務在給定的初始延遲後首次執行,隨後每次完成任務後等待指定的延遲再次執行。
     *
     * @param command 需要執行的任務
     * @param initialDelay 首次執行的初始延遲
     * @param delay 每次執行結束後的延遲時間
     * @param unit 時間單位
     * @return 可用於提取結果或取消的ScheduledFuture
     */
    public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                     long initialDelay,
                                                     long delay,
                                                     TimeUnit unit);
}

scheduleAtFixedRate

scheduleAtFixedRate 方法在initialDelay時長後第一次執行任務,以後每隔period時長再次執行任務。注意,period 是從任務開始執行算起的。開始執行任務後,定時器每隔 period 時長檢查該任務是否完成,如果完成則再次啟動任務,否則等該任務結束後才啟動任務。

scheduleWithFixDelay

該方法在initialDelay時長後第一次執行任務,以後每當任務執行完成後,等待delay時長,再次執行任務。

主要方法


schedule

// delay時長後執行任務command,該任務只執行一次
public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit) {
    if (command == null || unit == null)
        throw new NullPointerException();
    // 這裡的 decorateTask 方法僅僅返回第二個引數
    RunnableScheduledFuture<?> t = decorateTask(command,
                                   		new ScheduledFutureTask<Void>(command, null, triggerTime(delay,unit)));
    // 延時或者週期執行任務的主要方法,稍後統一說明
    delayedExecute(t);
    return t;
}

Delayed 介面

// 繼承Comparable介面,表示該類物件支援排序
public interface Delayed extends Comparable<Delayed> {
    // 返回該物件剩餘時延
    long getDelay(TimeUnit unit);
}

ScheduledFuture 介面

// 僅僅繼承了Delayed和Future介面,自己沒有任何程式碼
public interface ScheduledFuture<V> extends Delayed, Future<V> {
}

RunnableScheduledFuture 介面

public interface RunnableScheduledFuture<V> extends RunnableFuture<V>, ScheduledFuture<V> {    
    // 是否是週期任務,週期任務可被排程執行多次,非週期任務只被執行一次  
    boolean isPeriodic();
}

ScheduledFutureTask 類

schecule方法中,它建立了一個ScheduledFutureTask物件,由上面的關係圖可知,ScheduledFutureTask直接或者間接實現了很多介面。

構造方法

ScheduledFutureTask(Runnable r, V result, long ns, long period) {
    // 呼叫父類FutureTask的構造方法
    super(r, result);
    // time表示任務下次執行的時間
    this.time = ns;
    // 週期任務,正數表示按照固定速率,負數表示按照固定時延,0表示不是週期任務
    this.period = period;
    // 任務的編號
    this.sequenceNumber = sequencer.getAndIncrement();
}

Delayed 介面的實現

// 實現Delayed介面的getDelay方法,返回任務開始執行的剩餘時間
public long getDelay(TimeUnit unit) {
    return unit.convert(time - now(), TimeUnit.NANOSECONDS);
}

Comparable 介面的實現

// Comparable介面的compareTo方法,比較兩個任務的”大小”。
public int compareTo(Delayed other) {
    if (other == this)
      return 0;
    if (other instanceof ScheduledFutureTask) {
      ScheduledFutureTask<?> x = (ScheduledFutureTask<?>)other;
      long diff = time - x.time;
      // 小於0,說明當前任務的執行時間點早於other,要排在延時佇列other的前面
      if (diff < 0)
        return -1;
      // 大於0,說明當前任務的執行時間點晚於other,要排在延時佇列other的後面
      else if (diff > 0)
        return 1;
      // 如果兩個任務的執行時間點一樣,比較兩個任務的編號,編號小的排在佇列前面,編號大的排在佇列後面
      else if (sequenceNumber < x.sequenceNumber)
        return -1;
      else
        return 1;
    }
    // 如果任務型別不是ScheduledFutureTask,透過getDelay方法比較
    long d = (getDelay(TimeUnit.NANOSECONDS) -
              other.getDelay(TimeUnit.NANOSECONDS));
    return (d == 0) ? 0 : ((d < 0) ? -1 : 1);
}

setNextRunTime

// 任務執行完後,設定下次執行的時間
private void setNextRunTime() {
    long p = period;
    // p > 0,說明是固定速率執行的任務
    // 在原來任務開始執行時間的基礎上加上p即可
    if (p > 0)
      time += p;
    // p < 0,說明是固定時延執行的任務,
    // 下次執行時間在當前時間(任務執行完成的時間)的基礎上加上-p的時間
    else
      time = triggerTime(-p);
}

Runnable 介面實現

public void run() {
    boolean periodic = isPeriodic();
    // 如果當前狀態下不能執行任務,則取消任務
    if (!canRunInCurrentRunState(periodic))
      cancel(false);
    // 不是週期性任務,執行一次任務即可,呼叫父類的run方法
    else if (!periodic)
      ScheduledFutureTask.super.run();
    // 是週期性任務,呼叫FutureTask的runAndReset方法,方法執行完成後
    // 重新設定任務下一次執行的時間,並將該任務重新入隊,等待再次被排程
    else if (ScheduledFutureTask.super.runAndReset()) {
      setNextRunTime();
      reExecutePeriodic(outerTask);
    }
}

總結一下 run 方法的執行過程:

  1. 如果當前執行緒池執行狀態不執行執行任務,那麼就取消該任務,然後直接返回,否則執行步驟 2;
  2. 如果不是週期性任務,呼叫 FutureTask 中的 run 方法執行,會設定執行結果,然後直接返回,否則執行步驟 3;
  3. 如果是週期性任務,呼叫 FutureTask 中的 runAndReset 方法執行,不會設定執行結果,然後直接返回,否則執行步驟 4 和步驟 5;
  4. 計算下次執行該任務的具體時間;
  5. 重複執行任務。

runAndReset方法是為任務多次執行而設計的。runAndReset方法執行完任務後不會設定任務的執行結果,也不會去更新任務的狀態,以及維持任務的狀態為初始狀態(NEW狀態),這也是該方法和 FutureTask run方法的區別。

scheduleAtFixedRate

// 注意,固定速率和固定時延,傳入的引數都是Runnable,也就是說這種定時任務是沒有返回值的
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
                                                  long initialDelay,
                                                  long period,
                                                  TimeUnit unit) {
    if (command == null || unit == null)
      throw new NullPointerException();
    if (period <= 0)
      throw new IllegalArgumentException();
    // 建立一個有初始延時和固定週期的任務
    ScheduledFutureTask<Void> sft =
      new ScheduledFutureTask<Void>(command,
                                    null,
                                    triggerTime(initialDelay, unit),
                                    unit.toNanos(period));
    RunnableScheduledFuture<Void> t = decorateTask(command, sft);
    // outerTask表示將會重新入隊的任務
    sft.outerTask = t;
    // 稍後說明
    delayedExecute(t);
    return t;
}

scheduleAtFixedRate這個方法和schedule類似,不同點是scheduleAtFixedRate方法內部建立的是ScheduledFutureTask,帶有初始延時和固定週期的任務。

scheduleWithFixedDelay

scheduleWithFixedDelay也是透過ScheduledFutureTask體現的,唯一不同的地方在於建立的ScheduledFutureTask不同。

public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
                                                     long initialDelay,
                                                     long delay,
                                                     TimeUnit unit) {
    if (command == null || unit == null)
      throw new NullPointerException();
    if (delay <= 0)
      throw new IllegalArgumentException();
    // 建立一個有初始延時和固定時延的任務
    ScheduledFutureTask<Void> sft =
      new ScheduledFutureTask<Void>(command,
                                    null,
                                    triggerTime(initialDelay, unit),
                                    unit.toNanos(-delay));
    RunnableScheduledFuture<Void> t = decorateTask(command, sft);
    // outerTask表示將會重新入隊的任務
    sft.outerTask = t;
    // 稍後說明
    delayedExecute(t);
    return t;
}

delayedExecute

前面講到的schedulescheduleAtFixedRatescheduleWithFixedDelay最後都呼叫了delayedExecute方法,該方法是定時任務執行的主要方法。

private void delayedExecute(RunnableScheduledFuture<?> task) {
    // 執行緒池已經關閉,呼叫拒絕執行處理器處理
    if (isShutdown())
      reject(task);
    else {
      // 將任務加入到等待佇列
      super.getQueue().add(task);
      // 執行緒池已經關閉,且當前狀態不能執行該任務,將該任務從等待佇列移除並取消該任務
      if (isShutdown() &&
          !canRunInCurrentRunState(task.isPeriodic()) &&
          remove(task))
        task.cancel(false);
      else
        // 增加一個worker,就算corePoolSize=0也要增加一個worker
        ensurePrestart();
    }
}

delayedExecute方法將任務新增到等待佇列,然後呼叫ensurePrestart方法。

void ensurePrestart() {
    int wc = workerCountOf(ctl.get());
    if (wc < corePoolSize)
        addWorker(null, true);
    else if (wc == 0)
        addWorker(null, false);
}

ensurePrestart方法主要是呼叫了addWorker 方法,執行緒池中的工作執行緒就是透過該方法來啟動並執行任務的。

對於ScheduledThreadPoolExecutorworker新增到執行緒池後會在等待佇列中等待獲取任務,這點是和ThreadPoolExecutor是一致的。但是 worker 是怎麼從等待佇列取定時任務的呢?

DelayedWorkQueue

ScheduledThreadPoolExecutor使用了DelayedWorkQueue 來儲存等待的任務。

該等待佇列的隊首應該儲存的是最近將要執行的任務,所以worker只關心隊首任務,如果隊首任務的開始執行時間還未到,worker 也應該繼續等待。

DelayedWorkQueue 是一個無界優先佇列,使用陣列儲存,底層使用堆結構來實現優先佇列的功能。

可以轉換成如下的陣列:

在這種結構中,可以發現有如下特性。假設,索引值從 0 開始,子節點的索引值為 k,父節點的索引值為 p,則:

  • 一個節點的左子節點的索引為:k = p * 2 + 1;
  • 一個節點的右子節點的索引為:k = (p + 1) * 2;
  • 一個節點的父節點的索引為:p = (k - 1) / 2。

DelayedWorkQueue 的宣告和成員變數:

static class DelayedWorkQueue extends AbstractQueue<Runnable>
implements BlockingQueue<Runnable> {
	// 佇列初始容量
	private static final int INITIAL_CAPACITY = 16;
	// 陣列用來儲存定時任務,透過陣列實現堆排序
	private RunnableScheduledFuture[] queue = new RunnableScheduledFuture[INITIAL_CAPACITY];
	// 當前在隊首等待的執行緒
	private Thread leader = null;
	// 鎖和監視器,用於leader執行緒
	private final ReentrantLock lock = new ReentrantLock();
	private final Condition available = lock.newCondition();
	// 其他程式碼,略
}

當一個執行緒成為 leader,它只需等待隊首任務的 delay 時間即可,其他執行緒會無條件等待。leader 取到任務返回前要通知其他執行緒,直到有執行緒成為新的 leader。每當隊首的定時任務被其他更早需要執行的任務替換,leader 就設定為 null,其他等待的執行緒(被當前 leader 通知)和當前的 leader 重新競爭成為 leader。

所有執行緒都會有三種身份中的一種:leader、follower,以及一個幹活中的狀態:proccesser。它的基本原則是,永遠最多隻有一個 leader。所有 follower 都在等待成為 leader。執行緒池啟動時會自動產生一個 Leader 負責等待網路 IO 事件,當有一個事件產生時,Leader 執行緒首先通知一個 Follower 執行緒將其提拔為新的 Leader,然後自己就去幹活了,去處理這個網路事件,處理完畢後加入 Follower 執行緒等待佇列,等待下次成為 Leader。這種方法可以增強 CPU 快取記憶體相似性,及消除動態記憶體分配和執行緒間的資料交換。

同時,定義了 ReentrantLock 鎖 lock 和 Condition available 用於控制和通知下一個執行緒競爭成為 leader。

當一個新的任務成為隊首,或者需要有新的執行緒成為 leader 時,available 監視器上的執行緒將會被通知,然後競爭成為 leader 執行緒。有些類似於生產者-消費者模式。

DelayedWorkQueue 是一個優先順序佇列,它可以保證每次出隊的任務都是當前佇列中執行時間最靠前的,由於它是基於堆結構的佇列,堆結構在執行插入和刪除操作時的最壞時間複雜度是 O(logN)

接下來看看DelayedWorkQueue中幾個比較重要的方法。

take

public RunnableScheduledFuture take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
      for (;;) {
        // 取堆頂的任務,堆頂是最近要執行的任務
        RunnableScheduledFuture first = queue[0];
        // 堆頂為空,執行緒要在條件available上等待
        if (first == null)
          available.await();
        else {
          // 堆頂任務還要多長時間才能執行
          long delay = first.getDelay(TimeUnit.NANOSECONDS);
          // 堆頂任務已經可以執行了,finishPoll會重新調整堆,使其滿足最小堆特性,該方法設定任務在
          // 堆中的index為-1並返回該任務
          if (delay <= 0)
            return finishPoll(first);
          // 如果leader不為空,說明已經有執行緒成為leader並等待堆頂任務
          // 到達執行時間,此時,其他執行緒都需要在available條件上等待
          else if (leader != null)
            available.await();
          else {
            // leader為空,當前執行緒成為新的leader
            Thread thisThread = Thread.currentThread();
            leader = thisThread;
            try {
              // 當前執行緒已經成為leader了,只需要等待堆頂任務到達執行時間即可
              available.awaitNanos(delay);
            } finally {
              // 返回堆頂元素之前將leader設定為空
              if (leader == thisThread)
                leader = null;
            }
          }
        }
      }
    } finally {
      // 通知其他在available條件等待的執行緒,這些執行緒可以去競爭成為新的leader
      if (leader == null && queue[0] != null)
        available.signal();
      lock.unlock();
    }
}

take方法是什麼時候呼叫的呢?

在講解執行緒池的時候,我們介紹了getTask方法,工作執行緒會迴圈從workQueue中取任務。但計劃任務卻不同,因為一旦getTask方法取出了任務就開始執行了,而這時可能還沒有到執行時間,所以在take方法中,要保證只有到指定的執行時間,任務才可以被取走。

總結一下流程:

  1. 如果堆頂元素為空,在 available 上等待。
  2. 如果堆頂任務的執行時間已到,將堆頂元素替換為堆的最後一個元素並調整堆使其滿足最小堆特性,同時設定任務在堆中索引為-1,返回該任務。
  3. 如果 leader 不為空,說明已經有執行緒成為 leader 了,其他執行緒都要在 available 監視器上等待。
  4. 如果 leader 為空,當前執行緒成為新的 leader,並等待直到堆頂任務執行時間到達。
  5. take 方法返回之前,將 leader 設定為空,並通知其他執行緒。

再來說一下 leader 的作用,這裡的 leader 是為了減少不必要的定時等待,當一個執行緒成為 leader 時,它只等待下一個節點的時間間隔,但其它執行緒無限期等待。 leader 執行緒必須在take()poll()返回之前 signal 其它執行緒,除非其他執行緒成為了 leader。

舉例來說,如果沒有 leader,那麼在執行 take 時,都要執行available.awaitNanos(delay),假設當前執行緒執行了該段程式碼,這時還沒有 signal,第二個執行緒也執行了該段程式碼,則第二個執行緒也要被阻塞。

但只有一個執行緒返回隊首任務,其他的執行緒在awaitNanos(delay)之後,繼續執行 for 迴圈,因為隊首任務已經被返回了,所以這個時候的 for 迴圈拿到的隊首任務是新的,又需要重新判斷時間,又要繼續阻塞。

所以,為了不讓多個執行緒頻繁的做無用的定時等待,這裡增加了 leader,如果 leader 不為空,則說明佇列中第一個節點已經在等待出隊,這時其它的執行緒會一直阻塞,減少了無用的阻塞(注意,在finally中呼叫了signal()來喚醒一個執行緒,而不是signalAll())。

offer

該方法往佇列插入一個值,返回是否成功插入。

public boolean offer(Runnable x) {
    if (x == null)
      throw new NullPointerException();
    RunnableScheduledFuture e = (RunnableScheduledFuture)x;
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
      int i = size;
      // 佇列元素已經大於等於陣列的長度,需要擴容,新堆的容量是原來堆容量的1.5倍
      if (i >= queue.length)
        grow();
      // 堆中元素增加1
      size = i + 1;
      // 調整堆
      if (i == 0) {
        queue[0] = e;
        setIndex(e, 0);
      } else {
          // 調整堆,使的滿足最小堆,比較大小的方式就是上文提到的compareTo方法
        siftUp(i, e);
      }
      if (queue[0] == e) {
        leader = null;
        // 通知其他在available條件上等待的執行緒,這些執行緒可以競爭成為新的leader
        available.signal();
      }
    } finally {
      lock.unlock();
    }
    return true;
}

offer 方法實現了向延遲佇列插入一個任務的操作,並保證整個佇列仍然滿足最小堆的性質。

最小堆(Min Heap)是一個完全二叉樹,其中每一個父節點的值都小於或等於其子節點的值。換句話說,在最小堆中,根節點(即樹的頂部)是所有節點中的最小值。

前面我們也提到過最小堆。我們來看一下用於調整堆的 siftUp 方法。

private void siftUp(int k, RunnableScheduledFuture<?> key) {
    while (k > 0) {
        // 找到父節點的索引
        int parent = (k - 1) >>> 1;
        // 獲取父節點
        RunnableScheduledFuture<?> e = queue[parent];
        // 如果key節點的執行時間大於父節點的執行時間,不需要再排序了
        if (key.compareTo(e) >= 0)
            break;
        // 如果key.compareTo(e) < 0,說明key節點的執行時間小於父節點的執行時間,需要把父節點移到後面
        queue[k] = e;
        // 設定索引為k
        setIndex(e, k);
        k = parent;
    }
    // key設定為排序後的位置中
    queue[k] = key;
    setIndex(key, k);
}

程式碼很好理解,就是迴圈的根據key節點與它的父節點來判斷,如果key節點的執行時間小於父節點,則將兩個節點交換,使執行時間靠前的節點排列在佇列的前面。

假設新入隊的節點的延遲時間(呼叫getDelay()方法獲得)是5,執行過程如下:

1、先將新的節點新增到陣列的尾部,這時新節點的索引k為7:

2、計算新父節點的索引:parent = (k - 1) >>> 1,parent = 3,那麼queue[3]的時間間隔值為8,因為 5 < 8 ,將執行queue[7] = queue[3]

3、這時將k設定為3,繼續迴圈,再次計算parent為1,queue[1]的時間間隔為3,因為 5 > 3 ,這時退出迴圈,最終k為3:

可見,每次新增節點時,只是根據父節點來判斷,而不會影響兄弟節點。