Java集合之LinkedBlockingQueue源碼分析

問題

(1)LinkedBlockingQueue的實現方式?

(2)LinkedBlockingQueue是有界的還是無界的隊列?

(3)LinkedBlockingQueue相比ArrayBlockingQueue有什麼改進?

(4)LinkedBlockingQueue 內部是如何使用兩個獨佔鎖 ReentrantLock 以及對應的條件變量保證多線程先入隊出隊操作的線程安全?爲什麼不使用一把鎖,使用兩把爲何能提高併發度?

簡介

LinkedBlockingQueue是java併發包下一個以單鏈表實現的可選容量的阻塞隊列(容量限制是可選的,如果在初始化時沒有指定容量,那麼默認使用int的最大值作爲隊列容量),它是線程安全的,裏面的代碼寫的很漂亮,生產者消費者模式在這個類中用的酣暢淋漓,其作者是大名鼎鼎的 Doug Lea,掌握這個類是比較重要的。裏面很多實現基於鎖,可以好好學習一下。

首先看一下LinkedBlockingQueue 的類圖結構,如下圖所示:

LinkedBlockingQueue有別於一般的隊列,在於該隊列至少有一個節點,頭節點不含有元素。結構圖如下:

可以發現head.item=null,last.next=null。

原理

如類圖所示:LinkedBlockingQueue是使用單向鏈表實現,有兩個Node分別來存放首尾節點,並且裏面有個初始值爲0 的原子變量count,它用來記錄隊列元素個數。LinkedBlockingQueue中維持兩把鎖,一把鎖用於入隊,一把鎖用於出隊,這也就意味着,同一時刻,只能有一個線程執行入隊,其餘執行入隊的線程將會被阻塞;同時,可以有另一個線程執行出隊,其餘執行出隊的線程將會被阻塞。換句話說,雖然入隊和出隊兩個操作同時均只能有一個線程操作,但是可以一個入隊線程和一個出隊線程共同執行,也就意味着可能同時有兩個線程在操作隊列,說白了,這其實就是一個生產者 -  消費者模型。那麼爲了維持線程安全,LinkedBlockingQueue使用一個AtomicInterger類型的變量表示當前隊列中含有的元素個數,所以可以確保兩個線程之間操作底層隊列是線程安全的。另外notEmpty 和 notFull 是信號量,內部分別有一個條件隊列用來存放進隊和出隊的時候被阻塞的線程。

使用場景

我查了一些資料,感覺和 MQ 有點聯繫,就是說我們可以使用這個東西進行解耦,或者負載均衡,比如說,有很多任務需要提交,我們可以把任務提交給 Queue,消費者負責處理消息,這個可以根據消費者的能力決定任務的執行效率,不會一下字任務過來而導致崩潰,講道理,可以適合多生產者,多消費者模式,如果有這個,我們可以很好的進行解耦,負載均衡

源碼分析

主要屬性

 /** The capacity bound, or Integer.MAX_VALUE if none */
    private final int capacity;

    /** Current number of elements */
    private final AtomicInteger count = new AtomicInteger();

    /**
     * Head of linked list.
     * 不變性條件: head.item == null
     */
    transient Node<E> head;

    /**
     * Tail of linked list.
     * 不變性條件: last.next == null
     */
    private transient Node<E> last;

    /** 進隊鎖Lock held by take, poll, etc */
    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();

    /**

(1)capacity,有容量,可以理解爲LinkedBlockingQueue是有界隊列

(2)head, last,鏈表頭、鏈表尾指針(只有head節點爲公有,其餘全部爲private

(3)takeLock,notEmpty,take鎖及其對應的條件

(4)putLock, notFull,put鎖及其對應的條件

(5)入隊、出隊使用兩個不同的鎖控制,鎖分離,提高效率

內部類

/**
     * Linked list node class.
     */
    static class Node<E> {
        E item;

        /**
         * One of:
         * - the real successor Node
         * - this Node, meaning the successor is head.next
         * - null, meaning there is no successor (this is the last node)
         */
        Node<E> next;

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

典型的單鏈表結構。

主要構造方法

/**
     * Creates a {@code LinkedBlockingQueue} with a capacity of
     * {@link Integer#MAX_VALUE}.如果沒傳容量,就使用最大int值初始化其容量
     */
    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

    /**
     * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
     *
     * @param capacity the capacity of this queue
     * @throws IllegalArgumentException if {@code capacity} is not greater
     *         than zero
     */
    public LinkedBlockingQueue(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException();
        this.capacity = capacity;
        // 初始化head和last指針爲空值節點
        last = head = new Node<E>(null);  //last和head在隊列爲空時都存在,所以隊列中至少有一個節點
    }

    /**
     * Creates a {@code LinkedBlockingQueue} with a capacity of
     * {@link Integer#MAX_VALUE}, initially containing the elements of the
     * given collection,
     * added in traversal order of the collection's iterator.
     *
     * @param c the collection of elements to initially contain
     * @throws NullPointerException if the specified collection or any
     *         of its elements are null
     */
    public LinkedBlockingQueue(Collection<? extends E> c) {
        this(Integer.MAX_VALUE);
        final ReentrantLock putLock = this.putLock;
        putLock.lock(); // Never contended, but necessary for visibility
        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是原子類AtomicInteger,所以有set()方法
            count.set(n);
        } finally {
            putLock.unlock();
        }
    }

