【細談Java併發】談談LinkedBlockingQueue

https://blog.csdn.net/tonywu1992/article/details/83419448

最近在看concurrent包的知識,看到LinkedBlockingQueue,發現一篇好文推薦給大家。
原文地址:【細談Java併發】談談LinkedBlockingQueue

1、簡介

上篇我們介紹了ArrayBlockingQueue的相關方法的原理,這一篇我們來學習一下ArrayBlockingQueue的“親戚” LinkedBlockingQueue。在集合框架裏,想必大家都用過ArrayList和LinkedList,也經常在面試中問到他們之間的區別。ArrayList和ArrayBlockingQueue一樣,內部基於數組來存放元素,而LinkedBlockingQueue則和LinkedList一樣,內部基於鏈表來存放元素。

LinkedBlockingQueue實現了BlockingQueue接口,這裏放一張類的繼承關係圖(圖片來自之前的文章:說說隊列Queue
在這裏插入圖片描述
LinkedBlockingQueue不同於ArrayBlockingQueue,它如果不指定容量,默認爲Integer.MAX_VALUE,也就是無界隊列。所以爲了避免隊列過大造成機器負載或者內存爆滿的情況出現,我們在使用的時候建議手動傳一個隊列的大小。

2、源碼分析

2.1、屬性

/**
 * 節點類,用於存儲數據
 */
static class Node<E> {
    E item;
    Node<E> next;

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

/** 阻塞隊列的大小,默認爲Integer.MAX_VALUE */
private final int capacity;

/** 當前阻塞隊列中的元素個數 */
private final AtomicInteger count = new AtomicInteger();

/**
 * 阻塞隊列的頭結點
 */
transient Node<E> head;

/**
 * 阻塞隊列的尾節點
 */
private transient Node<E> last;

/** 獲取並移除元素時使用的鎖,如take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** notEmpty條件對象,當隊列沒有數據時用於掛起執行刪除的線程 */
private final Condition notEmpty = takeLock.newCondition();

/** 添加元素時使用的鎖如 put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** notFull條件對象,當隊列數據已滿時用於掛起執行添加的線程 */
private final Condition notFull = putLock.newCondition();

從上面的屬性我們知道,每個添加到LinkedBlockingQueue隊列中的數據都將被封裝成Node節點,添加的鏈表隊列中,其中head和last分別指向隊列的頭結點和尾結點。與ArrayBlockingQueue不同的是,LinkedBlockingQueue內部分別使用了takeLock 和 putLock 對併發進行控制,也就是說,添加和刪除操作並不是互斥操作,可以同時進行,這樣也就可以大大提高吞吐量。

這裏如果不指定隊列的容量大小,也就是使用默認的Integer.MAX_VALUE,如果存在添加速度大於刪除速度時候,有可能會內存溢出,這點在使用前希望慎重考慮。

另外,LinkedBlockingQueue對每一個lock鎖都提供了一個Condition用來掛起和喚醒其他線程。

2.2、構造函數

public LinkedBlockingQueue() {
    // 默認大小爲Integer.MAX_VALUE
    this(Integer.MAX_VALUE);
}

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

public LinkedBlockingQueue(Collection<? extends E> c) {
    this(Integer.MAX_VALUE);
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        int n = 0;
        for (E e : c) {
            if (e == null)
                throw new NullPointerException();
            if (n == capacity)
                throw new IllegalStateException("Queue full");
            enqueue(new Node<E>(e));
            ++n;
        }
        count.set(n);
    } finally {
        putLock.unlock();
    }
}

默認的構造函數和最後一個構造函數創建的隊列大小都爲Integer.MAX_VALUE,只有第二個構造函數用戶可以指定隊列的大小。第二個構造函數最後初始化了last和head節點,讓它們都指向了一個元素爲null的節點。
在這裏插入圖片描述
最後一個構造函數使用了putLock來進行加鎖,但是這裏並不是爲了多線程的競爭而加鎖,只是爲了放入的元素能立即對其他線程可見。

2.3、方法

同樣,LinkedBlockingQueue也有着和ArrayBlockingQueue一樣的方法,我們先來看看入隊列的方法。

2.3.1、入隊方法

LinkedBlockingQueue提供了多種入隊操作的實現來滿足不同情況下的需求,入隊操作有如下幾種:

  • void put(E e);
  • boolean offer(E e);
  • boolean offer(E e, long timeout, TimeUnit unit)。

put(E e)

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    // 獲取鎖中斷
    putLock.lockInterruptibly();
    try {
        //判斷隊列是否已滿,如果已滿阻塞等待
        while (count.get() == capacity) {
            notFull.await();
        }
        // 把node放入隊列中
        enqueue(node);
        c = count.getAndIncrement();
        // 再次判斷隊列是否有可用空間,如果有喚醒下一個線程進行添加操作
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    // 如果隊列中有一條數據,喚醒消費線程進行消費
    if (c == 0)
        signalNotEmpty();
}

小結put方法來看,它總共做了以下情況的考慮:

  • 隊列已滿,阻塞等待。
  • 隊列未滿,創建一個node節點放入隊列中,如果放完以後隊列還有剩餘空間,繼續喚醒下一個添加線程進行添加。如果放之前隊列中沒有元素,放完以後要喚醒消費線程進行消費。

很清晰明瞭是不是?

我們來看看該方法中用到的幾個其他方法,先來看看enqueue(Node node)方法:

private void enqueue(Node<E> node) {
    last = last.next = node;
}

該方法可能有些同學看不太懂,我們用一張圖來看看往隊列裏依次放入元素A和元素B,畢竟無圖無真相:
在這裏插入圖片描述
接下來我們看看signalNotEmpty,順帶着看signalNotFull方法。

private void signalNotEmpty() {
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
}

private void signalNotFull() {
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        notFull.signal();
    } finally {
        putLock.unlock();
    }
}

