1. 程式人生 > 實用技巧 >佇列:佇列線上程池等有限資源池中的應用

佇列:佇列線上程池等有限資源池中的應用

一、如何理解佇列?

  佇列跟棧一樣,也是一種操作受限的線性表資料結構。

  棧只支援兩個基本操作:入棧 push()和出棧 pop()。佇列,先進者先出,入隊 enqueue(),放一個數據到佇列尾部;出隊 dequeue(),從佇列頭部取一個元素。

  佇列的應用:比如迴圈佇列、阻塞佇列、併發佇列。它們在很多偏底層系統、框架、中介軟體的開發中,起著關鍵性的作用。比如高效能佇列 Disruptor、Linux 環形快取,都用到了迴圈併發佇列;Java concurrent 併發包利用 ArrayBlockingQueue 來實現公平鎖等。

二、順序佇列和鏈式佇列

  用陣列實現的佇列叫作順序佇列,用連結串列實現的佇列叫作鏈式佇列。

  1、順序佇列

// 用陣列實現的佇列
public class ArrayQueue {
  // 陣列:items,陣列大小:n
  private String[] items;
  private int n = 0;
  // head表示隊頭下標,tail表示隊尾下標
  private int head = 0;
  private int tail = 0;

  // 申請一個大小為capacity的陣列
  public ArrayQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入隊
  public boolean enqueue(String item) {
    // 如果tail == n 表示佇列已經滿了
    if (tail == n) {return false;}
    items[tail] = item;
    ++tail;
    return true;
  }

  // 出隊
  public String dequeue() {
    // 如果head == tail 表示佇列為空
    if (head == tail) {return null;}
    // 為了讓其他語言的同學看的更加明確,把--操作放到單獨一行來寫了
    String ret = items[head];
    ++head;
    return ret;
  }
}
  佇列需要兩個指標,一個隊頭指標head,一個隊尾指標tail。當發生入隊操作,tail指標後移。發生出隊操作,head指標後移。隨著入隊出隊操作的多次進行,head指標和tail指標都會後移,當tail指標後移到陣列末尾,即使陣列中有空間,佇列也無法進行入隊操作。 可以通過資料遷移來實現對於指標的管理,但沒有必要每次出隊都進行資料遷移,這樣做會使出隊的複雜度變為O(n)。可以在入隊時判斷,如果容量不足則進行一次資料遷移或者動態擴容。這樣做,時間複雜度接近於O(1)。出隊函式 dequeue() 保持不變,我們稍加改造一下入隊函式 enqueue() 即可解決問題。
   // 入隊操作,將item放入隊尾
  public boolean enqueue(String item) {
    // tail == n表示佇列末尾沒有空間了
    if (tail == n) {
      // tail ==n && head==0,表示整個佇列都佔滿了
      if (head == 0) {return false;}
      // 資料搬移
      for (int i = head; i < tail; ++i) {
        items[i-head] = items[i];
      }
      // 搬移完之後重新更新head和tail
      tail -= head;
      head = 0;
    }
    
    items[tail] = item;
    ++tail;
    return true;
  }

  2、鏈式佇列 : head 指標和 tail 指標。它們分別指向連結串列的第一個結點和最後一個結點。如圖所示,入隊時,tail->next= new_node, tail = tail->next;出隊時,head = head->next。

 public class LinkedQueue {
        //定義一個節點類
        private class Node{
            String value;
            Node next;
        }
        //記錄佇列元素個數
        private int size = 0;
        //head指向隊頭結點,tail指向隊尾節點
        private Node head;
        private Node tail;
        //申請一個佇列
        public LinkedQueue(){}
        //入隊
        public boolean enqueue(String item){
            Node newNode = new Node();
            newNode.value = item;
            if (size == 0) {
                head = newNode;
            } else {
                tail.next = newNode;
            }
            tail = newNode;
            size++;
            return true;
        }
        //出隊
        public String dequeue(){
            String res = null;
            if(size == 0) {return res;}
            if(size == 1) {tail = null;}
            res = head.value;
            head = head.next;
            size--;
            return res;
        }
    }

  

