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


作者:小傅哥
博客: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類的實現部分。

六、系列文章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM