【Java入門提高篇】Day32 Java容器類詳解(十四)ArrayDeque詳解


  今天來介紹一個不太常見也不太常用的類——ArrayDeque,這是一個很不錯的容器類,如果對它還不了解的話,那么就好好看看這篇文章吧。

  看完本篇,你將會了解到:

  1、ArrayDeque是什么?

  2、ArrayDeque如何使用?

  3、ArrayDeque的內部結構是怎樣的?

  4、ArrayDeque的各個方法是如何實現的?

  5、ArrayDeque是如何擴容的?

  6、ArrayDeque的容量有什么限制?

  7、ArrayDeque和LinkedList相比有什么優勢?

  8、ArrayDeque的應用場景是什么?

一、ArrayDeque簡介

  ArrayDeque是JDK容器中的一個雙端隊列實現,內部使用數組進行元素存儲,不允許存儲null值,可以高效的進行元素查找和尾部插入取出,是用作隊列、雙端隊列、棧的絕佳選擇,性能比LinkedList還要好。聽到這里,不熟悉ArrayDeque的你是不是有點尷尬?JDK中竟然還有這么好的一個容器類?

  別慌,現在了解還來得及,趁響指還沒有彈下去,快上車吧,沒時間解釋了。

  來看一個ArrayDeque的使用小栗子:

public class DequeTest {
    public static void main(String[] args){
        // 初始化容量為4
        ArrayDeque<String> arrayDeque = new ArrayDeque<>(4);
        //添加元素
        arrayDeque.add("A");
        arrayDeque.add("B");
        arrayDeque.add("C");
        arrayDeque.add("D");
        arrayDeque.add("E");
        arrayDeque.add("F");
        arrayDeque.add("G");
        arrayDeque.add("H");
        arrayDeque.add("I");
        System.out.println(arrayDeque);

        // 獲取元素
        String a = arrayDeque.getFirst();
        String a1 = arrayDeque.pop();
        String b = arrayDeque.element();
        String b1 = arrayDeque.removeFirst();
        String c = arrayDeque.peek();
        String c1 = arrayDeque.poll();
        String d = arrayDeque.pollFirst();
        String i = arrayDeque.pollLast();
        String e = arrayDeque.peekFirst();
        String h = arrayDeque.peekLast();
        String h1 = arrayDeque.removeLast();
        System.out.printf("a = %s, a1 = %s, b = %s, b1 = %s, c = %s, c1 = %s, d = %s, i = %s, e = %s, h = %s, h1 = %s", a,a1,b,b1,c,c1,d,i,e,h,h1);
        System.out.println();
        
        // 添加元素
        arrayDeque.push(e);
        arrayDeque.add(h);
        arrayDeque.offer(d);
        arrayDeque.offerFirst(i);
        arrayDeque.offerLast(c);
        arrayDeque.offerLast(h);
        arrayDeque.offerLast(c);
        arrayDeque.offerLast(h);
        arrayDeque.offerLast(i);
        arrayDeque.offerLast(c);
        System.out.println(arrayDeque);

        // 移除第一次出現的C
        arrayDeque.removeFirstOccurrence(c);
        System.out.println(arrayDeque);

        // 移除最后一次出現的C
        arrayDeque.removeLastOccurrence(c);
        System.out.println(arrayDeque);
        
    }
}

  輸出如下:

[A, B, C, D, E, F, G, H, I]
a = A, a1 = A, b = B, b1 = B, c = C, c1 = C, d = D, i = I, e = E, h = H, h1 = H
[I, E, E, F, G, H, D, C, H, C, H, I, C]
[I, E, E, F, G, H, D, H, C, H, I, C]
[I, E, E, F, G, H, D, H, C, H, I]

  可以看到,從ArrayDeque中取出元素的姿勢可謂是五花八門,不過別慌,稍后會對這些方法進行一一講解,現在只需要知道,get、peek、element方法都是獲取元素,但是不會將它移除,而pop、poll、remove都會將元素移除並返回,add和push、offer都是插入元素,它們的不同點在於插入元素的位置以及插入失敗后的結果。