三、迴圈佇列

  上面用陣列來實現佇列的時候,在 tail==n 時,會有資料搬移操作,這樣入隊操作效能就會受到影響。那麼迴圈佇列可以解決。

寫好一個迴圈佇列的關鍵是判斷好佇列為空和滿的情況。

  • 佇列為空:head == tail
  • 佇列為滿:(tail + 1)/length == head
public class CircularQueue {
  // 陣列:items,陣列大小:n
  private String[] items;
  private int n = 0;
  // head表示隊頭下標,tail表示隊尾下標
  private int head = 0;
  private int tail = 0;

  // 申請一個大小為capacity的陣列
  public CircularQueue(int capacity) {
    items = new String[capacity];
    n = capacity;
  }

  // 入隊
  public boolean enqueue(String item) {
    // 佇列滿了
    if ((tail + 1) % n == head){ return false;}
    items[tail] = item;
    tail = (tail + 1) % n;
    return true;
  }

  // 出隊
  public String dequeue() {
    // 如果head == tail 表示佇列為空
    if (head == tail){ return null;}
    String ret = items[head];
    head = (head + 1) % n;
    return ret;
  }
}

  

四、阻塞佇列和併發佇列

  阻塞佇列就是在佇列的基礎上增加了阻塞的操作,即佇列為空的時候,從佇列頭取資料會被阻塞,直到佇列不為空的時候再獲取。當佇列滿的時候,從佇列尾插入資料會被阻塞,直到佇列不滿的時候再插入。

  上述定義即為一個生產者消費者模型,因此使用阻塞佇列能夠很容易的實現該模型。參考連結:Java中的併發佇列和阻塞佇列生產者消費者

  使用阻塞執行緒就必然涉及到多執行緒下佇列的插入刪除功能,這個時候就需要使用併發隊列了。最簡單的方式就是在enQueue()deQueue()方法中加鎖。但是鎖的併發粒度比較低,同一時刻只能夠進行一次操作。基於陣列的迴圈佇列,使用CAS操作就可以實現非常高效的併發佇列,這也是迴圈佇列比鏈式佇列使用更廣泛的原因。

  當“生產者”生產資料的速度過快,“消費者”來不及消費時,儲存資料的佇列很快就會滿了,這時生產者就阻塞等待,直到“消費者”消費了資料,“生產者”才會被喚醒繼續生產。不僅如此,基於阻塞佇列,我們還可以通過協調“生產者”和“消費者”的個數,來提高資料處理效率,比如配置幾個消費者,來應對一個生產者。

五、佇列如何線上程池中使用

問題:執行緒池沒有空閒執行緒時,新的任務請求執行緒資源時,執行緒池該如何處理?各種處理策略又是如何實現的呢?

答:兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閒執行緒時,取出排隊的請求繼續處理。

  那如何儲存排隊的請求呢?我們希望公平地處理每個排隊的請求,先進者先服務,所以佇列這種資料結構很適合來儲存排隊請求。我們前面說過,佇列有基於連結串列和基於陣列這兩種實現方式。

  這兩種實現方式對於排隊請求又有什麼區別呢?

  基於連結串列的實現方式,可以實現一個支援無限排隊的無界佇列(unbounded queue),但是可能會導致過多的請求排隊等待,請求處理的響應時間過長。所以,針對響應時間比較敏感的系統,基於連結串列實現的無限排隊的執行緒池是不合適的。而基於陣列實現的有界佇列(bounded queue),佇列的大小有限,所以執行緒池中排隊的請求超過佇列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設定一個合理的佇列大小,也是非常有講究的。佇列太大導致等待的請求太多,佇列太小會導致無法充分利用系統資源、發揮最大效能。

  除了前面講到佇列應用線上程池請求排隊的場景之外,佇列可以應用在任何有限資源池中,用於排隊請求,比如資料庫連線池等。實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過“佇列”這種資料結構來實現請求排隊。