Java優先順序佇列DelayedWorkQueue原理分析

TigerJin發表於2021-09-09

我們知道執行緒池執行時,會不斷從任務佇列中獲取任務,然後執行任務。如果我們想實現延時或者定時執行任務,重要一點就是任務佇列會根據任務延時時間的不同進行排序,延時時間越短地就排在佇列的前面,先被獲取執行。

佇列是先進先出的資料結構,就是先進入佇列的資料,先被獲取。但是有一種特殊的佇列叫做優先順序佇列,它會對插入的資料進行優先順序排序,保證優先順序越高的資料首先被獲取,與資料的插入順序無關。

實現優先順序佇列高效常用的一種方式就是使用堆。

一. 用堆實現優先順序佇列

在這篇文章中,我們詳細地講解了堆排序的實現。這裡我們回顧一下。

1.1 什麼是堆

  1. 它是一個完全二叉樹,即除了最後一層節點不是滿的,其他層節點都是滿的,即左右節點都有。

  2. 它不是二叉搜尋樹,即左節點的值都比父節點值小,右節點的值都不比父節點值小,這樣查詢的時候,就可以透過二分的方式,效率是(log N)。

  3. 它是特殊的二叉樹,它要求父節點的值不能小於子節點的值。這樣保證大的值在上面,小的值在下面。所以堆遍歷和查詢都是低效的,因為我們只知道
    從根節點到子葉節點的每條路徑都是降序的,但是各個路徑之間都是沒有聯絡的,查詢一個值時,你不知道應該從左節點查詢還是從右節點開始查詢。

  4. 它可以實現快速的插入和刪除,效率都在(log N)左右。所以它可以實現優先順序佇列。

堆是一個二叉樹,但是它最簡單的方式是透過陣列去實現二叉樹,而且因為堆是一個完全二叉樹,就不存在陣列空間的浪費。怎麼使用陣列來儲存二叉樹呢?

就是用陣列的下標來模擬二叉樹的各個節點,比如說根節點就是0,第一層的左節點是1,右節點是2。由此我們可以得出下列公式:

// 對於n位置的節點來說:int left = 2 * n + 1; // 左子節點int right = 2 * n + 2; // 右子節點int parent = (n - 1) / 2; // 父節點,當然n要大於0,根節點是沒有父節點的

對於堆來說,只有兩個操作,插入insert和刪除remove,不管插入還是刪除保證堆的成立條件,1.是完全二叉樹,2.父節點的值不能小於子節點的值。

  public void insert(int value) {         // 第一步將插入的值,直接放在最後一個位置。並將長度加一
         store[size++] = value;         // 得到新插入值所在位置。
         int index = size - 1;         while(index > 0) {             // 它的父節點位置座標
             int parentIndex = (index - 1) / 2;             // 如果父節點的值小於子節點的值,你不滿足堆的條件,那麼就交換值
             if (store[index] > store[parentIndex]) {
                 swap(store, index, parentIndex);
                 index = parentIndex;
             } else {                 // 否則表示這條路徑上的值已經滿足降序,跳出迴圈
                 break;
             }
         }
     }

主要步驟:

  1. 直接將value插入到size位置,並將size自增,這樣store陣列中插入一個值了。

  2. 要保證從這個葉節點到根節點這條路徑上的節點,滿足父節點的值不能小於子節點。

  3. 透過int parentIndex = (index - 1) / 2得到父節點,如果比父節點值大,那麼兩者位置的值交換,然後再拿這個父節點和它的父父節點比較。
    直到這個節點值比父節點值小,或者這個節點已經是根節點就退出迴圈。

