Java併發系列之中級篇之併發包:併發場景下的集合框架

目錄

內容提要

一、ConcurrentHashMap

二、CopyOnWriteArrayList

三、ConcurrentLinkedQueue

1、重要內部類

2、重要成員變量

3、入隊方法:offer(E e)和add(E e)

4、出隊方法:poll(),peek()和element()

四、BlockingQueue

1、概述

2、ArrayBlockingQueue的主要成員屬性

3、入隊:put(E e)方法:

4、出隊:take()方法:

五、ConcurrentSkipListMap

1、數據結構簡介:跳錶

2、跳錶和其他結構的對比


內容提要

    java.util.concurrent包中提供了一些處理高併發場景的容器,在併發場景下使用這些容器,比使用“普通容器+鎖”的搭配性能高得多。

    相關的容器包括:

ConcurrentHashMap:高效併發的HashMap。

CopyOnWriteArrayList:適用於讀多寫少場景的的ArrayList。

ConcurrentLinkedQueue:高效的併發隊列,基於鏈表來實現。

BlockingQueue:接口,JDK提供了包括鏈表、數組在內的多種實現方式。

ConcurrentSkipListMap:使用跳錶結構進行快速查詢的Map。

一、ConcurrentHashMap

   總的來說,ConcurrentHashMap使用了“數組+鏈表+紅黑樹”的數據結構。

1、它的基礎數據結構,或者說最外層數據結構是數組,鏈表和紅黑樹都是存放在數組中的;

2、數組中存放的是Node的實例,Node是ConcurrentHashMap定義的內部類,包括key、value、hash、next等成員屬性。顧名思義,key和value就是要存儲的鍵值對;hash一般來說是key的hash值,特殊情況參考第二章第三小節transfer方法源碼後面的說明;next執行下一個Node實例;

3、數組中存放的Node實例,對於鏈表,是鏈表的頭節點;對於紅黑樹,則是特定封裝的一個特殊節點TreeBin,節點中不存儲鍵值對,只有next和hash兩個成員屬性有意義,hash值爲固定值-2,next則指向存儲數據的紅黑樹節點;

4、一個數組的成員中存放的是鏈表還是紅黑樹,取決於該位置存儲的節點的數量。在JDK1.8的情況下,如果當鏈表中的節點數量大於等於8,則將鏈表樹化成紅黑樹的結構;如果紅黑樹中的元素小於等於6的時候,則將紅黑樹去樹化成鏈表結構。

5、對於一個key值,ConcurrentHashMap找其在數組中對應位置的方法,並不是遍歷數組,而是計算(key的hash)和(數組長度-1)進行邏輯與運算的結果,該結果就是在數組中的位置索引。

6、ConcurrentHashMap擴容的時機:

  • 鏈表中節點數增加到8個及以上的時候,觸發樹化操作,此時會首先判斷數組長度是否小於64,滿足條件則擴容;
  • 存入新的鍵值對後,會調用addCount方法對應增加記錄的元素個數。當元素個數達到閾值(數組當前長度的0.75倍)會觸發擴容

源碼分析請參考:ConcurrentHashMap源碼分析

二、CopyOnWriteArrayList

    CopyOnWriteArrayList的讀與讀之間、讀與寫之間並不會加鎖,只有寫操作和寫操作之間才需要同步。所以CopyOnWriteArrayList適用於讀操作遠遠多於寫操作的高併發場景。

    CopyOnWriteArrayList的讀操作和普通的ArrayList並沒有太大區別。CopyOnWriteArrayList的寫操作,是將原本的數據複製一份,並對複製得到的副本執行寫操作,然後副本代替原本的數據。在寫操作執行期間,如果有其他線程通過讀操作訪問數據,依然是從原本的數據中進行讀取。

    讀操作的源碼:

public E get(int index) {
    return get(getArray(), index);
}

private E get(Object[] a, int index) {
    return (E) a[index];
}

private transient volatile Object[] array;

final Object[] getArray() {
    return array;
}

    可以看到,和普通的ArrayList相比,CopyOnWriteArrayList的讀操作需要先確定用來執行讀取操作的目標數組。這是由於寫操作會導致CopyOnWriteArrayList中的數組指向另外一個數組實例。

    寫操作的源碼,以add(E e)方法爲例,其他的add方法或者remove方法保證讀寫操作之間數據安全的思想是一致的:

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

    首先,寫操作需要加鎖,寫操作和寫操作之間不能同時執行;然後,由於要增加一個元素,所以通過Arrays.copyOf(elements, len + 1)複製得到一個長度比原數組大1的新數組,並把新加入的元素放到最後位置;最後,將成員屬性array的引用指向新數組,完成數組的替換。

三、ConcurrentLinkedQueue

    ConcurrentLinkedQueue是用於高併發場景下的高性能的隊列,是通過非阻塞方式實現線程安全的。

1、重要內部類

    Node是非常重要的內部類,作用類似於ConcurrentHashMap中的Node,每一份數據都是通過包裝成一個Node的實例,然後再存儲到ConcurrentLinkedQueue中的。

    Node的實例包含兩個成員變量,分別是數據域的item,即這個節點中存放的數據;以及指針域的next,指向隊列的下一個元素所在的節點。

    Node對成員變量的修改是通過CAS方法實現的。

    源碼:

    private static class Node<E> {
        volatile E item;
        volatile Node<E> next;

        /**
         * Constructs a new node.  Uses relaxed write because item can
         * only be seen after publication via casNext.
         */
        Node(E item) {
            UNSAFE.putObject(this, itemOffset, item);
        }

        boolean casItem(E cmp, E val) {
            return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
        }

        void lazySetNext(Node<E> val) {
            UNSAFE.putOrderedObject(this, nextOffset, val);
        }

        boolean casNext(Node<E> cmp, Node<E> val) {
            return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
        }

        // Unsafe mechanics

        private static final sun.misc.Unsafe UNSAFE;
        private static final long itemOffset;
        private static final long nextOffset;

        static {
            try {
                UNSAFE = sun.misc.Unsafe.getUnsafe();
                Class<?> k = Node.class;
                itemOffset = UNSAFE.objectFieldOffset
                    (k.getDeclaredField("item"));
                nextOffset = UNSAFE.objectFieldOffset
                    (k.getDeclaredField("next"));
            } catch (Exception e) {
                throw new Error(e);
            }
        }
    }

2、重要成員變量

    重要的成員變量有兩個:

    /**
     * A node from which the first live (non-deleted) node (if any)
     * can be reached in O(1) time.
     * Invariants:
     * - all live nodes are reachable from head via succ()
     * - head != null
     * - (tmp = head).next != tmp || tmp != head
     * Non-invariants:
     * - head.item may or may not be null.
     * - it is permitted for tail to lag behind head, that is, for tail
     *   to not be reachable from head!
     */
    private transient volatile Node<E> head;

    /**
     * A node from which the last node on list (that is, the unique
     * node with node.next == null) can be reached in O(1) time.
     * Invariants:
     * - the last node is always reachable from tail via succ()
     * - tail != null
     * Non-invariants:
     * - tail.item may or may not be null.
     * - it is permitted for tail to lag behind head, that is, for tail
     *   to not be reachable from head!
     * - tail.next may or may not be self-pointing to tail.
     */
    private transient volatile Node<E> tail;

    其中,head表示隊列的頭節點,tail指向隊列的尾節點。需要注意的是,tail是尾節點,但並不一定是最後一個節點。