從上面的構造方法中可以得出3點結論:
1. 當調用無參的構造方法時,容量是int的最大值
2. 隊列中至少包含一個節點,哪怕隊列對外表現爲空
3. LinkedBlockingQueue不支持null元素

入隊

入隊同樣有四個方法,我們這裏依次分析一下:

1.offer操作,向隊列尾部插入一個元素,如果隊列有空閒容量則插入成功後返回true,如果隊列已滿則丟棄當前元素然後返回false,如果 e元素爲null,則拋出空指針異常(NullPointerException ),還有一點就是,該方法是非阻塞的。源碼如下:

//如果可以在不超過隊列容量的情況下立即插入指定的元素到隊列的尾部,成功後返回{@code true},
//如果隊列已滿,返回{@code false}。當使用容量受限的隊列時,此方法通常比方法{@link BlockingQueue#add add}更可取,後者只能通過拋出異常才能插入元素
public boolean offer(E e) {
        if (e == null) throw new NullPointerException();
        final AtomicInteger count = this.count;
        if (count.get() == capacity)
            return false;
        final int c;
        final Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        putLock.lock();
        try {
            //連續兩次判斷,是爲了防止在第一次判斷與獲取ReentrantLock鎖之間有節點添加進來
            if (count.get() == capacity)
                return false;
            enqueue(node);
            //原子操作
            c = count.getAndIncrement();  //getAndIncrement()爲原子遞增當前值,並返回遞增前的舊值
            //與ArrayBlockingQueue不一樣,這裏notFull()喚醒入隊線程是在offer()中,而不是在poll()中。
            //並且ArrayBlockingQueue中的notFull()是在dequeue中,
            //究其原因應該在兩者使用的參數不一樣,所以把他們放在不同的方法中
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
         //c == 0說明offer()操作之前爲空,操作之後有節點進入,通知出隊線程隊列非空
        if (c == 0)    
            signalNotEmpty();     //類似於ArrayBlockingQueue的offer(),會提醒阻塞的出隊線程
        return true;
 }


/**
     * Signals a waiting take. Called only from put/offer (which do not
     * otherwise ordinarily lock takeLock.)
     */
    private void signalNotEmpty() {
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
    }


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

        if (e == null) throw new NullPointerException();
        long nanos = unit.toNanos(timeout);
        final int c;
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            while (count.get() == capacity) {
                if (nanos <= 0L)
                    return false;
                nanos = notFull.awaitNanos(nanos);
            }
            //與其他的進隊不一樣,只有在進隊的時候纔將e包裝成node節點,
            //而put()與offer()都是在獲取鎖之前就進行過包裝
            enqueue(new Node<E>(e));      
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
        return true;
    }

注意:與ArrayBlockingQueue不一樣,這裏notFull()喚醒入隊線程是在offer()中,而不是在poll()中,因爲這裏是兩把鎖。offer()與offer(E e, long timeout, TimeUnit unit)(與put()類似)最大的不同是在try-finally中第一個判斷,offer()中是if條件判斷,offer(E e, long timeout, TimeUnit unit)中是while()條件判斷,因爲offer()是非阻塞的,不滿足條件立即返回,而offer(E e, long timeout, TimeUnit unit)是指定時間內阻塞的,所以要在規定時間內要一直循環直到時間耗盡或者線程被喚醒

這裏有個疑問,爲什麼LinkedBlockingQueue中的offer()與poll()比ArrayBlockingQueue中的各多了一次count 判斷?可能是LinkedBlockingQueue中的count是原子類,執行get()方法是原子操作,而ArrayBlockingQueue中count不是原子類型,操作會受其他線程影響

 2.put操作,向隊列尾部插入一個元素,如果隊列有空閒則插入後直接返回true,如果隊列已經滿則阻塞當前線程知道隊列有空閒插入成功後返回true,如果在阻塞的時候被其他線程設置了中斷標誌,則被阻塞線程會拋出InterruptedException 異常而返回,另外如果 e 元素爲 null 則拋出 NullPointerException 異常。源碼如下:

public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        final int c;
        final Node<E> node = new Node<E>(e);
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            /*
             * Note that count is used in wait guard even though it is
             * not protected by lock. This works because count can
             * only decrease at this point (all other puts are shut
             * out by lock), and we (or some other waiting put) are
             * signalled if it ever changes from capacity. Similarly
             * for all other uses of count in other wait guards.
             * 注意count在wait guard中使用,即使它沒有被鎖保護。這是因爲count只能在此時減少(所有其他put都被鎖定關閉),如果它因容量更改,我們(或其他一些等待put)將收到信號。類似地,count在其他等待守衛中的所有其他用途也是如此。
             */
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            c = count.getAndIncrement();
            // 如果現隊列長度如果小於容量
            // 就再喚醒一個阻塞在notFull條件上的線程
            // 這裏爲啥要喚醒一下呢?
            // 因爲可能有很多線程阻塞在notFull這個條件上的
            // 而取元素時只有取之前隊列是滿的纔會喚醒notFull
            // 爲什麼隊列滿的才喚醒notFull呢?
            // 因爲喚醒是需要加putLock的,這是爲了減少鎖的次數
            // 所以,這裏索性在放完元素就檢測一下,未滿就喚醒其它notFull上的線程
            // 說白了,這也是鎖分離帶來的代價
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }
/**
     * Links node at end of queue.
     *
     * @param node the node
     */
    private void enqueue(Node<E> node) {
        // assert putLock.isHeldByCurrentThread();
        // assert last.next == null;
        last = last.next = node;
    }

(1)使用putLock加鎖;

(2)如果隊列滿了就阻塞在notFull條件上;

(3)否則就入隊;

(4)如果入隊後元素數量小於容量,喚醒其它阻塞在notFull條件上的線程;

(5)釋放鎖;

(6)如果放元素之前隊列長度爲0,就喚醒notEmpty條件;

出隊

出隊同樣也有四個方法,我們依次分析一下:

1.poll操作,從隊列頭部獲取並移除一個元素,如果隊列爲空則返回 null,該方法是不阻塞的。源碼如下:

public E poll() {
        final AtomicInteger count = this.count;
        if (count.get() == 0)
            return null;
        final E x;
        final int c;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            if (count.get() == 0)
                return null;
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        return x;
}

/**
     * Removes a node from head of queue.出隊列操作,因爲隊列的head節點爲null節點,在出隊列時,會始終保持head節點爲空,next節點爲真正意義上的首節點
     *
     * @return the node
     */
    private E dequeue() {
        // assert takeLock.isHeldByCurrentThread();
        // assert head.item == null;
        //要分清對象引用與真正在堆中的對象的區別,引用和其被引用的對象共用一組屬性
        Node<E> h = head;     //這裏爲什麼要引入h節點?感覺沒必要
        Node<E> first = h.next;
        h.next = h; // help GC
        head = first;
        E x = first.item;
        first.item = null;
        return x;
    }

  poll 代碼邏輯比較簡單,值得注意的是獲取元素時候只操作了隊列的頭節點。

 2.peek 操作,獲取隊列頭部元素但是不從隊列裏面移除,如果隊列爲空則返回 null,該方法是不阻塞的。源碼如下:

public E peek() {
        final AtomicInteger count = this.count;
        if (count.get() == 0)
            return null;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lock();
        try {
            return (count.get() > 0) ? head.next.item : null;   //寫的很精煉,直接通過count是否大於0來判斷而不是通過head.next == null?來判斷
        } finally {
            takeLock.unlock();
        }
}

3.take 操作,獲取當前隊列頭部元素並從隊列裏面移除,如果隊列爲空則阻塞當前線程直到隊列不爲空,然後返回元素,如果在阻塞的時候被其他線程設置了中斷標誌,則被阻塞線程會拋出InterruptedException 異常而返回。源碼如下:

public E take() throws InterruptedException {
        final E x;
        final int c;
        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;
}

