關於算法和數據結構的整理,讓你面對面試從容不破(三)

簡言:

       我們知道,CPU 資源是有限的,任務的處理速度與線程個數並不是線性正相關。相反,過多的線程反而會導致 CPU 頻繁切換,處理性能下降。所以,線程池的大小一般都是綜合考慮要處理任務的特點和硬件環境,來事先設置的。當我們向固定大小的線程池中請求一個線程時,如果線程池中沒有空閒資源了,這個時候線程池如何處理這個請求?是拒絕請求還是排隊請求?各種處理策略又是怎麼實現的呢?實際上,這些問題並不複雜,其底層的數據結構就是我們今天要學的內容,隊列(queue)。

1. 我們如何理解隊列那?

其實隊列就像排隊買肯德基,先來的先買,後來的人只能站末尾,不允許插隊。先進者先出,這就是典型的“隊列”。

隊列跟棧非常相似,棧的兩個操作就是入棧和出棧,隊列的操作也是兩個,入隊:放到數據的最後。 出隊:從數據的頭部取出數據。

隊列跟棧一樣,也是一種操作受限的線性表數據結構。

作爲一種非常基礎的數據結構,隊列的應用也非常廣泛,特別是一些具有某些額外特性的隊列,比如循環隊列、阻塞隊列、併發隊列。它們在很多偏底層系統、框架、中間件的開發中,起着關鍵性的作用。比如高性能隊列 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 指針,指向隊尾。如圖所示:

當我們調用兩次出隊操作之後,隊列中 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 的位置。

 

3.鏈表的隊列實現方式

基於鏈表的實現,我們同樣需要兩個指針:head 指針和 tail 指針。它們分別指向鏈表的第一個結點和最後一個結點。如圖所示,入隊時,tail->next= new_node, tail = tail->next;出隊時,head = head->next。

 

4.阻塞隊列和併發隊列

阻塞隊列其實就是在隊列基礎上增加了阻塞操作。簡單來說,就是在隊列爲空的時候,從隊頭取數據會被阻塞。因爲此時還沒有數據可取,直到隊列中有了數據才能返回;如果隊列已經滿了,那麼插入數據的操作就會被阻塞,直到隊列中有空閒位置後再插入數據,然後再返回。

上述的定義就是一個“生產者 - 消費者模型”!是的,我們可以使用阻塞隊列,輕鬆實現一個“生產者 - 消費者模型”!

這種基於阻塞隊列實現的“生產者 - 消費者模型”,可以有效地協調生產和消費的速度。當“生產者”生產數據的速度過快,“消費者”來不及消費時,存儲數據的隊列很快就會滿了。這個時候,生產者就阻塞等待,直到“消費者”消費了數據,“生產者”纔會被喚醒繼續“生產”。

而且不僅如此,基於阻塞隊列,我們還可以通過協調“生產者”和“消費者”的個數,來提高數據的處理效率。比如前面的例子,我們可以多配置幾個“消費者”,來應對一個“生產者”。

線程安全的隊列我們叫作併發隊列。最簡單直接的實現方式是直接在 enqueue()、dequeue() 方法上加鎖,但是鎖粒度大併發度會比較低,同一時刻僅允許一個存或者取操作。實際上,基於數組的循環隊列,利用 CAS 原子操作,可以實現非常高效的併發隊列。這也是循環隊列比鏈式隊列應用更加廣泛的原因。在實戰篇講 Disruptor 的時候,我會再詳細講併發隊列的應用。

 

問題難點解答:隊列在線程池等有限資源池中的應用

我們一般有兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閒線程時,取出排隊的請求繼續處理。那如何存儲排隊的請求呢?

我們希望公平地處理每個排隊的請求,先進者先服務,所以隊列這種數據結構很適合來存儲排隊請求。我們前面說過,隊列有基於鏈表和基於數組這兩種實現方式。這兩種實現方式對於排隊請求又有什麼區別呢?

基於鏈表的實現方式,可以實現一個支持無限排隊的無界隊列(unbounded queue),但是可能會導致過多的請求排隊等待,請求處理的響應時間過長。所以,針對響應時間比較敏感的系統,基於鏈表實現的無限排隊的線程池是不合適的。

而基於數組實現的有界隊列(bounded queue),隊列的大小有限,所以線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設置一個合理的隊列大小,也是非常有講究的。隊列太大導致等待的請求太多,隊列太小會導致無法充分利用系統資源、發揮最大性能。、

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

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章