3、入隊方法:offer(E e)和add(E e)

    元素放入隊列的方法,還包括add(E e)方法,但是add方法的實質是offer方法,是通過調用offer方法來實現的。add方法源碼:

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

    offer方法的源碼:

    /**
     * Inserts the specified element at the tail of this queue.
     * As the queue is unbounded, this method will never return {@code false}.
     *
     * @return {@code true} (as specified by {@link Queue#offer})
     * @throws NullPointerException if the specified element is null
     */
    public boolean offer(E e) {
        // 如果e == null,會拋出NullPointerException, 所以這裏要驗空
        checkNotNull(e);
        final Node<E> newNode = new Node<E>(e);

        // for循環是CAS的常用搭配,一直執行直到CAS執行成功
        for (Node<E> t = tail, p = t;;) {
            // tail不一定是最後一個元素,因爲tail的更新可能有延遲。tail的更新參考註釋
            Node<E> q = p.next;
            if (q == null) {// 判斷p是不是最後一個元素。
                if (p.casNext(null, newNode)) { // CAS把newNode放到最後,必須成功
                    // t指向tail,p指向最後一個元素,p != t表示tail沒有指向最後一個元素
                    // 即每插入兩個元素更新一次tail,tail執行最後一個元素或者倒數第二個元素
                    if (p != t) // hop two nodes at a time
                        // 把tail指向成newNode,這裏失敗也沒關係。
                        // 假如這裏也保證必須成功,那麼萬一失敗就會出現異常的情況:
                        // 下一輪還會執行“p.casNext(null, newNode)”,新元素可能插入多次
                        casTail(t, newNode);  // Failure is OK.
                    return true;
                }
                // 上面的if失敗,表示CAS競爭失敗,沒有成功更新newNode,繼續循環
            }
            else if (p == q) // 判斷p是不是哨兵節點
                // 由於q = p.next,所以p = q表示p是哨兵節點(next指向自己的節點)。哨兵節點參考poll方法
                // 當遇到哨兵節點,無法通過next獲取後續節點,此時需要從head開始遍歷,去獲取真正的tail
                // 下面的複雜的式子,是爲p賦值的操作,p的值是右邊三目運算符的結果。
                // t != (t = tail)表示,先給t賦值爲tail,然後判斷t != t。
                // 判斷在多線程情況下才可能出現,因爲可能在先後兩次分別取出左右兩邊的t的期間,其他線程修改了t
                p = (t != (t = tail)) ? t : head;
            else
                // 根據上面的註釋,t != (t = tail)表示t被其他線程修改了
                // 三門運算符的第一個表達式表示,如果p不是tail並且tail被其他線程修改了,就讓p指向最新的tail;
                // 否則,就讓p指向p.next
                p = (p != t && t != (t = tail)) ? t : q;
        }
    }

註釋:tail的更新

    tail的更新並不總是及時的。實際上,tail每次更新會跳躍兩個元素:

4、出隊方法:poll(),peek()和element()

    出隊同樣有三個方法:poll(),peek()和element(),三者都會取出head指向的元素。區別如下:

  • poll():取出head指向的元素,並從隊列中移除該元素。移除指的是:head = head.next;
  • peek():取出head指向的元素,不執行移除操作;
  • element():取出head指向的元素,不執行移除操作;同時,如果隊列爲空,則拋出異常NoSuchElementException。

    上述三個方法中,poll()方法最複雜,因爲刪除操作涉及到的併發場景更復雜一些。

    現在再來看poll()方法的源碼:

    public E poll() {
        restartFromHead:
        for (;;) {
            for (Node<E> h = head, p = h, q;;) {
                E item = p.item;
                // 從前往後遍歷,取第一個item不爲空的節點中的item
                // 根據poll方法的特點,取出第一個item後還需要CAS將這個item置null
                if (item != null && p.casItem(item, null)) {
                    // Successful CAS is the linearization point
                    // for item to be removed from this queue.
                    if (p != h) // p != head的時候需要更新head
                        // 如果p.next == null,就把head設置爲p;否則設置爲p.next
                        // head設置爲p.next可以在隊列中還有元素時,更快找到非空的item
                        updateHead(h, ((q = p.next) != null) ? q : p);
                    return item;
                }
                else if ((q = p.next) == null) {
                    // 還沒訪問到item不爲空的節點,就沒有下一個節點了,說明隊列爲空
                    // 根據前面offer方法源碼可知,空隊列的head:item=null,next=null
                    // 此時的p正好符合上述條件,所以把head更新爲p
                    updateHead(h, p);
                    return null;
                }
                else if (p == q) 
                    // 哨兵節點,節點自身的next指向節點自身,此時需要重新執行
                    continue restartFromHead;
                else 
                    // head.next.item是隊列的第一個元素,這裏讓p=p.next
                    // 非空隊列進入循環的第一輪,有可能進入這個判斷的分支中。參考註釋
                    p = q;
            }
        }
    }

