隊列也是一種數據結構,今天我們主要學習幾種基本的隊列,然後學習下隊列在線程池中的應用。
1. 什麼是隊列?
隊列具有“先進先出,後進後出”的特點。支持操作的也有限,最基本的操作也只有兩種:
-
入隊enqueue():放一個數據到隊列尾部
-
出隊dequeue():從隊列頭部取出一個元素
從上圖可以看出,隊列和棧一樣,都是操作受限的線性表數據結構。
隊列作爲一種非常基礎的數據結構,應用非常廣泛,特別是一些具有某些額外特性的隊列,比如循環隊列、阻塞隊列、併發隊列。它們在很多底層系統、框架、中間件的開發中,起着非常關鍵的作用。比如高性能Disruptor、Linux環形緩存等,都用到了循環併發隊列;Java concurrent併發包利用ArrayBlockingQueue來實現公平鎖等。
2. 順序隊列和鏈式隊列
隊列跟棧一樣,都可以使用數組和鏈表來實現。
-
用數組實現的隊列叫作順序隊列
-
用鏈表實現的隊列叫作鏈式隊列
順序隊列
// 用數組實現的隊列
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指針:指向隊尾
爲了方便理解,可以結合下面的圖來理解:當a、b、c、d依次入隊後,隊列中的head指針指向下標爲0的位置,tail指針指向下標爲4的位置。
當調用了兩次出隊操作後,隊列中head指針指向下標爲2的位置,tail指針仍然指向下標爲4的位置。
從圖中我們可以看出,隨着不停的進行入隊、出隊操作,head和tail都會不斷的往後移動。當tail移動到最右邊時,即使數組中還有空閒的空間,但還是無法繼續往隊列中添加數據了。爲了優化這種情況,我們只需要在出隊時,不搬移數據。在入隊是,如果沒有空閒空間,就集中觸發一次數據搬移操作。
所以優化方案就是:出隊函數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;
}
從上面的代碼中可以看到,當隊列的tail指針移動到數組最右邊後,如果有新的數據入隊,就將head和tail之間的數據,整體搬移到數組中0到tail-head的位置。
這種實現思路,出隊操作的時間複雜度是O(1)。
鏈式隊列
基於鏈表的實現,同樣需要兩個指針:
-
head指針:指向鏈表的第一個結點
-
tail指針:指向鏈表的最後一個結點
代碼實現如下:
package queue;
public class QueueBasedOnLinkedList {
// 隊列的隊首和隊尾
private Node head = null;
private Node tail = null;
// 入隊
public void enqueue(String value) {
if (tail == null) {
Node newNode = new Node(value, null);
head = newNode;
tail = newNode;
} else {
tail.next = new Node(value, null);
tail = tail.next;
}
}
// 出隊
public String dequeue() {
if (head == null) return null;
String value = head.data;
head = head.next;
if (head == null) {
tail = null;
}
return value;
}
public void printAll() {
Node p = head;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
private static class Node {
private String data;
private Node next;
public Node(String data, Node next) {
this.data = data;
this.next = next;
}
public String getData() {
return data;
}
}
}
爲了方便理解,畫了如下圖:
從圖中可以看出:
入隊時:tail->next=new_node,tail=tail->next
出隊時:head=head->next
3. 循環隊列
在前面用數組實現隊列的時候,當tail=n時,就會進行數據的搬移操作,這樣在入隊的時候性能就會受影響。而循環隊列就可以有效的避免這種情況下的數據搬移。
循環隊列的首尾相連,形成一個環形,如下圖所示:
上面隊列的大小是8,當前head=4,tail=7。
當有一個新元素a入隊時,將其放到下標爲7的位置,但此時並不將tail更新爲8,而是將其在環中後移一位,到下標爲0的位置。
當再有一個新元素b入隊時,將其放到下標爲0的位置,然後tail加1更新爲1。所以,在a、b依次入隊後,循環隊列就變成了如下所示:
通過上面的方法,就可以避免數據搬移的操作。
在用數組實現的非循環隊列中,隊滿的判斷條件是tail == n,隊空的判斷條件是head==tail。
在循環隊列中,隊空的條件仍然是head==tail,但隊滿的條件有點複雜,爲了總結出規律,畫了一張隊滿的圖,如下所示:
在上圖中,head=4,tail=3,n=8,故總結出的規律是(3+1)%8=4,所以在循環隊列中,隊滿的判斷條件是:(tail+1)%n=head。
從上面隊滿的圖中可以看到,其實,當隊列滿的時候,tail指向的位置實際上沒有存儲數據,所以循環隊列會浪費一個數組的存儲空間。
循環隊列代碼實現如下:
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;
}
}
4. 阻塞隊列和併發隊列
阻塞隊列
在隊列的基礎上加上阻塞操作。
在隊列爲空的時候,從隊頭取數據會被阻塞。因爲此時沒有數據可取,直到隊列中有了數據才能返回;在隊列滿了的時候,從隊尾插入數據就會被阻塞,直到隊列中有空閒位置時,再插入數據,然後再返回。
從上面的圖可以看出,阻塞隊列是一種“生產者——消費者模型”,這種模型可以有效地協調生產和消費的速度。當“生產者”的生產速度過快,“消費者”來不及消費時,存儲數據的隊列很快就會變滿,此時,生產者就會阻塞等待,直到“消費者”消費了數據,“生產者”纔會被喚醒繼續“生產”。
其實,基於阻塞隊列,我們可以通過協調“生產者”和“消費者”的個數來提高數據的處理效率,例如我們可以配置多個“消費者”,如下圖所示:
併發隊列
在多線程情況下,會有多個線程同時操作隊列,而線程安全的隊列我們叫作“併發隊列”。
併發隊列最簡單的實現方式就是直接在enqueue()、dequeue()的方法上加鎖,同一時刻只允許一個存或取的操作。但當鎖粒度較大時,併發度會比較低。
5. 隊列在線程池中的應用
當線程池中沒有空閒線程時,此時線程池一般有兩種處理策略:
-
非阻塞式的處理方式,直接拒絕任務請求
-
阻塞式的處理方式,將請求排隊,等到有空閒線程時,取出排隊的請求繼續處理。
爲了公平的處理每個排隊的請求,我們可以採用隊列這種數據結構去處理。從前面我們可以知道隊列有基於鏈表和數組的兩種實現方式,但這兩種實現方式對於排隊請求又有區別:
-
基於鏈表的實現方式,可以實現一個支持無限排隊的無解隊列,但可能會導致過多的請求排隊等待,請求處理響應的時間過長,所以,針對響應時間比較敏感的系統,基於鏈表實現的無限排隊線程池是不合適的。
-
基於數組實現的有界隊列,隊列的大小有限,在線程池中排隊請求超過隊列大小時,後續的請求就會直接被拒絕。這種方式對響應時間敏感的系統來說,就會更加合理。但是,設置一個合理的隊列大小,也是非常重要的。隊列太大會導致等待的請求太多,隊列太小會導致無法充分利用系統資源,不能發揮出最大性能。
實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過“隊列”這種數據結構來實現請求排隊。