佇列:佇列線上程池等有限資源池中的應用
一、如何理解佇列?
佇列跟棧一樣,也是一種操作受限的線性表資料結構。
棧只支援兩個基本操作:入棧 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; } }
// 入隊操作,將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),佇列的大小有限,所以執行緒池中排隊的請求超過佇列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設定一個合理的佇列大小,也是非常有講究的。佇列太大導致等待的請求太多,佇列太小會導致無法充分利用系統資源、發揮最大效能。
除了前面講到佇列應用線上程池請求排隊的場景之外,佇列可以應用在任何有限資源池中,用於排隊請求,比如資料庫連線池等。實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過“佇列”這種資料結構來實現請求排隊。