面經手冊 · 第9篇《佇列是什麼?什麼是雙端佇列、延遲對列、阻塞佇列,全是知識盲區!》

小傅哥發表於2020-09-03

作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

買房子最重要的是房屋格局!

如果買房子能接受地理位置、平米價格外,最重要的就是房屋格局。什麼?丈母孃!你??‍♂,出去! 房屋的格局其實對應的就是程式開發的根本,也就是資料結構。有的土豪可以用錢換空間,房間格局更大,那沒錢的就只能選經濟小空間節省錢。是不是很像不同的資料結構,直接影響著是空間換時間,還是時間換空間。那麼,再細看房間,如;客廳沙發坐人像雜湊表、去廚房?叫進棧「LIFO」、上廁所?叫入佇列「FIFO」、晚上各回各屋子像進陣列。所以你能在這個屋子生活的舒服,很大一部分取決於整個房間的佈局。也同樣你能把程式寫好,很大的原因是因為資料結構定義的合理。

那麼決定這程式開發基礎資料結構有哪些呢?

程式開發中資料結構可以分為這八類;陣列連結串列佇列雜湊表。其中,陣列、連結串列、雜湊表、樹是程式開發直接或者間接用到的最多的。相關的對應實現類可以包括如下;

型別 實現 文章
陣列 ArrayList ArrayList也這麼多知識?一個指定位置插入就把謝飛機面暈了!
連結串列 LinkedList LinkedList插入速度比ArrayList快?你確定嗎?
2-3樹、紅黑樹 看圖說話,講解2-3平衡樹「紅黑樹的前身」
紅黑樹操作原理,解析什麼時候染色、怎麼進行旋轉、與2-3樹有什麼關聯
雜湊表 HashMap HashMap核心知識,擾動函式、負載因子、擴容連結串列拆分,深度學習
HashMap資料插入、查詢、刪除、遍歷,原始碼分析
Stack
佇列 Queue、Deque
  • 如上,除了棧和佇列外,小傅哥已經編寫了非常細緻的文章來介紹了其他資料結構的核心知識和具體的實現應用。
  • 接下來就把剩下的棧和佇列在本章介紹完,其實這部分知識並不難了,有了以上對陣列和連結串列的理解,其他的資料結構基本都從這兩方面擴充套件出來的。

本文涉及了較多的程式碼和實踐驗證圖稿,歡迎關注公眾號:bugstack蟲洞棧,回覆下載得到一個連結開啟後,找到ID:19?獲取!

二、面試題

謝飛機,飛機你旁邊這是?

:啊,謝坦克,我弟弟。還沒畢業,想來看看大公司面試官的容顏。

:飛機,上次把LinkedList都看了吧,那我問你哈。LinkedList可以當佇列用嗎?

:啊?可以,可以吧!

:那,陣列能當佇列用嗎?不能?對列有啥特點嗎?

:佇列先進先出,嗯,嗯。

:還有嗎?瞭解延時佇列嗎?雙端佇列呢?

飛機拉著坦克的手出門了,還帶走了面試官送的一本《面經手冊》,坦克對飛機說,基礎不牢,地動山搖,我要好好學習。

三、資料結構

把我們已經掌握了的陣列和連結串列立起來,就是棧和佇列了!

如圖,這一章節的資料結構的知識點並不難,只要已經學習過陣列和連結串列,那麼對於掌握其他資料結構就已經有了基礎,只不過對於資料的存放、讀取加了一些限定規則。尤其像連結串列這樣的資料結構,只操作頭尾的效率是非常高的。

四、原始碼學習

1. 先說一個被拋棄Stack

有時候不會反而不會犯錯誤!怕就怕在只知道一知半解。

拋棄的不是棧這種資料結構,而是Stack實現類,如果你還不瞭解就用到業務開發中,就很可能會影響系統效能。其實Stack這個棧已經是不建議使用了,但是為什麼不建議使用,我們可以通過使用和原始碼分析瞭解下根本原因。

在學習之前先大概的瞭解下這樣的資料結構,它很像羽毛球的擺放,是一種後進先出佇列,如下;

1.1 功能使用

@Test
public void test_stack() {
    Stack<String> s = new Stack<String>();
    s.push("aaa");
    s.push("bbb");
    s.push("ccc");
    
    System.out.println("獲取最後一個元素:" + s.peek());
    System.out.println("獲取最後一個元素:" + s.lastElement());
    System.out.println("獲取最先放置元素:" + s.firstElement());
    
    System.out.println("彈出一個元素[LIFO]:" + s.pop());
    System.out.println("彈出一個元素[LIFO]:" + s.pop());
    System.out.println("彈出一個元素[LIFO]:" + s.pop());
}

