數據結構與算法分析:(七)隊列

一、隊列的定義

隊列是一種特殊的線性表,是一種先進先出(FIFO)的數據結構。它只允許在表的前端(front)進行刪除操作,而在表的後端(rear)進行插入操作。進行插入操作的端稱爲隊尾,進行刪除操作的端稱爲隊頭。隊列中沒有元素時,稱爲空隊列。

我們知道,棧只支持兩個基本操作:入棧 push()和出棧 pop()。隊列跟棧非常相似,支持的操作也很有限,最基本的操作也是兩個:入隊 enqueue(),放一個數據到隊列尾部;出隊 dequeue(),從隊列頭部取一個元素。

在這裏插入圖片描述

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

我們先來一下看一下Queue的實現:

1、沒有實現的阻塞接口的

內置的不阻塞隊列: PriorityQueueConcurrentLinkedQueue

PriorityQueue 和 ConcurrentLinkedQueue 類在 Collection Framework 中加入兩個具體集合實現。

PriorityQueue 類實質上維護了一個有序列表。加入到 Queue 中的元素根據它們的天然排序(通過其 java.util.Comparable 實現)或者根據傳遞給構造函數的 java.util.Comparator 實現來定位。

ConcurrentLinkedQueue 是基於鏈接節點的、線程安全的隊列。併發訪問不需要同步。因爲它在隊列的尾部添加元素並從頭部刪除它們,所以只要不需要知道隊列的大小,       ConcurrentLinkedQueue 對公共集合的共享訪問就可以工作得很好。收集關於隊列大小的信息會很慢,需要遍歷隊列。

2、實現阻塞接口的

java.util.concurrent 中加入了 BlockingQueue 接口和五個阻塞隊列類。它實質上就是一種帶有一點扭曲的 FIFO 數據結構。不是立即從隊列中添加或者刪除元素,線程執行操作阻塞,直到有空間或者元素可用。

五個隊列所提供的各有不同:

  • ArrayBlockingQueue :一個由數組支持的有界隊列。
  • LinkedBlockingQueue :一個由鏈接節點支持的可選有界隊列。
  • PriorityBlockingQueue :一個由優先級堆支持的無界優先級隊列。
  • DelayQueue :一個由優先級堆支持的、基於時間的調度隊列。
  • SynchronousQueue :一個利用 BlockingQueue 接口的簡單聚集(rendezvous)機制。

在這裏插入圖片描述

關於阻塞隊列與非阻塞隊列的詳細分析可以關注: 源碼解讀專欄

二、如何實現一個隊列?

跟棧一樣,隊列可以用數組來實現,也可以用鏈表來實現。用數組實現的棧叫作順序棧,用鏈表實現的棧叫作鏈式棧。同樣,用數組實現的隊列叫作順序隊列,用鏈表實現的隊列叫作鏈式隊列

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 temp = items[head];
        ++head;
        return temp;
    }
}

對比前面講的棧來說稍微有點複雜。對於棧來說,我們只需要一個棧頂指針就可以了。但是隊列需要兩個指針:一個是 head 指針,指向隊頭;一個是 tail 指針,指向隊尾。


我們還是像前面講的來分析下時間、空間複雜度。

不難發現,隨着不停地進行入隊、出隊操作,head 和 tail 都會持續往後移動。當 tail 移動到最右邊,即使數組中還有空閒空間,也無法繼續往隊列中添加數據了。這個問題該如何解決呢?

你是否還記得我們前面有講過數組的刪除操作會導致數組中的數據不連續。當時我們用的數據搬移。但是,每次進行出隊操作都相當於刪除數組下標爲 0 的數據,要搬移整個隊列中的數據,這樣出隊操作的時間複雜度就會從原來的 O(1) 變爲 O(n)。能否優化一下呢?

實際上,我們在出隊時可以不用搬移數據。如果沒有空閒空間了,我們只需要在入隊時,再集中觸發一次數據的搬移操作。藉助這個思想,出隊函數 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 的位置。


Q:這種實現思路中,出隊操作的時間複雜度仍然是 O(1),但入隊操作的時間複雜度還是 O(1) 嗎?