因為我們每次只插入一個值,所以只需要保證新插入位置的葉節點到根節點路徑滿足堆的條件,因為其他路徑沒做操作,肯定是滿足條件的。第二因為是直接在size位置插入值,所以肯定滿足是完全二叉樹這個條件。因為每次迴圈index都是除以2這種倍數遞減的方式,所以它最多迴圈次數是(log N)次。

   public int remove() {          // 將根的值記錄,最後返回
          int result = store[0];          // 將最後位置的值放到根節點位置
          store[0] = store[--size];          int index = 0;          // 透過迴圈,保證父節點的值不能小於子節點。
          while(true) {              int leftIndex = 2 * index + 1; // 左子節點
              int rightIndex = 2 * index + 2; // 右子節點
              // leftIndex >= size 表示這個子節點還沒有值。
              if (leftIndex >= size) break;              int maxIndex = leftIndex;              if (store[leftIndex] 

在堆中最大值就在根節點,所以操作步驟:

  1. 將根節點的值儲存到result中。

  2. 將最後節點的值移動到根節點,再將長度減一,這樣滿足堆成立第一個條件,堆是一個完全二叉樹。

  3. 使用迴圈,來滿足堆成立的第二個條件,父節點的值不能小於子節點的值。

  4. 最後返回result。

那麼怎麼樣滿足堆的第二個條件呢?

因為根點的值現在是新值,那麼就有可能比它的子節點小,所以就有可能要進行交換。

  1. 我們要找出左子節點和右子節點那個值更大,因為這個值可能要和父節點值進行交換,如果它不是較大值的話,它和父節點進行交換之後,就會出現父節點的值小於子節點。

  2. 將找到的較大子節點值和父節點值進行比較。

  3. 如果父節點的值小於它,那麼將父節點和較大子節點值進行交換,然後再比較較大子節點和它的子節點。

  4. 如果父節點的值不小於子節點較大值,或者沒有子節點(即這個節點已經是葉節點了),就跳出迴圈。

  5. 每次迴圈我們都是以2的倍數遞增,所以它也是最多迴圈次數是(log N)次。

所以透過堆這種方式可以快速實現優先順序佇列,它的插入和刪除操作的效率都是O(log N)。

二. DelayedWorkQueue類

    static class DelayedWorkQueue extends AbstractQueue        implements BlockingQueue {

從定義中看出DelayedWorkQueue是一個阻塞佇列。

2.1 重要成員屬性

       // 初始時,陣列長度大小。
        private static final int INITIAL_CAPACITY = 16;        // 使用陣列來儲存佇列中的元素。
        private RunnableScheduledFuture>[] queue =            new RunnableScheduledFuture>[INITIAL_CAPACITY];        // 使用lock來保證多執行緒併發安全問題。
        private final ReentrantLock lock = new ReentrantLock();        // 佇列中儲存元素的大小
        private int size = 0;        //特指佇列頭任務所線上程
        private Thread leader = null;        
        // 當佇列頭的任務延時時間到了,或者有新的任務變成佇列頭時,用來喚醒等待執行緒
        private final Condition available = lock.newCondition();

DelayedWorkQueue是用陣列來儲存佇列中的元素,那麼我們看看它是怎麼實現優先順序佇列的。

2.2 插入元素排序siftUp方法

        private void siftUp(int k, RunnableScheduledFuture> key) {            // 當k==0時,就到了堆二叉樹的根節點了,跳出迴圈
            while (k > 0) {                // 父節點位置座標, 相當於(k - 1) / 2
                int parent = (k - 1) >>> 1;                // 獲取父節點位置元素
                RunnableScheduledFuture> e = queue[parent];                // 如果key元素大於父節點位置元素,滿足條件,那麼跳出迴圈
                // 因為是從小到大排序的。
                if (key.compareTo(e) >= 0)                    break;                // 否則就將父節點元素存放到k位置
                queue[k] = e;                // 這個只有當元素是ScheduledFutureTask物件例項才有用,用來快速取消任務。
                setIndex(e, k);                // 重新賦值k,尋找元素key應該插入到堆二叉樹的那個節點
                k = parent;
            }            // 迴圈結束,k就是元素key應該插入的節點位置
            queue[k] = key;
            setIndex(key, k);
        }

透過迴圈,來查詢元素key應該插入在堆二叉樹那個節點位置,並互動父節點的位置。具體流程在前面已經介紹過了。

2.3 移除元素排序siftDown方法

     private void siftDown(int k, RunnableScheduledFuture> key) {            int half = size >>> 1;            // 透過迴圈,保證父節點的值不能小於子節點。
            while (k  c = queue[child];                // 右子節點, 相當於 (k * 2) + 2
                int right = child + 1;                // 如果左子節點元素值大於右子節點元素值,那麼右子節點才是較小值的子節點。
                // 就要將c與child值重新賦值
                if (right  0)
                    c = queue[child = right];                // 如果父節點元素值小於較小的子節點元素值,那麼就跳出迴圈
                if (key.compareTo(c) 

透過迴圈,保證父節點的值不能小於子節點。

2.4 插入元素方法

        public void put(Runnable e) {
            offer(e);
        }        public boolean add(Runnable e) {            return offer(e);
        }        public boolean offer(Runnable e, long timeout, TimeUnit unit) {            return offer(e);
        }

我們發現與普通阻塞佇列相比,這三個新增方法都是呼叫offer方法。那是因為它沒有佇列已滿的條件,也就是說可以不斷地向DelayedWorkQueue新增元素,當元素個數超過陣列長度時,會進行陣列擴容。

public boolean offer(Runnable x) {            if (x == null)                throw new NullPointerException();
            RunnableScheduledFuture> e = (RunnableScheduledFuture>)x;            // 使用lock保證併發操作安全
            final ReentrantLock lock = this.lock;
            lock.lock();            try {                int i = size;                // 如果要超過陣列長度,就要進行陣列擴容
                if (i >= queue.length)                    // 陣列擴容
                    grow();                // 將佇列中元素個數加一
                size = i + 1;                // 如果是第一個元素,那麼就不需要排序,直接賦值就行了
                if (i == 0) {
                    queue[0] = e;
                    setIndex(e, 0);
                } else {                    // 呼叫siftUp方法,使插入的元素變得有序。
                    siftUp(i, e);
                }                // 表示新插入的元素是佇列頭,更換了佇列頭,
                // 那麼就要喚醒正在等待獲取任務的執行緒。
                if (queue[0] == e) {
                    leader = null;                    // 喚醒正在等待等待獲取任務的執行緒
                    available.signal();
                }
            } finally {
                lock.unlock();
            }            return true;
        }

主要是三步:

  1. 元素個數超過陣列長度,就會呼叫grow()方法,進行陣列擴容。

  2. 將新元素e新增到優先順序佇列中對應的位置,透過siftUp方法,保證按照元素的優先順序排序。

  3. 如果新插入的元素是佇列頭,即更換了佇列頭,那麼就要喚醒正在等待獲取任務的執行緒。這些執行緒可能是因為原佇列頭元素的延時時間沒到,而等待的。

陣列擴容方法:

      private void grow() {            int oldCapacity = queue.length;            // 每次擴容增加原來陣列的一半數量。
            int newCapacity = oldCapacity + (oldCapacity >> 1); // grow 50%
            if (newCapacity 

2.5 獲取佇列頭元素

2.5.1 立即獲取佇列頭元素

      public RunnableScheduledFuture> poll() {            final ReentrantLock lock = this.lock;
            lock.lock();            try {
                RunnableScheduledFuture> first = queue[0];                // 佇列頭任務是null,或者任務延時時間沒有到,都返回null
                if (first == null || first.getDelay(NANOSECONDS) > 0)                    return null;                else
                    // 移除佇列頭元素
                    return finishPoll(first);
            } finally {
                lock.unlock();
            }
        }

當佇列頭任務是null,或者任務延時時間沒有到,表示這個任務還不能返回,因此直接返回null。否則呼叫finishPoll方法,移除佇列頭元素並返回。

        // 移除佇列頭元素
        private RunnableScheduledFuture> finishPoll(RunnableScheduledFuture> f) {            // 將佇列中元素個數減一
            int s = --size;            // 獲取佇列末尾元素x
            RunnableScheduledFuture> x = queue[s];            // 原佇列末尾元素設定為null
            queue[s] = null;            if (s != 0)                // 因為移除了佇列頭元素,所以進行重新排序。
                siftDown(0, x);
            setIndex(f, -1);            return f;
        }

這個方法與我們在第一節中,介紹堆的刪除方法一樣。

  1. 先將佇列中元素個數減一。

  2. 將原佇列末尾元素設定成佇列頭元素,再將佇列末尾元素設定為null。

  3. 呼叫siftDown(0, x)方法,保證按照元素的優先順序排序。

2.5.2 等待獲取佇列頭元素

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(NANOSECONDS);                        // 如果延時時間到了,就返回這個任務,用來執行。
                        if (delay 

如果佇列中沒有任務,那麼就讓當前執行緒在available條件下等待。如果佇列頭任務的剩餘延時時間delay大於0,那麼就讓當前執行緒在available條件下等待delay時間。

如果佇列插入了新的佇列頭,它的剩餘延時時間肯定小於原來佇列頭的時間,這個時候就要喚醒等待執行緒,看看它是否能獲取任務。

2.5.3 超時等待獲取佇列頭元素

        public RunnableScheduledFuture> poll(long timeout, TimeUnit unit)            throws InterruptedException {            long nanos = unit.toNanos(timeout);            final ReentrantLock lock = this.lock;
            lock.lockInterruptibly();            try {                for (;;) {
                    RunnableScheduledFuture> first = queue[0];                    // 如果沒有任務。
                    if (first == null) {                        // 超時時間已到,那麼就直接返回null
                        if (nanos 

與take方法相比較,就要考慮設定的超時時間,如果超時時間到了,還沒有獲取到有用任務,那麼就返回null。其他的與take方法中邏輯一樣。

三. 總結

使用優先順序佇列DelayedWorkQueue,保證新增到佇列中的任務,會按照任務的延時時間進行排序,延時時間少的任務首先被獲取。


作者:wo883721
連結:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2144/viewspace-2802428/,如需轉載,請註明出處,否則將追究法律責任。

相關文章