註釋1:關於head節點中item是否爲空的問題

  • 從offer的方法源碼中可以看出,如果一個隊列一直只執行offer()方法,那麼head始終是item == null的;
  • 當長度大於1的隊列執行poll方法後,根據上述源碼中的updateHead(h, ((q = p.next) != null) ? q : p);可知,此時head.item是隊列的第一個元素的值,即此時的head就是存儲第一個元素的節點;
  • 如果隊列長度恰好爲1,即對列只有一個元素,那麼執行poll後隊列爲空,head就是空節點(item和next都是null)

註釋2:updateHead方法和哨兵節點:

    updateHead方法源碼如下:

    /**
     * Tries to CAS head to p. If successful, repoint old head to itself
     * as sentinel for succ(), below.
     */
    final void updateHead(Node<E> h, Node<E> p) {
        if (h != p && casHead(h, p))
            // 此處設置原本的head的next指向自身,即設置原本的head爲哨兵節點
            h.lazySetNext(h);
    }

    根據updateHead方法的源碼,我們總算弄清楚哨兵節點的來源了:head被更新後,舊的head節點就會被設置成哨兵節點。所以,無論在offer方法中還是在poll方法中,只要碰到哨兵節點,就說明訪問到了一個被廢棄的head節點,此時是無法繼續執行遍歷操作的,遍歷操作需要重新開始。

四、BlockingQueue

1、概述

    BlockingQueue本身是作爲“線程間的數據共享通道”而被設計的。我們希望兩個或多個線程間可以進行通信,但是又希望線程間是解耦的,以降低維護成本。而BlockingQueue就可以用來在線程間傳遞數據。

    BlockingQueue是一個接口,它的主要實現包括:

    這裏主要通過ArrayBlockingQueue來了解BlockingQueue的基本特點。

2、ArrayBlockingQueue的主要成員屬性

存儲相關的屬性:

    /** The queued items */
    final Object[] items;

    /** items index for next take, poll, peek or remove */
    int takeIndex;

    /** items index for next put, offer, or add */
    int putIndex;

    /** Number of elements in the queue */
    int count;
  1. 數組items用來存儲需要放入隊列中的元素;
  2. takeIndex索引是用於出隊列操作的索引,當需要彈出或者移除隊列中的元素時,操作的元素即takeIndex指示的元素;
  3. putIndex索引是用於入隊列操作的索引,當需要向隊列中添加元素時,被添加的元素就會放到putIndex指示的位置;
  4. count:隊列中剩餘的元素數量

阻塞相關的屬性:

    /** Main lock guarding all access */
    final ReentrantLock lock;

    /** Condition for waiting takes */
    private final Condition notEmpty;

    /** Condition for waiting puts */
    private final Condition notFull;

    使用非常經典的雙condition算法來實現阻塞和通知。兩個Condition實例,notEmpty和notFull,都是基於lock創建的。基於Lock的Condition實例可以通過await()方法使當前線程休眠,直到收到同一個Condition實例通過single()方法發出的信號,纔會結束休眠。通過這種方式,可以實現線程間的通信。