二、ArrayDeque的內部結構

  ArrayDeque的整體繼承結構如下:  

  

  ArrayDeque是繼承自Deque接口,Deque繼承自Queue接口,Queue是隊列,而Deque是雙端隊列,也就是可以從前或者從后插入或者取出元素,也就是比隊列存取更加方便一點,單向隊列只能從一頭插入,從另一頭取出。

  再來看看ArrayDeque的內部結構,其實從名字就可以看出來,ArrayDeque自然是基於Array的雙端隊列,內部結構自然是數組:

   //存儲元素的數組
   transient Object[] elements; // 非private訪問限制,以便內部類訪問

    /**
     * 頭部節點序號
     */
    transient int head;

    /**
     * 尾部節點序號,(指向最后一點節點的后一個位置)
     */
    transient int tail;

    /**
     * 雙端隊列的最小容量,必須是2的冪
     */
    private static final int MIN_INITIAL_CAPACITY = 8;

  這里可以看到,元素都存儲在Object數組中,head記錄首節點的序號,tail記錄尾節點后一個位置的序號,隊列的容量最小為8,而且必須為2的冪。看到這里,有沒有想到HashMap的元素個數限制也必須為2的冪,嗯,這里同HashMap一樣,自有妙用,后面會有分析。

三、ArrayDeque的常用方法

    從隊列首部插入/取出   從隊列尾部插入/取出
  失敗拋出異常 失敗返回特殊值 失敗拋出異常 失敗返回特殊值
插入 addFirst(e)    push() offerFirst(e) addLast(e) offerLast(e)
移除 removeFirst()  pop() pollFirst() removeLast() pollLast()
獲取 getFirst() peekFirst() getLast() peekLast()

   

   嗯,幾乎絕大多數常用方法都在這里了,基本上可以分成兩類,一類是以add,remove,get開頭的方法,這類方法失敗后會拋出異常,一類是以offer,poll,peek開頭的方法,這類方法失敗之后會返回特殊值,如null。大部分方法基本上都是可以根據命名來推斷其作用,如addFirst,當然就是從隊列頭部插入,removeLast,便是從隊列尾部移除,get和peek只獲取元素而不移除,getFirst方法調用時,如果隊列為空,會拋出NoSuchElementException異常,而peekFirst在隊列為空時調用則返回null。

  一下擺出這么多方法有些難以接受?別慌別慌,接下來讓我們從源碼的角度一起來看看這些方法,用圖說話,來解釋我們最開始那個栗子中到底發生了哪些事情。

四、ArrayDeque源碼分析

  先來看看構造函數:

    /**
     * 構造一個初始容量為16的空隊列
     */
    public ArrayDeque() {
        elements = new Object[16];
    }

    /**
     * 構造一個能容納指定大小的空隊列
     */
    public ArrayDeque(int numElements) {
        allocateElements(numElements);
    }

    /**
     * 構造一個包含指定集合所有元素的隊列
     */
    public ArrayDeque(Collection<? extends E> c) {
        allocateElements(c.size());
        addAll(c);
    }

  所以之前栗子中,

ArrayDeque<String> arrayDeque = new ArrayDeque<>(4);

  調用的是第二個構造函數,里面有這么一個函數allocateElements,讓我們來看看它的實現:

 1     private void allocateElements(int numElements) {
 2         elements = new Object[calculateSize(numElements)];
 3     }
 4 
 5     private static int calculateSize(int numElements) {
 6         int initialCapacity = MIN_INITIAL_CAPACITY;
 7         if (numElements >= initialCapacity) {
 8             initialCapacity = numElements;
 9             initialCapacity |= (initialCapacity >>>  1);
10             initialCapacity |= (initialCapacity >>>  2);
11             initialCapacity |= (initialCapacity >>>  4);
12             initialCapacity |= (initialCapacity >>>  8);
13             initialCapacity |= (initialCapacity >>> 16);
14             initialCapacity++;
15 
16             if (initialCapacity < 0) 
17                 initialCapacity >>>= 1;
18         }
19         return initialCapacity;
20     }

  allocateElements方法主要用於給內部的數組分配合適大小的空間,calculateSize方法用於計算比numElements大的最小2的冪次方,如果指定的容量大小小於MIN_INITIAL_CAPACITY(值為8),那么將容量設置為8,否則通過多次無符號右移進行最小2次冪計算。先將initialCapacity賦值為numElements,接下來,進行5次無符號右移,下面將以一個小栗子介紹這樣運算的妙處。

  在Java中,int類型是占4字節,也就是32位。簡單起見,這里以一個8位二進制數來演示前三次操作。假設這個二進制數對應的十進制為89,整個過程如下:

  可以看到最后,除了第一位,其他位全部變成了1,然后這個結果再加一,即得到大於89的最小的2次冪,怎么樣,很巧妙吧,也許你會想,為什么右移的數值要分別是1,2,4,8,16呢?嗯,好問題。其實仔細觀察就會發現,先右移在進行或操作,其實我們只需要關注第一個不為0的位即可,下面以64為例再演示一次:

  所以,事實上,在這系列操作中,其他位只是配角,我們只需要關注第一個不為0的位即可,假設其為第n位,先右移一位然后進行或操作,得到的結果,第n位和第n-1位肯定為1,這樣就有兩個位為1了,然后進行右移兩位,再進行或操作,那么第n位到第n-3位一定都為1,然后右移4位,依次類推。int為32位,因此,最后只需要移動16位即可。1+2+4+8+16 = 31,所以經過這一波操作,原數字對應的二進制,操作得到的結果將是從其第一個不為0的位開始,往后的位均為1。然后:

initialCapacity++;

  再自增一下,目標完成。觀察到還有下面這一小節代碼:

if (initialCapacity < 0) 
    initialCapacity >>>= 1;

  其實它是為了防止進行這一波操作之后,得到了負數,即原來第31位為1,得到的結果第32位將為1,第32位為符號位,1代表負數,這樣的話就必須回退一步,將得到的數右移一位(即2 ^ 30)。 嗯,那么這一部分就先告一段落了。

  來看看之前的那些函數。  

    public boolean add(E e) {
        addLast(e);
        return true;
    }

    /**
     * 在隊列頭部插入元素,如果元素為null,則拋出異常
     */
   public void addFirst(E e) {
        if (e == null)
            throw new NullPointerException();
        elements[head = (head - 1) & (elements.length - 1)] = e;
        if (head == tail)
            doubleCapacity();
    }

    /**
     * 在隊列尾部插入元素,如果元素為null,則拋出異常
     */
    public void addLast(E e) {
        if (e == null)
            throw new NullPointerException();
        elements[tail] = e;
        if ( (tail = (tail + 1) & (elements.length - 1)) == head)
            doubleCapacity();
    }

  add的幾個函數比較簡單,在頭部或者尾部插入元素,如果直接調用add方法,則是在尾部插入,這時直接在對應位置塞入該元素即可。

elements[tail] = e;

  然后把tail記錄其后一個位置,如果tail記錄的位置已經是數組的最后一個位置了怎么辦?嗯,這里又有一個巧妙的操作,跟HashMap中的取模是一樣的:

tail = (tail + 1) & (elements.length - 1)

  因為elements.length是2的冪次方,所以減一后就變成了掩碼,tail如果記錄的是最后一個位置,即 elements.length - 1,tail + 1 則等於elements.length,與 elements.length - 1 做與操作后,就變成了0,嗯,沒錯,這樣就變成了一個循環數組,如果tail與head相等,則表示沒有剩余空間可以存放更多元素了,則調用doubleCapacity進行擴容:

    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;
    }

  擴容其實也是很簡單粗暴的,先記錄一下原來head的位置,然后把容量變為原來的兩倍,然后把head之后的元素復制到新數組的開頭,把剩余的元素復制到新數組之后。以之前的栗子為例,新建的ArrayDeque實例容量為8,然后我們調用add插入元素,插入H之后,tail指向第一個位置,與head重合,就會觸發擴容。

        arrayDeque.add("A");
        arrayDeque.add("B");
        arrayDeque.add("C");
        arrayDeque.add("D");
        arrayDeque.add("E");
        arrayDeque.add("F");
        arrayDeque.add("G");
        arrayDeque.add("H");
        arrayDeque.add("I");

  看圖應該就比較清楚了,然后來看看獲取元素的幾個方法:

        // 獲取元素
        String a = arrayDeque.getFirst();
        String a1 = arrayDeque.pop();
        String b = arrayDeque.element();
        String b1 = arrayDeque.removeFirst();
        String c = arrayDeque.peek();
        String c1 = arrayDeque.poll();
        String d = arrayDeque.pollFirst();
        String i = arrayDeque.pollLast();
        String e = arrayDeque.peekFirst();
        String h = arrayDeque.peekLast();
        String h1 = arrayDeque.removeLast();
        System.out.printf("a = %s, a1 = %s, b = %s, b1 = %s, c = %s, c1 = %s, d = %s, i = %s, e = %s, h = %s, h1 = %s", a,a1,b,b1,c,c1,d,i,e,h,h1);
        System.out.println();

  getFirst方法直接取head位置的元素,如果為null則拋出異常。

    public E getFirst() {
        @SuppressWarnings("unchecked")
        E result = (E) elements[head];
        if (result == null)
            throw new NoSuchElementException();
        return result;
    }

  getLast也是類似,取出tail所在位置的前一個位置,這里也做了掩碼操作。

    public E getLast() {
        @SuppressWarnings("unchecked")
        E result = (E) elements[(tail - 1) & (elements.length - 1)];
        if (result == null)
            throw new NoSuchElementException();
        return result;
    }

  element方法直接調用的是getFirst方法:

    public E element() {
        return getFirst();
    }

  remove方法有三個:

    public E remove() {
        return removeFirst();
    }

    public E removeFirst() {
        E x = pollFirst();
        if (x == null)
            throw new NoSuchElementException();
        return x;
    }

    public E removeLast() {
        E x = pollLast();
        if (x == null)
            throw new NoSuchElementException();
        return x;
    }

  remove方法其實是調用的對應的poll方法,poll方法也有三個:

    public E poll() {
        return pollFirst();
    }

    public E pollFirst() {
        int h = head;
        @SuppressWarnings("unchecked")
        E result = (E) elements[h];
        // 如果結果為null則返回null
        if (result == null)
            return null;
        elements[h] = null;     // Must null out slot
        head = (h + 1) & (elements.length - 1);
        return result;
    }

    public E pollLast() {
        int t = (tail - 1) & (elements.length - 1);
        @SuppressWarnings("unchecked")
        E result = (E) elements[t];
        if (result == null)
            return null;
        elements[t] = null;
        tail = t;
        return result;
    }

  其實也很簡單,都是先取出對應的元素,如果為null則返回null,否則取出對應的元素並對head或tail進行調整。

  pop方法調用的是removeFirst方法,removeFIrst方法調用的是pollFirst方法,所以方法看起來這么多,實際上最后真正調用的就那么幾個。

    public E pop() {
        return removeFirst();
    }

  peek方法是取出元素但是不移除,也有三個方法:

    public E peek() {
        return peekFirst();
    }

    @SuppressWarnings("unchecked")
    public E peekFirst() {
        // elements[head] is null if deque empty
        return (E) elements[head];
    }

    @SuppressWarnings("unchecked")
    public E peekLast() {
        return (E) elements[(tail - 1) & (elements.length - 1)];
    }

  這里沒有做任何校驗,所以如果如果取到的元素是null,返回的也是null。

  再來看看插入元素的其它幾個方法:

    public boolean offer(E e) {
        return offerLast(e);
    }

    public boolean offerLast(E e) {
        addLast(e);
        return true;
    }

    public boolean offerFirst(E e) {
        addFirst(e);
        return true;
    }

    public void push(E e) {
        addFirst(e);
    }        

  offer方法直接調用的是add方法。

  emmm,都是相互調用,為啥要設置那么多方法呢?其實主要是為了模擬不同的數據結構,如棧操作:pop,push,peek,隊列操作:add,offer,remove,poll,peek,element,雙端隊列操作:addFirst,addLast,getFirst,getLast,peekFirst,peekLast,removeFirst,removeLast,pollFirst,pollLast。不過確實稍微多了一點。

  之前的栗子里還有用到兩個方法,removeFirstOccurrence和removeLastOccurrence,前者是移除首次出現的位置,后者是移除最后一次出現的位置。

    public boolean removeFirstOccurrence(Object o) {
        if (o == null)
            return false;
        int mask = elements.length - 1;
        int i = head;
        Object x;
        while ( (x = elements[i]) != null) {
            if (o.equals(x)) {
                delete(i);
                return true;
            }
            i = (i + 1) & mask;
        }
        return false;
    }

    public boolean removeLastOccurrence(Object o) {
        if (o == null)
            return false;
        int mask = elements.length - 1;
        int i = (tail - 1) & mask;
        Object x;
        while ( (x = elements[i]) != null) {
            if (o.equals(x)) {
                delete(i);
                return true;
            }
            i = (i - 1) & mask;
        }
        return false;
    }

  其實都是通過循環遍歷的方式進行查找一個是從head開始往后查找,一個是從tail開始往前查找。

  最后,我們再來看看它的迭代器類。

    public Iterator<E> iterator() {
        return new DeqIterator();
    }

    private class DeqIterator implements Iterator<E> {

        private int cursor = head;

        private int fence = tail;

        private int lastRet = -1;

        public boolean hasNext() {
            return cursor != fence;
        }

        public E next() {
            if (cursor == fence)
                throw new NoSuchElementException();
            @SuppressWarnings("unchecked")
            E result = (E) elements[cursor];
            if (tail != fence || result == null)
                throw new ConcurrentModificationException();
            lastRet = cursor;
            cursor = (cursor + 1) & (elements.length - 1);
            return result;
        }

        public void remove() {
            if (lastRet < 0)
                throw new IllegalStateException();
            if (delete(lastRet)) { 
                cursor = (cursor - 1) & (elements.length - 1);
                fence = tail;
            }
            lastRet = -1;
        }

        public void forEachRemaining(Consumer<? super E> action) {
            Objects.requireNonNull(action);
            Object[] a = elements;
            int m = a.length - 1, f = fence, i = cursor;
            cursor = f;
            while (i != f) {
                @SuppressWarnings("unchecked") E e = (E)a[i];
                i = (i + 1) & m;
                if (e == null)
                    throw new ConcurrentModificationException();
                action.accept(e);
            }
        }
    }

  在迭代器類中,cursor記錄的是head的位置,fence記錄的是tail的位置,lastRet記錄的是調用next返回的元素的序號,如果調用了remove方法,lastRet會置為-1,這里沒有像其它容器那樣使用modCount來實現fast-fail機制,而是通過在next方法中進行修改判斷。

        // 如果移除了尾部元素,會導致 tail != fence
        // 如果移除了頭部元素,會導致 result == null
        if (tail != fence || result == null)
            throw new ConcurrentModificationException();

  當然,這種檢測比較弱,如果先移除一個尾部元素,然后再添加一個尾部元素,那么tail依舊和fence相等,這種情況就檢測不出來了。

  在調用remove方法的時候,調用了一個delete方法,這是ArrayDeque類中的一個私有方法。

    private boolean delete(int i) {
        // 先做不變性檢測,判斷是否當前結構滿足刪除需求
        checkInvariants();
        final Object[] elements = this.elements;
        // mask即掩碼
        final int mask = elements.length - 1;
        final int h = head;
        final int t = tail;
        // front代表i到頭部的距離
        final int front = (i - h) & mask;
        // back代表i到尾部的距離
        final int back  = (t - i) & mask;

        // 再次校驗,如果i到頭部的距離大於等於尾部到頭部的距離,表示當前隊列已經被修改了,通過最開始檢測后,i是不應該滿足該條件的
        if (front >= ((t - h) & mask))
            throw new ConcurrentModificationException();

        // 為移動盡量少的元素做優化,如果離頭部比較近,則將該位置到頭部的元素進行移動,如果離尾部比較近,則將該位置到尾部的元素進行移動
        if (front < back) {
            if (h <= i) {
                System.arraycopy(elements, h, elements, h + 1, front);
            } else { // Wrap around
                System.arraycopy(elements, 0, elements, 1, i);
                elements[0] = elements[mask];
                System.arraycopy(elements, h, elements, h + 1, mask - h);
            }
            elements[h] = null;
            head = (h + 1) & mask;
            return false;
        } else {
            if (i < t) { // Copy the null tail as well
                System.arraycopy(elements, i + 1, elements, i, back);
                tail = t - 1;
            } else { // Wrap around
                System.arraycopy(elements, i + 1, elements, i, mask - i);
                elements[mask] = elements[0];
                System.arraycopy(elements, 1, elements, 0, t);
                tail = (t - 1) & mask;
            }
            return true;
        }
    }    

  所以這個delete還是花了一點心思的,不僅做了兩次校驗,還對元素移動進行了優化。嗯,到此為止,源碼部分就差不多了。

  那么現在再回到最開始提的問題。

  1、ArrayDeque是什么?ArrayDeque是一個用循環數組實現的雙端隊列。

  2、ArrayDeque如何使用?通過add,offer,poll等方法進行操作。

  3、ArrayDeque的內部結構是怎樣的?內部結構是一個循環數組。

  4、ArrayDeque的各個方法是如何實現的?嗯,見上文。

  5、ArrayDeque是如何擴容的?擴容成原來的兩倍,然后將原來的內容復制到新數組中。

  6、ArrayDeque的容量有什么限制?容量必須為2的冪次方,最小為8,默認為16.

  7、ArrayDeque和LinkedList相比有什么優勢?ArrayDeque通常來說比LinkedList更高效,因為可以在常量時間通過序號對元素進行定位,並且省去了對元素進行包裝的空間和時間成本。

  8、ArrayDeque的應用場景是什么?在很多場景下可以用來代替LinkedList,可以用做隊列或者棧。

  到此,本篇圓滿結束。如果覺得還不錯的話,記得動動小手點個贊,也歡迎關注博主,你們的支持是我寫出更好博客的動力。

  有興趣對Java進行更深入學習和交流的小伙伴,歡迎加入QQ群交流:529253292

 


免責聲明!

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



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