例子是對Stack棧的使用,如果不執行你能知道它的輸出結果嗎?

測試結果:

獲取最後一個元素:ccc
獲取最後一個元素:ccc
獲取最先放置元素:aaa
彈出一個元素[LIFO]:ccc
彈出一個元素[LIFO]:bbb
彈出一個元素[LIFO]:aaa

Process finished with exit code 0

看到測試結果,與你想的答案是否一致?

  • peek,是偷看的意思,就是看一下,不會彈出元素。滿足後進先出的規則,它看的是最後放進去的元素ccc
  • lastElement、firstElement,字面意思的方法,獲取最後一個和獲取第一個元素。
  • pop,是佇列中彈出元素,彈出後也代表著要把屬於這個位置都元素清空,刪掉。

1.2 原始碼分析

我們說Stack棧,這個實現類已經不推薦使用了,需要從它的原始碼上看。

/**
 *
 * <p>A more complete and consistent set of LIFO stack operations is
 * provided by the {@link Deque} interface and its implementations, which
 * should be used in preference to this class.  For example:
 * <pre>   {@code
 *   Deque<Integer> stack = new ArrayDeque<Integer>();}</pre>
 *   
 * @author  Jonathan Payne
 * @since   JDK1.0
 */
public class Stack<E> extends Vector<E> 
s.push("aaa");

public synchronized void addElement(E obj) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = obj;
}
  1. Stack 棧是在JDK1.0時代時,基於繼承Vector,實現的。本身Vector就是一個不推薦使用的類,主要在於它的一些操作方法鎖?(synchronized)的力度太粗,都是放到方法上。
  2. Stack 棧底層是使用Vector陣列實現,在學習ArrayList時候我們知道,陣列結構在元素新增和擅長需要通過System.arraycopy,進行擴容操作。而本身棧的特點是首尾元素的操作,也不需要遍歷,使用陣列結構其實並不太理想。
  3. 同時在這個方法的註釋上也明確標出來,推薦使用Deque<Integer> stack = new ArrayDeque<Integer>();,雖然這也是陣列結構,但是它沒有粗粒度的鎖,同時可以申請指定空間並且在擴容時操作時也要優於Stack 。並且它還是一個雙端佇列,使用起來更靈活。

2. 雙端佇列ArrayDeque

ArrayDeque 是基於陣列實現的可動態擴容的雙端佇列,也就是說你可以在佇列的頭和尾同時插入和彈出元素。當元素數量超過陣列初始化長度時,則需要擴容和遷移資料。

資料結構和操作,如下;

小傅哥 bugstack.cn & 雙端佇列資料結構操作

從上圖我們可以瞭解到如下幾個知識點;

  1. 雙端佇列是基於陣列實現,所以擴容遷移資料操作。
  2. push,像結尾插入、offerLast,向頭部插入,這樣兩端都滿足後進先出。
  3. 整體來看,雙端佇列,就是一個環形。所以擴容後繼續插入元素也滿足後進先出。

2.1 功能使用

@Test
public void test_ArrayDeque() {
    Deque<String> deque = new ArrayDeque<String>(1);
    
    deque.push("a");
    deque.push("b");
    deque.push("c");
    deque.push("d");
    
    deque.offerLast("e");
    deque.offerLast("f");
    deque.offerLast("g");
    deque.offerLast("h");  // 這時候擴容了
    
    deque.push("i");
    deque.offerLast("j");
    
    System.out.println("資料出棧:");
    while (!deque.isEmpty()) {
        System.out.print(deque.pop() + " ");
    }
}

以上這部分程式碼就是與上圖的展現是一致的,按照圖中的分析我們看下輸出結果,如下;

資料出棧:
i d c b a e f g h j 
Process finished with exit code 0
  • i d c b a e f g h j ,正好滿足了我們的說的資料出棧順序。可以參考上圖再進行理解

2.2 原始碼分析

ArrayDeque 這種雙端佇列是基於陣列實現的,所以原始碼上從初始化到資料入棧擴容,都會有陣列操作的痕跡。接下來我們就依次分析下。

2.2.1 初始化

new ArrayDeque<String>(1);,其實它的建構函式初始化預設也提供了幾個方法,比如你可以指定大小以及提供預設元素。