3、入隊:put(E e)方法:

    顧名思義,ArrayBlockingQueue是基於數組實現的。數組的容量需要在創建的時候進行指定,而數組的動態擴展只能通過創建更大的新數組來替換原有數組來實現,所以相比於基於鏈表實現的LinkedBlockingQueue,ArrayBlockingQueue更適合做有界隊列。

    BlockingQueue將元素放入隊列的方法有三種:add(),offer(),和put()。三個方法的特點:

  • add:如果當前隊列滿了,會拋出IllegalStateException異常。對於容量有限的隊列,更推薦使用offer方法;
  • offer:如果當前隊列滿了,會立即返回false,並不會等待,也不會拋出異常;
  • put:如果當前隊列滿了,會一直等待,知道隊列中有空閒的位置。

    從上面的介紹可以看出,put方法纔是BlockingQueue的靈魂所在,才能真正體現“Blocking”的意義。這裏分析put方法的源碼:

    public void put(E e) throws InterruptedException {
        // 不允許放入null元素
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        // 一旦被中斷,就會釋放鎖
        lock.lockInterruptibly();
        try {
            // 當隊列已滿的情況下,保證線程進行等待
            while (count == items.length)
                notFull.await();
            // 執行入隊操作
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }

    入隊列函數enqueue:

    /**
     * Inserts element at current put position, advances, and signals.
     * Call only when holding lock.
     */
    private void enqueue(E x) {
        final Object[] items = this.items;
        // 新元素放入putIndex指示的位置
        items[putIndex] = x;
        // 更新putIndex
        if (++putIndex == items.length)
            putIndex = 0;
        // 更新count
        count++;
        // 通知隊列已經不空了,如果有線程在take等待狀態,就會收到信號
        notEmpty.signal();
    }

4、出隊:take()方法:

    出隊列相關的方法,包括take(), poll(), peek(), remove()。這四個方法的特點:

  • take():如果隊列爲空,則一直等待,直到隊列中有可用的元素;
  • poll():彈出隊首的元素,如果隊列爲空,則直接返回null;
  • peek():和poll的區別就在於,peek取出隊首的元素後不會從隊列中移除這個元素;
  • remove():和poll的區別就是,如果隊列爲空,就拋出異常。

    可以看出,take()方法才能體現“Blocking”的思想。這裏看take()的源碼:

    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        // 一旦被中斷,就會釋放鎖
        lock.lockInterruptibly();
        try {
            // 當隊列爲空的情況下,保證線程進行等待
            while (count == 0)
                notEmpty.await();
            // 執行出隊操作
            return dequeue();
        } finally {
            lock.unlock();
        }
    }

    出隊函數dequeue:

    /**
     * Extracts element at current take position, advances, and signals.
     * Call only when holding lock.
     */
    private E dequeue() {
        final Object[] items = this.items;
        @SuppressWarnings("unchecked")
        // 獲取takeIndex指示的元素
        E x = (E) items[takeIndex];
        // 取出元素後隊列中該元素置空
        items[takeIndex] = null;
        // 更新takeIndex
        if (++takeIndex == items.length)
            takeIndex = 0;
        // 更新count
        count--;
        if (itrs != null)
            itrs.elementDequeued();
        // 通知隊列已經不滿了,如果有線程在put等待狀態,就會收到信號
        notFull.signal();
        return x;
    }

五、ConcurrentSkipListMap

1、數據結構簡介:跳錶

1、結構:

    跳錶是一種用來快速查找的數據結構。跳錶的本質是維護了多個鏈表,並且鏈表是分層的。最底層的鏈表維護了跳錶內的全部元素,每上面一層鏈表都是下面一層鏈表的子集,一個元素插入哪些層完全是隨機的。示例圖:

2、查找:

    跳錶內的所有鏈表的元素都是排序的。查找時可以先從頂層鏈表開始查找,一旦找到目標值,就會返回相應結果;或者如果找到的目標值所在的區間,就轉入下一次鏈表繼續查找。不斷重複上述操作,直到找到目標元素。查找元素7的示意圖:

2、跳錶和其他結構的對比

    使用跳錶來實現Map和使用hash實現Map的主要優點在於,哈希並不會保存元素的順序,而跳錶內的元素都是排序的。和普通的鏈表相比,跳錶的時間複雜度是O(logn),而普通鏈表的時間複雜度是O(n),跳錶的查詢速率明顯高於普通鏈表。

    不過也有缺點。一方面,跳錶查找的時間複雜度是O(logn),而使用哈希的話時間複雜度是一個常量,所以跳錶的時間複雜度明顯低於哈希;另一方面,跳錶比普通鏈表逐個迭代的時間複雜度低,其主要原因是它的多層鏈表結構,但是多層鏈表也會帶來空間上更多的消耗。實際上鍊表是一種空間換時間的算法。

    這裏需要記住的是,如果你的業務對有序性的要求是第一位的,那麼推薦使用跳錶。跳錶的核心競爭力就是有序性

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