併發佇列ConcurrentLinkedQueue與LinkedBlockingQueue原始碼分析與對比

凝冰物語發表於2021-08-01

前言

之前在專案中使用到了併發佇列,場景為多寫多讀,查閱資料推薦使用ConcurretLinkedQueue,但不知道為什麼。這裡對併發佇列ConcurrentLinkedQueue與LinkedBlockingQueue的原始碼做一個簡單分析,比較一下兩者差別,並測試在不同併發請求下讀寫的效能差異。使用的JDK版本為1.8。

ConcurrentLinkedQueue

使用方法

使用方法很簡單,該類實現了Queue介面,提供了offer()、poll()等入隊和出隊的操作介面。
多執行緒環境下的使用如下:

// 無界併發佇列
ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();
// 模擬n個執行緒競爭環境
int n = 100;
CountDownLatch countDownLatch = new CountDownLatch(n);
for (int i = 0; i < n; i++) {
    int finalI = i;
    new Thread(()->{
        // 進行10000次的寫操作
        for (int j = 0; j < 10000; j++) {
            queue.add(j);
        }
        // 進行10000次的讀操作
        for (int j = 0; j < 10000; j++) {
            queue.poll();
        }
        // 該執行緒結束讀寫請求
        System.out.println("Thread-"+ finalI +"結束");
        countDownLatch.countDown();
    }).start();
}
// 直到所有執行緒結束讀寫
countDownLatch.await();
// 驗證併發佇列中元素是否清空
System.out.println("佇列已清空:"+queue.isEmpty());

輸出結果如下:

Thread-0結束
...........
Thread-55結束
佇列已清空:true

儲存結構

該類使用了Node類來表示佇列中的節點,包含一個volatile修飾的型別為傳入泛型的item成員(節點儲存的值)和volatile修飾的next指標。同時引入了Unsafe元件,使用了其CAS方法來替換item和next。其中lazySetNext()方法保證了volatile的語義,該次修改對下次讀是可見的。

private static class Node<E> {
    volatile E item;
    volatile Node<E> next;
    Node(E item) {
        UNSAFE.putObject(this, itemOffset, item);
    }
    // CAS替換節點的值,返回是否成功
    boolean casItem(E cmp, E val) {
        return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
    }
    // 給next引用賦值,這個方法保證了volatile的語義,即該修改對next讀取是可見的
    void lazySetNext(Node<E> val) {
        UNSAFE.putOrderedObject(this, nextOffset, val);
    }
    // CAS替換next引用,返回是否成功
    boolean casNext(Node<E> cmp, Node<E> val) {
        return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
    }
    // unsafe類引入和相關靜態程式碼
    ...
}

初始化

預設初始化方法如下:

public ConcurrentLinkedQueue() {
    // 建立空的頭尾節點
    head = tail = new Node<E>(null);
}

還有一個基於已有集合的初始化方法,大致流程為:依次取出集合元素;檢查是否為null;構建新節點;採用尾插法插入到連結串列尾部。

public ConcurrentLinkedQueue(Collection<? extends E> c) {
    Node<E> h = null, t = null;
    for (E e : c) {
        // 檢查元素是否為null
        checkNotNull(e);
        // 基於集合中的元素構建新節點
        Node<E> newNode = new Node<E>(e);
        // 第一個元素設定為頭尾結點
        if (h == null)          
            h = t = newNode;
        else {     // 其餘元素採用尾插法插入      
            t.lazySetNext(newNode);
            t = newNode;
        }
    }
    // 集合為空集合時,新建值為nul的頭尾節點
    if (h == null)
        h = t = new Node<E>(null);
    head = h;
    tail = t;
}

入隊