private static int calculateSize(int numElements) {
    int initialCapacity = MIN_INITIAL_CAPACITY;
    // Find the best power of two to hold elements.
    // Tests "<=" because arrays aren't kept full.
    if (numElements >= initialCapacity) {
        initialCapacity = numElements;
        initialCapacity |= (initialCapacity >>>  1);
        initialCapacity |= (initialCapacity >>>  2);
        initialCapacity |= (initialCapacity >>>  4);
        initialCapacity |= (initialCapacity >>>  8);
        initialCapacity |= (initialCapacity >>> 16);
        initialCapacity++;
        if (initialCapacity < 0)   // Too many elements, must back off
            initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 element
    }
    return initialCapacity;
}
  • 在初始化的過程中,它需要找到你當前傳輸值最小的2的倍數的一個容量。這與HashMap的初始化過程相似。
2.2.2 資料入棧

deque.push("a");,ArrayDeque,提供了一個 push 方法,這個方法與deque.offerFirst(“a”),一致,因為它們的底層原始碼是一樣的,如下;

addFirst:

public void addFirst(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[head = (head - 1) & (elements.length - 1)] = e;
    if (head == tail)
        doubleCapacity();
}

addLast:

public void addLast(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[tail] = e;
    if ( (tail = (tail + 1) & (elements.length - 1)) == head)
        doubleCapacity();
}

這部分入棧元素,其實就是給陣列賦值,知識點如下;

  1. addFirst()中,定位下標,head = (head - 1) & (elements.length - 1),因為我們的陣列長度是2^n的倍數,所以 2^n - 1 就是一個全是1的二進位制數,可以用於與運算得出陣列下標。
  2. 同樣addLast()中,也使用了相同的方式定位下標,只不過它是從0開始,往上增加。
  3. 最後,當頭(head)與尾(tile),陣列則需要兩倍擴容doubleCapacity

下標計算:head = (head - 1) & (elements.length - 1)

  • (0 - 1) & (8 - 1) = 7
  • (7 - 1) & (8 - 1) = 6
  • (6 - 1) & (8 - 1) = 5
  • ...
2.2.3 兩倍擴容,資料遷移
private void doubleCapacity() {
    assert head == tail;
    int p = head;
    int n = elements.length;
    int r = n - p; // number of elements to the right of p
    int newCapacity = n << 1;
    if (newCapacity < 0)
        throw new IllegalStateException("Sorry, deque too big");
    Object[] a = new Object[newCapacity];
    System.arraycopy(elements, p, a, 0, r);
    System.arraycopy(elements, 0, a, r, p);
    elements = a;
    head = 0;
    tail = n;
}

其實以上這部分原始碼,就是進行兩倍n << 1擴容,同時把兩端資料遷移進新的陣列,整個操作過程也與我們上圖對應。為了更好的理解,我們單獨把這部分程式碼做一些測試。

測試程式碼:

@Test
public void test_arraycopy() {
    int head = 0, tail = 0;
    Object[] elements = new Object[8];
    elements[head = (head - 1) & (elements.length - 1)] = "a";
    elements[head = (head - 1) & (elements.length - 1)] = "b";
    elements[head = (head - 1) & (elements.length - 1)] = "c";
    elements[head = (head - 1) & (elements.length - 1)] = "d";
    
    elements[tail] = "e";
    tail = (tail + 1) & (elements.length - 1);
    elements[tail] = "f";
    tail = (tail + 1) & (elements.length - 1);
    elements[tail] = "g";
    tail = (tail + 1) & (elements.length - 1);
    elements[tail] = "h";
    tail = (tail + 1) & (elements.length - 1);
    
    System.out.println("head:" + head);
    System.out.println("tail:" + tail);
    
    int p = head;
    int n = elements.length;
    int r = n - p; // number of elements to the right of p
    
    // 輸出當前的元素
    System.out.println(JSON.toJSONString(elements));
    
    // head == tail 擴容
    Object[] a = new Object[8 << 1];
    System.arraycopy(elements, p, a, 0, r);
    System.out.println(JSON.toJSONString(a));
    System.arraycopy(elements, 0, a, r, p);
    System.out.println(JSON.toJSONString(a));
    
    elements = a;
    head = 0;
    tail = n;
    a[head = (head - 1) & (a.length - 1)] = "i";
    System.out.println(JSON.toJSONString(a));
}

以上的測試過程主要模擬了8個長度的空間的陣列,在進行雙端佇列操作時陣列擴容,資料遷移操作,可以單獨執行,測試結果如下;

head:4
tail:4
["e","f","g","h","d","c","b","a"]
["d","c","b","a",null,null,null,null,null,null,null,null,null,null,null,null]
["d","c","b","a","e","f","g","h",null,null,null,null,null,null,null,null]
["d","c","b","a","e","f","g","h","j",null,null,null,null,null,null,"i"]

Process finished with exit code 0