A:

  • 如果隊尾沒有滿,可以直接入隊,時間複雜度爲O(1)。
  • 如果隊尾已滿的情況下,就必須進行數據搬移了,tail=n,搬移的時間複雜度爲O(n)。
  • 總體情況來看,tail的可能是0 ~ n的任意值,在0 ~ n-1的時候隊列入隊的時間複雜度都是O(1),不需要搬移直接入隊即可;只有當tail=n的時候時間複雜度才迅速飆升爲O(n),即需要進行n次搬移,此時n次的搬移如果均攤到0~n-1這n次上,其實總體的均攤複雜度還是O(1)。

2、鏈式隊列

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

如下代碼:

/**
 * 基於鏈表實現的隊列
 */
public class LinkedListQueue {
    // 隊列的隊首和隊尾
    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 temp = head.data;
        head = head.next;
        if (head == null) tail = null;
        return temp;
    }

    public void printAll() {
        Node temp = head;
        while (temp != null) {
            System.out.print(temp.data + "->");
            temp = temp.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;
        }
    }
}

3、循環隊列

我們剛纔用數組來實現隊列的時候,在 tail==n 時,會有數據搬移操作,這樣入隊操作性能就會受到影響。那有沒有辦法能夠避免數據搬移呢?我們來看看循環隊列的解決思路。

循環隊列就是將隊列存儲空間的最後一個位置繞到第一個位置,形成邏輯上的環狀空間,供隊列循環使用。在循環隊列結構中,當存儲空間的最後一個位置已被使用而再要進入隊運算時,只需要存儲空間的第一個位置空閒,便可將元素加入到第一個位置,即將存儲空間的第一個位置作爲隊尾。循環隊列可以更簡單防止僞溢出的發生,但隊列大小是固定的。

如下圖:

在這裏插入圖片描述

我們可以看到,圖中這個隊列的大小爲 8,當前 head=4,tail=7。當有一個新的元素 a 入隊時,我們放入下標爲 7 的位置。但這個時候,我們並不把 tail 更新爲 8,而是將其在環中後移一位,到下標爲 0 的位置。當再有一個元素 b 入隊時,我們將 b 放入下標爲 0 的位置,然後 tail 加 1 更新爲 1。所以,在 a,b 依次入隊之後,循環隊列中的元素就變成了下面的樣子:

在這裏插入圖片描述

通過這樣的方法,我們成功避免了數據搬移操作。看着好像簡單了,但要寫出沒有bug的循環隊列還是有點難度的。最關鍵的是,要確定好隊空和隊滿的判定條件

在用數組實現的非循環隊列中,隊滿的判斷條件是 tail == n,隊空的判斷條件是 head == tail。那針對循環隊列,如何判斷隊空和隊滿呢?

隊列爲空的判斷條件仍然是 head == tail。但隊列滿的判斷條件就稍微有點複雜了。請看下圖:

在這裏插入圖片描述

tail=3,head=4,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 temp = items[head];
        head = (head + 1) % n;
        return temp;
    }

    public void printAll() {
        if (0 == n) return;
        for (int i = head; i % n != tail; i = (i + 1) % n) {
            System.out.print(items[i] + "->");
        }
        System.out.println();
    }

}

4、阻塞隊列

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

在這裏插入圖片描述

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

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

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

在這裏插入圖片描述

前面的幾個基礎隊列不怎麼用得到感覺,但這個阻塞隊列可是我們搞後端的常客了吧,大名鼎鼎的MQ消息隊列就是基於阻塞隊列實現的。

5、併發隊列

在併發隊列上JDK提供了兩套實現:

  • 一個是以ConcurrentLinkedQueue爲代表的高性能隊列,
  • 一個是以BlockingQueue接口爲代表的阻塞隊列,

無論哪種都繼承自Queue。

(1)、ConcurrentLinkedQueue

ConcurrentLinkedQueue是一個適用於高併發場景下的隊列,通過無鎖的方式,實現了高併發狀態下的高性能。通常ConcurrentLinkedQueue性能好於BlockingQueue。它是一個基於鏈接節點的無界線程安全隊列,該隊列的元素遵循先進先出的原則。頭是最先加入的,尾是最近加入的,該隊列不允許null元素。

ConcurrentLinkedQueue重要方法:

  • add() 和offer() 都是加入元素的方法(在ConcurrentLinkedQueue中這兩個方法沒有任何區別)
  • poll() 和peek() 都是取頭元素節點,區別在於前者會刪除元素(出隊列),後者不會。