public boolean offer(E e) {
    // 確保元素非null,為null時丟擲NullPointer異常
    checkNotNull(e);
    // 基於傳入值構造新節點
    final Node<E> newNode = new Node<E>(e);

    // 自旋,直到入隊成功
    for (Node<E> t = tail, p = t;;) {
        Node<E> q = p.next;
        // case1:此時p為隊尾節點,q=null
        if (q == null) {
            // 通過cas的方式設定新節點為p的後繼節點
            // 如果失敗,說明此時p已不再是隊尾結點,繼續進行自旋
            // 如果成功,嘗試修改tail後返回true
            if (p.casNext(null, newNode)) {

                // p != t代表此時p和第一次迴圈時相比已經向後移動了,此時就通過CAS的方式將tail節點修改為newNode
                // 失敗了也沒關係,代表有其他執行緒已經修改了tail
                if (p != t) // hop two nodes at a time
                    casTail(t, newNode);
                return true;
            }
        }
        // case2:p=q,表示是刪除的節點
        else if (p == q)

            // t != (t = tail) 說明t!=tail,tail節點已經更新過,此時就使用tail賦值給p,然後繼續自旋
            // 否則說明tail沒有更新過,指向出隊的節點。這時就使用head賦值給p,然後繼續自旋
            p = (t != (t = tail)) ? t : head;
        // case3:p不是隊尾節點,也沒有出隊。就更新p,然後繼續自旋
        else
            // case3.1:p!=t且t!=tail時,說明tail節點更新過,讓p重新指向tail節點
            // case3.2:否則,p往後移動一位,指向q
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

入隊的邏輯看起來比較複雜,其核心思想就是自旋+cas的方式將新節點插入到隊尾節點的後面。
這裡就按第一次入隊和第二次入隊兩種情況分析一下:

  • 第一次入隊

首先檢查非空,然後構造新節點。
t和p都指向tail節點,q為null。此時進入case1:嘗試CAS設定p.next為newNode。
成功的話,說明節點入隊成功了。然後直接返回true
失敗的話,說明p.next!=null,p不是隊尾節點了,這時就自旋,q=p.next,然後會進入case3.2的邏輯,更新p。再次自旋,q=p.next,然後會進入case1的邏輯,然後重複上面一樣的操作,直到CAS設定成功。

  • 第二次入隊

首先檢查非空,然後構造新節點。
tail節點指向倒數第二個節點,t和p指向tail,q指向最後一個節點。此時進入case3:,執行case3.2的邏輯,p = q。
然後自旋後,q=p.next,進入case1,然後CAS設定p.next為newNode。成功了的話,會發現p!=t,執行重置tail節點的操作,該操作失敗了說明有其他執行緒重置了,所以也ok。之後返回true。

出隊

// 將原head(h指向head節點)更新為p
// 並將原head節點next指向自己,表示當前節點已經出隊
final void updateHead(Node<E> h, Node<E> p) {
    if (h != p && casHead(h, p))    // 將head通過CAS的方式更新為p
        h.lazySetNext(h);   // 將h節點的next指向自己,表示出隊
}

public E poll() {
    restartFromHead:
    // 大迴圈
    for (;;) {
        // 自旋
        for (Node<E> h = head, p = h, q;;) {
            E item = p.item;

            // case1:p指向節點為第一個有元素節點(實質上要出隊的節點)
            // cas的方式設定item,失敗了的話說明有其他執行緒將該接節點出隊了,會再次自旋
            if (item != null && p.casItem(item, null)) {
                // Successful CAS is the linearization point
                // for item to be removed from this queue.
                // p!=h,表示p已經向後移動了。此時
                if (p != h) // hop two nodes at a time
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
            // case2:如果p的後繼節點為null,表示p已經是最後一個節點,無節點可出隊了
            else if ((q = p.next) == null) {
                // 更新頭節點為p,然後返回null
                updateHead(h, p);
                return null;
            }
            // case3:p=q,表示p和q指向的節點已經出隊,通過p和q已無法找到頭節點,這時需要重新去獲取head節點
            else if (p == q)
                // 回到大迴圈中重新開始小迴圈自旋
                continue restartFromHead;
            // case4:將p指向q,實質上是q往後移動一位
            else
                p = q;
        }
    }
}

出隊的核心思想就是找到頭節點,CAS將其item設定為null。如果成功的話,就可以出隊了,如果失敗了,就自旋再次尋找頭結點。
這裡也分析一下出隊執行步驟:

  • 出隊

最開始的時候,head節點的item應該是null(queue初始化方法建立的節點)。第一次迴圈,h和p指向head節點。
如果此時佇列中沒有元素,會進入case2,直接更新head節點後返回null。
如果佇列中有元素,會進入case4,將q向後移動,然後再次自旋,進行case1的判斷。如果case1中item!=null且cas設定成功,則表示出隊成功,返回出隊元素。如果cas設定失敗,則繼續自旋尋找頭結點出隊。直至出隊成功,同時如果p!=h,會更新下頭結點。在自旋的過程中,如果當前節點已經被出隊了,會進入case3,然後回到大迴圈重新尋找head節點。

獲取容器元素數量

// 返回p的後繼節點,如果p已經出隊(next指向自身),則返回head節點
final Node<E> succ(Node<E> p) {
    Node<E> next = p.next;
    // 當一個節點從佇列刪除後,其next指標會指向自己。此時就返回head節點
    return (p == next) ? head : next;
}

// 獲取隊首節點
Node<E> first() {
    restartFromHead:
    for (;;) {
        for (Node<E> h = head, p = h, q;;) {
            boolean hasItem = (p.item != null);
            // p節點有元素,或者p節點為最後一個節點
            if (hasItem || (q = p.next) == null) {
                // 更新頭結點
                updateHead(h, p);
                // p節點有元素返回p,無元素代表p是最後一個節點,返回null
                return hasItem ? p : null;
            }
            // 如果p已經出隊,重新回到大迴圈
            else if (p == q)
                continue restartFromHead;
            // p向後移動一位
            else
                p = q;
        }
    }
}

public int size() {
    int count = 0;
    // 獲取首元素後,遍歷後繼節點的數量
    for (Node<E> p = first(); p != null; p = succ(p))
        if (p.item != null)
            // Collection.size() spec says to max out
            if (++count == Integer.MAX_VALUE)
                break;
    return count;
}

可以看到計算的大小不是非常準確的,從獲取到首節點開始後,一直遍歷到尾結點。期間增加的節點都能被統計進入,出隊的節點則不計入數量。所以計算的數量>=計算完成時刻的實際數量。

LinkedBlockingQueue

使用方法

LinkedBlockingQueue實現了Queue介面,也提供了offer和poll等方法。同時也提供了put和帶時間引數的offer和pool方法。簡單示例如下:

// 無界併發佇列
LinkedBlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
// 插入一個元素,容量滿時會失敗
queue.offer(1);
// 插入一個元素,容量滿時最多等待2s
queue.offer(2, 2, TimeUnit.SECONDS);
// 插入一個元素,容量滿時會一直等待,直到能夠入隊
queue.put(3);
// 取出一個元素,無元素時返回null
queue.poll();
// 取出一個元素,無元素時最多等待2s
queue.poll(2, TimeUnit.SECONDS);

儲存結構

使用了Node節點儲存元素,不過沒有UNSAFE元件,沒有CAS操作。後面也可以看到,使用了可重入鎖(獨佔鎖),所以不需要考慮多執行緒同時修改屬性的情況。

static class Node<E> {
    E item;

    Node<E> next;

    Node(E x) { item = x; }
}

使用了head和last表示佇列的頭部和尾部節點,使用了入隊鎖和出隊鎖兩個鎖來實現同一時刻只有一個元素入隊,同一時刻只有一個元素出隊。使用了AotomicInteger類來表示佇列中的元素個數。

transient Node<E> head;

private transient Node<E> last;

private final int capacity;

private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

初始化

預設初始化方法,設定容量為Integer.MAX_VALUE

public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

還有一個基於已有集合的初始化方法,大致思路為:

1.加上putLock入隊鎖;
2.遍歷集合的所有元素,然後依次新增到佇列中。
3.解鎖。

入隊

由於使用了ReentrantLock,同一時刻只有單個執行緒入隊,所以不用考慮併發問題。新增一個節點,然後將該節點新增到last節點後,最後更新last節點即可。
offer方法原始碼解析如下:需要注意,當入隊時容量達到最大容量,會入隊失敗。

public boolean offer(E e) {
    if (e == null) throw new NullPointerException();
    final AtomicInteger count = this.count;
    // 當前容量已滿時,直接返回false
    if (count.get() == capacity)
        return false;
    int c = -1;
    // 構建新節點
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    // 入隊鎖加鎖,已經被其它執行緒加鎖時,當前執行緒會park掛起
    putLock.lock();
    try {
        // 只有當前元素個數<capacity,才能入隊
        if (count.get() < capacity) {
            // 執行入隊操作
            enqueue(node);
            // count數量+1
            c = count.getAndIncrement();
            // 如果當前元素個數<capacity,表示還可以繼續入隊
            if (c + 1 < capacity)
                // 喚醒一個在notFull的條件等待佇列中的執行緒
                notFull.signal();
        }
    } finally {
        // 入隊鎖解鎖
        putLock.unlock();
    }
    // 如果此時元素數量為1,表示可以出隊
    if (c == 0)
        // 喚醒一個在notEmpty的條件等待佇列中的執行緒
        signalNotEmpty();
    // c>=表示入隊成功,返回true,反之入隊失敗,返回false
    return c >= 0;
}

// 節點入隊,加到隊尾節點,然後更新last
private void enqueue(Node<E> node) {
    // assert putLock.isHeldByCurrentThread();
    // assert last.next == null;
    last = last.next = node;
}

put方法相對於offer方法,多了一個等待邏輯,當元素數量達到最大容量時,會一直等待,直到能夠入隊。

putLock.lockInterruptibly();
try {
    // 多了一個等待的過程
    // 如果容量已滿,當前執行緒park並進入notFull的條件等待佇列
    while (count.get() == capacity) {
        notFull.await();
    }
    enqueue(node);
    c = count.getAndIncrement();
    if (c + 1 < capacity)
        notFull.signal();
} finally {
    putLock.unlock();
}

出隊

同一時刻只有單個執行緒出隊,所以不用考慮併發問題。
offer方法原始碼解析如下:需要注意,當入隊時容量達到最大容量,會入隊失敗。

public E poll() {
    final AtomicInteger count = this.count;
    if (count.get() == 0)
        return null;
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    // 出隊鎖加鎖
    takeLock.lock();
    try {
        // 只有數量>0時才能出隊
        if (count.get() > 0) {
            // 執行出隊操作
            x = dequeue();
            // 容器數量-1
            c = count.getAndDecrement();
            // 當容器數量>=1時,喚醒notEmpty條件佇列中等待的一個執行緒
            if (c > 1)
                notEmpty.signal();
        }
    } finally {
        // 出隊鎖釋放
        takeLock.unlock();
    }
    // 表示當前數量<capacity時,容器未滿,喚醒notFull條件佇列中等待的一個執行緒
    if (c == capacity)
        signalNotFull();
    return x;
}
// 節點出隊操作
 private E dequeue() {
    // 獲取隊首節點以及下一個節點(隊首節點值都是null,下一個節點才是真正有元素的節點)
    Node<E> h = head;
    Node<E> first = h.next;
    // h節點next指向自身,表示出隊
    h.next = h;
    // 更新head節點
    head = first;
    // 返回第一個實際節點的值並重置為null(head節點的item都是null)
    E x = first.item;
    first.item = null;
    return x;
}

take方法相比於poll,多了一個等待邏輯,當元素數量=0時,會一直等待,直到能夠入隊。

takeLock.lockInterruptibly();
    try {
        // 多了一個等待的過程
        // 如果數量=0,當前執行緒park並進入notEmpty的條件等待佇列
        while (count.get() == 0) {
            notEmpty.await();
        }
        x = dequeue();
        c = count.getAndDecrement();
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }

獲取容器元素數量

直接獲取原子變數capacity的值即可。由於入隊和出隊對數量大小的修改都是原子的,所以獲取的數量大小是十分準確的,為當前時刻容器元素數量。

public int size() {
    return count.get();
}

ConcurrentLinkedQueue與LinkedBlockingQueue比較

簡單比較

通過之前的介紹,可以發現

  1. ConcurrentLinkedQueue是一個無界佇列,最大長度為Integer.MAX_VALUE;LinkedBlockingQueue是一個有界佇列(不設定長度時為Integer.MAX_VALUE),在達到最大容量後新增元素有可能會失敗(使用offer方法入隊會失敗,put方法入隊會一直等待)。
  2. ConcurrentLinkedQueue全程是沒有執行緒阻塞的,通過自旋+CAS的方式入隊和出隊(不達目的不罷休);而LinkedBlockingQueue同一時刻只能有一個執行緒執行入隊操作或出隊操作,通過入隊鎖和出隊鎖實現(ReentrantLock+Condition)。

效能比較測試

ConcurrentLinkedQueue全程是無鎖的,而LinkedBlockingQueue多執行緒出入隊時會有掛起和喚醒執行緒的操作,會進行執行緒的上下文切換,相對來說更耗時。
這裡設定了幾組不同的執行緒數量和併發讀取次數,來測試各自的完成時間,每組資料測試5次,取平均資料。使用了同一臺機器(4核CPU)進行測試。
程式碼設計如下:

// 無界併發佇列
ConcurrentLinkedQueue<Integer> queue = new ConcurrentLinkedQueue<>();

long startTime = System.currentTimeMillis();
// 模擬n個執行緒競爭環境,各自完成m次插入和查詢操作,計算最終完成時間
int n = 10;
// 讀寫次數
int m = 10000;
// 執行緒執行完成的計數器
CountDownLatch countDownLatch = new CountDownLatch(n);
// 控制所有執行緒同時執行
CyclicBarrier cyclicBarrier = new CyclicBarrier(n);
for (int i = 0; i < n; i++) {
    int finalI = i;
    new Thread(()->{
        // 等待訊號量的改變
        try {
            cyclicBarrier.await();
        } catch (InterruptedException | BrokenBarrierException e) {
            e.printStackTrace();
        }
        // 進行100000次的寫操作
        for (int j = 0; j < m; j++) {
            queue.add(j);
        }
        // 進行1000000次的讀操作
        for (int j = 0; j < m; j++) {
            queue.poll();
        }
        // 該執行緒結束讀寫請求
        System.out.println("Thread-"+ finalI +"結束");
        countDownLatch.countDown();
    }).start();
}

// 直到所有執行緒結束讀寫,計算時間
countDownLatch.await();
long endTime = System.currentTimeMillis();
long costTime = endTime - startTime;
System.out.println("所用時間:" + costTime + "ms");
// 驗證併發佇列中元素是否清空
System.out.println("佇列已清空:"+queue.isEmpty());

該次執行結果:
Thread-9結束
...
Thread-8結束
所用時間:78ms
佇列已清空:true

最終測試得到結果:
LinkedBlockingQueue測試結果(ms):

執行緒數量\讀取次數 10000 50000 100000
10 94 125 187
50 167 800 3109
100 266 1332 6168
200 503 5374 11365

ConcurrentLinkedQueue測試結果(ms):

執行緒數量\讀取次數 10000 50000 100000
10 78 156 249
50 172 594 1375
100 250 828 3343
200 437 1656 6300

可以發現,線上程數量較少時,兩者的消耗時長差不多。當執行緒數量比較多,並且短時間內的讀寫請求數量較大時,ConcurrentLinkedQueue消耗時間明顯更少。

參考連結

https://zhuanlan.zhihu.com/p/224964810

相關文章