從測試結果可以看到;

  1. 當head與tail相等時,進行擴容操作。
  2. 第一次資料遷移,System.arraycopy(elements, p, a, 0, r);d、c、b、a,落入新陣列。
  3. 第二次資料遷移,System.arraycopy(elements, 0, a, r, p);e、f、g、h,落入新陣列。
  4. 最後再嘗試新增新的元素,i和j。每一次的輸出結果都可以看到整個雙端鏈路的變化。

3. 雙端佇列LinkedList

Linkedlist天生就可以支援雙端佇列,而且從頭尾取資料也是它時間複雜度O(1)的。同時資料的插入和刪除也不需要像陣列佇列那樣拷貝資料,雖然Linkedlist有這些優點,但不能說ArrayDeque因為有陣列複製效能比它低。

Linkedlist,資料結構

3.1 功能使用

@Test
public void test_Deque_LinkedList(){
    Deque<String> deque = new LinkedList<>();
    deque.push("a");
    deque.push("b");
    deque.push("c");
    deque.push("d");
    deque.offerLast("e");
    deque.offerLast("f");
    deque.offerLast("g");
    deque.offerLast("h"); 
    deque.push("i");
    deque.offerLast("j");
    
    System.out.println("資料出棧:");
    while (!deque.isEmpty()) {
        System.out.print(deque.pop() + " ");
    }
}

測試結果

資料出棧:
i d c b a e f g h j 

Process finished with exit code 0
  • 測試結果上看與使用ArrayDeque是一樣的,功能上沒有差異。

3.2 原始碼分析

壓棧deque.push("a");deque.offerFirst("a");

private void linkFirst(E e) {
    final Node<E> f = first;
    final Node<E> newNode = new Node<>(null, e, f);
    first = newNode;
    if (f == null)
        last = newNode;
    else
        f.prev = newNode;
    size++;
    modCount++;
}

壓棧deque.offerLast("e");

void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}
  • linkFirstlinkLast,兩個方法分別是給連結串列的首尾節點插入元素,因為這是連結串列結構,所以也不存在擴容,只需要把雙向鏈路連結上即可。

4. 延時佇列DelayQueue

你是否有時候需要把一些資料存起來,倒數計時到某個時刻在使用?

在Java的佇列資料結構中,還有一種佇列是延時佇列,可以通過設定存放時間,依次輪訓獲取。

4.1 功能使用

先寫一個Delayed的實現類

public class TestDelayed implements Delayed {

    private String str;
    private long time;

    public TestDelayed(String str, long time, TimeUnit unit) {
        this.str = str;
        this.time = System.currentTimeMillis() + (time > 0 ? unit.toMillis(time) : 0);
    }

    @Override
    public long getDelay(TimeUnit unit) {
        return time - System.currentTimeMillis();
    }

    @Override
    public int compareTo(Delayed o) {
        TestDelayed work = (TestDelayed) o;
        long diff = this.time - work.time;
        if (diff <= 0) {
            return -1;
        } else {
            return 1;
        }
    }

    public String getStr() {
        return str;
    }
}
  • 這個相當於延時佇列的一個固定模版方法,通過這種方式來控制延時。

案例測試

@Test
public void test_DelayQueue() throws InterruptedException {
    DelayQueue<TestDelayed> delayQueue = new DelayQueue<TestDelayed>();
    delayQueue.offer(new TestDelayed("aaa", 5, TimeUnit.SECONDS));
    delayQueue.offer(new TestDelayed("ccc", 1, TimeUnit.SECONDS));
    delayQueue.offer(new TestDelayed("bbb", 3, TimeUnit.SECONDS));
    
    logger.info(((TestDelayed) delayQueue.take()).getStr());
    logger.info(((TestDelayed) delayQueue.take()).getStr());
    logger.info(((TestDelayed) delayQueue.take()).getStr());
}

測試結果

01:44:21.000 [main] INFO  org.itstack.interview.test.ApiTest - ccc
01:44:22.997 [main] INFO  org.itstack.interview.test.ApiTest - bbb
01:44:24.997 [main] INFO  org.itstack.interview.test.ApiTest - aaa

Process finished with exit code 0
  • 在案例測試中我們分別設定不同的休眠時間,1、3、5,TimeUnit.SECONDS。
  • 測試結果分別在21、22、24,輸出了我們要的佇列結果。
  • 佇列中的元素不會因為存放的先後順序而導致輸出順序,它們是依賴於休眠時長決定。

4.2 原始碼分析

4.2.1 元素入棧

入棧:delayQueue.offer(new TestDelayed("aaa", 5, TimeUnit.SECONDS));