爲什麼要這麼寫?因爲signal的時候要獲取到該signal對應的Condition對象的鎖才行。

offer(E e)

public boolean offer(E e) {
    if (e == null) throw new NullPointerException();
    final AtomicInteger count = this.count;
    if (count.get() == capacity)
        return false;
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        // 隊列有可用空間,放入node節點,判斷放入元素後是否還有可用空間,
        // 如果有,喚醒下一個添加線程進行添加操作。
        if (count.get() < capacity) {
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        }
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
    return c >= 0;
}

可以看到offer僅僅對put方法改動了一點點,當隊列沒有可用元素的時候,不同於put方法的阻塞等待,offer方法直接方法false。

offer(E e, long timeout, TimeUnit unit)

public boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException {

    if (e == null) throw new NullPointerException();
    long nanos = unit.toNanos(timeout);
    int c = -1;
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly();
    try {
        // 等待超時時間nanos,超時時間到了返回false
        while (count.get() == capacity) {
            if (nanos <= 0)
                return false;
            nanos = notFull.awaitNanos(nanos);
        }
        enqueue(new Node<E>(e));
        c = count.getAndIncrement();
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
    return true;
}

該方法只是對offer方法進行了阻塞超時處理,使用了Condition的awaitNanos來進行超時等待,這裏爲什麼要用while循環?因爲awaitNanos方法是可中斷的,爲了防止在等待過程中線程被中斷,這裏使用while循環進行等待過程中中斷的處理,繼續等待剩下需等待的時間。

2.3.2、出隊方法

入隊列的方法說完後,我們來說說出隊列的方法。LinkedBlockingQueue提供了多種出隊操作的實現來滿足不同情況下的需求,如下:

  • E take();
  • E poll();
  • E poll(long timeout, TimeUnit unit);

take()

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
        // 隊列爲空,阻塞等待
        while (count.get() == 0) {
            notEmpty.await();
        }
        x = dequeue();
        c = count.getAndDecrement();
        // 隊列中還有元素,喚醒下一個消費線程進行消費
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    // 移除元素之前隊列是滿的,喚醒生產線程進行添加元素
    if (c == capacity)
        signalNotFull();
    return x;
}

take方法看起來就是put方法的逆向操作,它總共做了以下情況的考慮:

  • 隊列爲空,阻塞等待。
  • 隊列不爲空,從隊首獲取並移除一個元素,如果消費後還有元素在隊列中,繼續喚醒下一個消費線程進行元素移除。如果放之前隊列是滿元素的情況,移除完後要喚醒生產線程進行添加元素。

我們來看看dequeue方法

private E dequeue() {
    // 獲取到head節點
    Node<E> h = head;
    // 獲取到head節點指向的下一個節點
    Node<E> first = h.next;
    // head節點原來指向的節點的next指向自己,等待下次gc回收
    h.next = h; // help GC
    // head節點指向新的節點
    head = first;
    // 獲取到新的head節點的item值
    E x = first.item;
    // 新head節點的item值設置爲null
    first.item = null;
    return x;
}

可能有些童鞋鏈表算法不是很熟悉,我們可以結合註釋和圖來看就清晰很多了。
在這裏插入圖片描述
其實這個寫法看起來很繞,我們其實也可以這麼寫:

private E dequeue() {
    // 獲取到head節點
    Node<E> h = head;
    // 獲取到head節點指向的下一個節點,也就是節點A
    Node<E> first = h.next;
    // 獲取到下下個節點,也就是節點B
    Node<E> next = first.next;
    // head的next指向下下個節點,也就是圖中的B節點
    h.next = next;
    // 得到節點A的值
    E x = first.item;
    first.item = null; // help GC
    first.next = first; // help GC
    return x;
}

poll()

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 {
        if (count.get() > 0) {
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        }
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        signalNotFull();
    return x;
}