(2)、BlockingQueue

阻塞隊列(BlockingQueue)是一個支持兩個附加操作的隊列。
這兩個附加的操作是:

  • 在隊列爲空時,獲取元素的線程會等待隊列變爲非空。
  • 當隊列滿時,存儲元素的線程會等待隊列可用。

阻塞隊列常用於生產者和消費者的場景,生產者是往隊列裏添加元素的線程,消費者是從隊列裏拿元素的線程。阻塞隊列就是生產者存放元素的容器,而消費者也只從容器裏拿元素。

BlockingQueue即阻塞隊列,從阻塞這個詞可以看出,在某些情況下對阻塞隊列的訪問可能會造成阻塞。
被阻塞的情況主要有如下兩種:

  • 當隊列滿了的時候進行入隊列操作
  • 當隊列空了的時候進行出隊列操作

因此,當一個線程試圖對一個已經滿了的隊列進行入隊列操作時,它將會被阻塞,除非有另一個線程做了出隊列操作;同樣,當一個線程試圖對一個空隊列進行出隊列操作時,它將會被阻塞,除非有另一個線程進行了入隊列操作。

在Java中,BlockingQueue的接口位於java.util.concurrent 包中(在Java5版本開始提供),由上面介紹的阻塞隊列的特性可知,阻塞隊列是線程安全的。
在新增的Concurrent包中,BlockingQueue很好的解決了多線程中,如何高效安全“傳輸”數據的問題。通過這些高效並且線程安全的隊列類,爲我們快速搭建高質量的多線程程序帶來極大的便利。

常用的隊列主要有以下兩種:(當然通過不同的實現方式,還可以延伸出很多不同類型的隊列,DelayQueue就是其中的一種)

  • 先進先出(FIFO):先插入的隊列的元素也最先出隊列,類似於排隊的功能。從某種程度上來說這種隊列也體現了一種公平性。
  • 後進先出(LIFO):後插入隊列的元素最先出隊列,這種隊列優先處理最近發生的事件。

最簡單直接的實現方式是直接在 enqueue()、dequeue() 方法上加鎖,但是鎖粒度大併發度會比較低,同一時刻僅允許一個存或者取操作。實際上,基於數組的循環隊列,利用 CAS 原子操作,可以實現非常高效的併發隊列。

多線程環境中,通過隊列可以很容易實現數據共享,比如經典的“生產者”和“消費者”模型中,通過隊列可以很便利地實現兩者之間的數據共享。假設我們有若干生產者線程,另外又有若干個消費者線程。如果生產者線程需要把準備好的數據共享給消費者線程,利用隊列的方式來傳遞數據,就可以很方便地解決他們之間的數據共享問題。但如果生產者和消費者在某個時間段內,萬一發生數據處理速度不匹配的情況呢?理想情況下,如果生產者產出數據的速度大於消費者消費的速度,並且當生產出來的數據累積到一定程度的時候,那麼生產者必須暫停等待一下(阻塞生產者線程),以便等待消費者線程把累積的數據處理完畢,反之亦然。然而,在concurrent包發佈以前,在多線程環境下,我們每個程序員都必須去自己控制這些細節,尤其還要兼顧效率和線程安全,而這會給我們的程序帶來不小的複雜度。好在此時,強大的concurrent包橫空出世了,而它也給我們帶來了強大的BlockingQueue。(在多線程領域:所謂阻塞,在某些情況下會掛起線程(即阻塞),一旦條件滿足,被掛起的線程又會自動被喚醒)

三、知識拓展

Q:我們前面說過,隊列有基於鏈表和基於數組這兩種實現方式。這兩種實現方式對於排隊請求又有什麼區別呢?

A:

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

Q:如何實現無鎖併發隊列?

A:

  • CAS,Compare and Swap即比較並替換。CAS實現原理:CAS有三個操作數:內存值V、預期值A、要修改的值B,當且僅當預期值A和內存值V相同時,將內存值修改爲B並返回true,否則什麼都不做並返回false。
  • 這裏就可以讓下標索引來進行CAS操作,但這裏會出現ABA問題,因爲CAS操作的時候檢查下下標索引有沒有變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。從Java1.5開始JDK的atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章