當隊列爲空時,就加入到notEmpty(的條件等待隊列中,當隊列不爲空時就取走一個元素,當取完發現還有元素可取時,再通知一下自己的夥伴(等待在條件隊列中的線程);最後,如果隊列從滿到非滿,通知一下put線程。

4.remove操作,刪除隊列裏面指定元素,有則刪除返回 true,沒有則返回 false之前的入隊和出隊都是隻獲取一個鎖,而remove()方法需要同時獲得兩把鎖,源碼如下:


    public boolean remove(Object o) {
        if (o == null) return false;
        fullyLock();
        try {
            for (Node<E> pred = head, p = pred.next;
                 p != null;
                 pred = p, p = p.next) {
                if (o.equals(p.item)) {
                    unlink(p, pred);
                    return true;
                }
            }
            return false;
        } finally {
            fullyUnlock();
        }
    }

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

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

    /**
     * 將p與它的前繼節點pred斷開連接,目的是刪除p.
     */
    void unlink(Node<E> p, Node<E> pred) {
        // assert putLock.isHeldByCurrentThread();
        // assert takeLock.isHeldByCurrentThread();
        // p.next is not changed, to allow iterators that are
        // traversing p to maintain their weak-consistency guarantee.p.next沒有改變,允許遍歷p的迭代器維護它們的弱一致性保證。
        p.item = null;
        pred.next = p.next;    //先將pred.next賦值爲p.next
        if (last == p)
            last = pred;        //last指針也要處理
        if (count.getAndDecrement() == capacity)     //還要喚醒添加線程,這裏爲什麼不喚醒出隊線程。有待思考?
            notFull.signal();
    }

那麼問題來了,爲什麼remove()方法同時需要兩把鎖?
remove()操作會從隊列的頭遍歷到尾,用到了隊列的兩端,所以需要對兩端加鎖,而對兩端加鎖就需要獲取兩把鎖;入隊和出隊均只在隊列的一端操作,所以只需獲取一把鎖。

size()方法

size()方法用於返回隊列中元素的個數,其實現如下: 

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

由於count是一個AtomicInteger的變量,所以該方法是一個原子性的操作,是線程安全的。

最後用一張圖來加深LinkedBlockingQueue的理解,如下圖:

因此我們要思考一個問題:爲何 ConcurrentLinkedQueue 中需要遍歷鏈表來獲取 size 而不使用一個原子變量呢?

這是因爲使用原子變量保存隊列元素個數需要保證入隊出隊操作和操作原子變量是原子操作,而ConcurrentLinkedQueue 是使用 CAS 無鎖算法的,所以無法做到這個。

總結

在上面分析LinkedBlockingQueue的源碼之後,可以與ArrayBlockingQueue做一個比較。
相同點有如下2點:
1. 不允許元素爲null
2. 線程安全的隊列

不同點有如下幾點:
1. ArrayBlockingQueue底層基於定長的數組,所以容量限制了;LinkedBlockingQueue底層基於鏈表實現隊列,所以容量可選,如果不設置,那麼容量是int的最大值
2. ArrayBlockingQueue內部維持一把鎖和兩個條件,同一時刻只能有一個線程隊列的一端操作,導致入隊出隊相互阻塞,效率低下;LinkedBlockingQueue內部維持兩把鎖和兩個條件,同一時刻可以有兩個線程在隊列的兩端操作,但同一時刻只能有一個線程在一端操作,效率較高。
3. LinkedBlockingQueue的remove()類似方法時,由於需要對整個隊列鏈表實現遍歷,所以需要獲取兩把鎖,對兩端加鎖。

4.LinkedBlockingQueue在puts操作都會生成新的Node對象,takes操作Node對象在某一時間會被GC,可能會影響GC性能;ArrayBlockingQueue是固定的數組長度循環使用, 不會出現對象的產生與回收

 

爲了讓大家更加明白 ReentrantLock,我這裏給出一個例子供大家學習

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author Create by xuantang
 * @date on 8/22/18
 */
public class ReentrantLockDemo {
    private static ReentrantLock mLock = new ReentrantLock();
    private static Condition mCondition = mLock.newCondition();

    public static void main(String[] args) {
        new WaitThread("waiter one").start();
        new WaitThread("waiter two").start();
        new WaitThread("waiter three").start();
        new NotifyThread("notify one").start();
    }

    static class WaitThread extends Thread {
        WaitThread(String name) {
            super(name);
        }

        @Override
        public void run() {
            try {
                mLock.lockInterruptibly();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            try {
                System.out.println(this.getName() + " Waiting......");
                mCondition.await();
                System.out.println(this.getName() + " Finished.....");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                mLock.unlock();
            }
        }
    }

    static class NotifyThread extends Thread {

        NotifyThread(String name) {
            super(name);
        }

        @Override
        public void run() {
            try {
                mLock.lockInterruptibly();

                mCondition.signal();
                System.out.println(this.getName() + " Notify.....");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                mLock.unlock();
            }
        }
    }
}

輸入結果,只能喚醒一個,當然你可以使用 signalAll() 喚醒所有的

waiter one Waiting......
waiter two Waiting......
waiter three Waiting......
notify one Notify.....
waiter one Finished.....

參考:

Java併發編程筆記之LinkedBlockingQueue源碼探究

https://www.cnblogs.com/huangjuncong/p/9218194.html

入隊出隊總結

ConcurrentLinkedQueue 與LinkedBlockingQueue比較

ReentrantLock使用實例

死磕 java集合之LinkedBlockingQueue源碼分析

 

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