poll方法去除了take方法中元素爲空後阻塞等待這一步驟,這裏也就不詳細說了。同理,poll(long timeout, TimeUnit unit)也和offer(E e, long timeout, TimeUnit unit)一樣,利用了Condition的awaitNanos方法來進行阻塞等待直至超時。這裏就不列出來說了。

2.3.3、獲取元素方法

public E peek() {
    if (count.get() == 0)
        return null;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        Node<E> first = head.next;
        if (first == null)
            return null;
        else
            return first.item;
    } finally {
        takeLock.unlock();
    }
}

加鎖後,獲取到head節點的next節點,如果爲空返回null,如果不爲空,返回next節點的item值。

2.3.4、刪除元素方法

public boolean remove(Object o) {
    if (o == null) return false;
    // 兩個lock全部上鎖
    fullyLock();
    try {
        // 從head開始遍歷元素,直到最後一個元素
        for (Node<E> trail = head, p = trail.next;
             p != null;
             trail = p, p = p.next) {
            // 如果找到相等的元素,調用unlink方法刪除元素
            if (o.equals(p.item)) {
                unlink(p, trail);
                return true;
            }
        }
        return false;
    } finally {
        // 兩個lock全部解鎖
        fullyUnlock();
    }
}

void fullyLock() {
    putLock.lock();
    takeLock.lock();
}

void fullyUnlock() {
    takeLock.unlock();
    putLock.unlock();
}

因爲remove方法使用兩個鎖全部上鎖,所以其他操作都需要等待它完成,而該方法需要從head節點遍歷到尾節點,所以時間複雜度爲O(n)。我們來看看unlink方法。

void unlink(Node<E> p, Node<E> trail) {
    // p的元素置爲null
    p.item = null;
    // p的前一個節點的next指向p的next,也就是把p從鏈表中去除了
    trail.next = p.next;
    // 如果last指向p,刪除p後讓last指向trail
    if (last == p)
        last = trail;
    // 如果刪除之前元素是滿的,刪除之後就有空間了,喚醒生產線程放入元素
    if (count.getAndDecrement() == capacity)
        notFull.signal();
}
  • 3、問題

看源碼的時候,我給自己拋出了一個問題。

  • 爲什麼dequeue裏的h.next不指向null,而指向h?
  • 爲什麼unlink裏沒有p.next = null或者p.next = p這樣的操作?
    這個疑問一直困擾着我,直到我看了迭代器的部分源碼後才豁然開朗,下面放出部分迭代器的源碼:
private Node<E> current;
private Node<E> lastRet;
private E currentElement;

Itr() {
    fullyLock();
    try {
        current = head.next;
        if (current != null)
            currentElement = current.item;
    } finally {
        fullyUnlock();
    }
}

private Node<E> nextNode(Node<E> p) {
    for (;;) {
        // 解決了問題1
        Node<E> s = p.next;
        if (s == p)
            return head.next;
        if (s == null || s.item != null)
            return s;
        p = s;
    }
}

迭代器的遍歷分爲兩步,第一步加雙鎖把元素放入臨時變量中,第二部遍歷臨時變量的元素。也就是說remove可能和迭代元素同時進行,很有可能remove的時候,有線程在進行迭代操作,而如果unlink中改變了p的next,很有可能在迭代的時候會造成錯誤,造成不一致問題。這個解決了問題2。

而問題1其實在nextNode方法中也能找到,爲了正確遍歷,nextNode使用了 s == p的判斷,當下一個元素是自己本身時,返回head的下一個節點。

4、總結

LinkedBlockingQueue是一個阻塞隊列,內部由兩個ReentrantLock來實現出入隊列的線程安全,由各自的Condition對象的await和signal來實現等待和喚醒功能。它和ArrayBlockingQueue的不同點在於:

  • 隊列大小有所不同,ArrayBlockingQueue是有界的初始化必須指定大小,而LinkedBlockingQueue可以是有界的也可以是無界的(Integer.MAX_VALUE),對於後者而言,當添加速度大於移除速度時,在無界的情況下,可能會造成內存溢出等問題。
  • 數據存儲容器不同,ArrayBlockingQueue採用的是數組作爲數據存儲容器,而LinkedBlockingQueue採用的則是以Node節點作爲連接對象的鏈表。
  • 由於ArrayBlockingQueue採用的是數組的存儲容器,因此在插入或刪除元素時不會產生或銷燬任何額外的對象實例,而LinkedBlockingQueue則會生成一個額外的Node對象。這可能在長時間內需要高效併發地處理大批量數據的時,對於GC可能存在較大影響。
  • 兩者的實現隊列添加或移除的鎖不一樣,ArrayBlockingQueue實現的隊列中的鎖是沒有分離的,即添加操作和移除操作採用的同一個ReenterLock鎖,而LinkedBlockingQueue實現的隊列中的鎖是分離的,其添加採用的是putLock,移除採用的則是takeLock,這樣能大大提高隊列的吞吐量,也意味着在高併發的情況下生產者和消費者可以並行地操作隊列中的數據,以此來提高整個隊列的併發性能。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章