public boolean offer(E e) {
    if (e == null)
        throw new NullPointerException();
    modCount++;
    int i = size;
    if (i >= queue.length)
        grow(i + 1);
    size = i + 1;
    if (i == 0)
        queue[0] = e;
    else
        siftUp(i, e);
    return true;
}

private void siftUpUsingComparator(int k, E x) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        if (comparator.compare(x, (E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = x;
}
  • 關於資料存放還有 ReentrantLock 可重入鎖?,但暫時不是我們本章節資料結構的重點,後面章節會介紹到。
  • DelayQueue 是基於陣列實現的,所以可以動態擴容,另外它插入元素的順序並不影響最終的輸出順序。
  • 而元素的排序依賴於compareTo方法進行排序,也就是休眠的時間長短決定的。
  • 同時只有實現了Delayed介面,才能存放元素。
4.2.2 元素出棧

出棧delayQueue.take()

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            E first = q.peek();
            if (first == null)
                available.await();
            else {
                long delay = first.getDelay(NANOSECONDS
                if (delay <= 0)
                    return q.poll();
                first = null; // don't retain ref while
                if (leader != null)
                    available.await();
                else {
                    Thread thisThread = Thread.currentT
                    leader = thisThread;
                    try {
                        available.awaitNanos(delay);
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
            available.signal();
        lock.unlock();
    }
}
  • 這部分的程式碼有點長,主要是元素的獲取。DelayQueueLeader-Followr 模式的變種,消費者執行緒處於等待await時,總是等待最先休眠完成的元素。
  • 這裡會最小化的空等時間,提高執行緒利用率。資料結構講完後,後面會有專門章節介紹

5. 還有哪些佇列?

5.1 佇列類結構

型別 實現 描述
Queue LinkedBlockingQueue 由連結串列結構組成的有界阻塞佇列
Queue ArrayBlockingQueue 由陣列結構組成的有界阻塞佇列
Queue PriorityBlockingQueue 支援優先順序排序的無界阻塞佇列
Queue SynchronousQueue 不儲存元素的阻塞佇列
Queue LinkedTransferQueue 由連結串列結構組成的無界阻塞佇列
Deque LinkedBlockingDeque 由連結串列結構組成的雙向阻塞佇列
Deque ConcurrentLinkedDeque 由連結串列結構組成的執行緒安全的雙向阻塞佇列
  • 除了我們已經講過的佇列以外,剩餘的基本都是阻塞佇列,也就是上面這些。
  • 在資料結構方面基本沒有差異,只不過新增了相應的阻塞功能和鎖的機制。

5.2 使用案例

public class DataQueueStack {

	private BlockingQueue<DataBean> dataQueue = null;
	
	public DataQueueStack(){
		//例項化佇列
		dataQueue = new LinkedBlockingQueue<DataBean>(100);
	}
	
	/**
	 * 新增資料到佇列
	 * @param dataBean
	 * @return
	 */
	public boolean doOfferData(DataBean dataBean){
		try {
			return dataQueue.offer(dataBean, 2, TimeUnit.SECONDS);
		} catch (InterruptedException e) {
			e.printStackTrace();
			return false;
		}
	}
	
	/**
	 * 彈出佇列資料
	 * @return
	 */
	public DataBean doPollData(){
		try {
			return dataQueue.poll(2, TimeUnit.SECONDS);
		} catch (InterruptedException e) {
			e.printStackTrace();
			return null;
		}
	}
	
	/**
	 * 獲得佇列資料個數
	 * @return
	 */
	public int doGetQueueCount(){
		return dataQueue.size();
	}
	
}
  • 這是一個LinkedBlockingQueue佇列使用案例,一方面儲存資料,一方面從佇列中獲取進行消費。
  • 因為這是一個阻塞佇列,所以在獲取元素的時候,如果佇列為空,會進行阻塞。
  • LinkedBlockingQueue是一個阻塞佇列,內部由兩個ReentrantLock來實現出入佇列的執行緒安全,由各自的Condition物件的await和signal來實現等待和喚醒功能。

五、總結

  • 關於棧和佇列的資料結構方面到這裡就介紹完了,另外這裡還有一些關於阻塞佇列鎖?的應用過程,到我們後面講鎖相關知識點,再重點介紹。
  • 佇列結構的設計非常適合某些需要LIFO或者FIFO的應用場景,同時在佇列的資料結構中也有雙端、延時和組合的功能類,使用起來也非常方便。
  • 資料結構方面的知識到本章節算是告一段落,如果有優秀的內容,後面還會繼續補充。再下一章節小傅哥(bugstack.cn)準備給大家介紹,關於資料結構中涉及的演算法部分,這些主要來自於Collections類的實現部分。

六、系列文章

相關文章