併發程式設計之 LinkedBolckingQueue 原始碼剖析

莫那·魯道發表於2019-01-24

併發程式設計之 LinkedBolckingQueue 原始碼剖析

前言

JDK 1.5 之後,Doug Lea 大神為我們寫了很多的工具,整個 concurrent 包基本都是他寫的。也為我們程式設計師寫好了很多工具,包括我們之前說的執行緒池,重入鎖,執行緒協作工具,ConcurrentHashMap 等等,今天我們要講的是和 ConcurrentHashMap 類似的資料結構,LinkedBolckingQueue,阻塞佇列。在生產者消費者模型中,該類可以幫助我們快速的實現業務功能。

  1. 如何使用?
  2. 原始碼分析

1. 如何使用?

我們在生產者消費者模型,生產者向一個資料共享通道存放資料,消費者從相同的資料共享通道獲取資料,將生產和消費完全隔離,不僅是生產者消費者,現在流行的訊息佇列,比如各種MQ,kafka,和這個都差不多。廢話不多說,直接來個demo ,看看怎麼使用:

  public static void main(String[] args) {
    LinkedBlockingQueue linkedBlockingQueue = new LinkedBlockingQueue(1024);
    for (int i = 0; i < 5; i++) {
      final int num = i;
      new Thread(() -> {
        try {
          for (int j = 0; ; j++) {
            linkedBlockingQueue.put(num + "號執行緒的" + j + "號商品");
            Thread.sleep(5000);
          }
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }).start();
    }

    for (int i = 0; i < 5; i++) {
      new Thread(() -> {
        try {
          for (; ; ) {
            System.out.println("消費了" + linkedBlockingQueue.take());
            Thread.sleep(1000);
          }
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }).start();
    }
  }


複製程式碼

執行結果:

消費了0號執行緒的0號商品
消費了3號執行緒的0號商品
消費了2號執行緒的0號商品
消費了1號執行緒的0號商品
消費了4號執行緒的0號商品
消費了2號執行緒的1號商品
消費了1號執行緒的1號商品
消費了0號執行緒的1號商品
消費了3號執行緒的1號商品
消費了4號執行緒的1號商品
消費了1號執行緒的2號商品
消費了0號執行緒的2號商品
消費了2號執行緒的2號商品
消費了3號執行緒的2號商品
消費了4號執行緒的2號商品
·········
複製程式碼

從上面的程式碼中,我們使用了5條執行緒分別向佇列中插入資料,也就是一個字串,然後讓5個執行緒從佇列中取出資料並列印,可以看到,生產者插入的資料從消費者執行緒中被列印,沒有漏掉一個。

注意,這裡的 put 方法和 take 方法都是阻塞的,不然就不是阻塞佇列了,什麼意思呢?如果佇列滿了,put 方法就會等待,直到佇列有空為止,因此該方法使用時需要注意,如果業務即時性很高,那麼最好使用帶有超時選項的 offer (V,long,TimeUnit),方法,同樣, take 方法也是如此,當佇列中沒有的時候,就會阻塞,直到佇列中有資料為止。同樣可以使用 poll(long, TimeUnit)方法超時退出。

當然不止這幾個方法,樓主將常用的方法總結一下:

插入方法:

 
    // 如果滿了,立即返回false
    boolean b = linkedBlockingQueue.offer("");
    // 如果滿了,則等待到給定的時間,如果還滿,則返回false
    boolean b2 = linkedBlockingQueue.offer("", 1000, TimeUnit.MILLISECONDS);
    // 阻塞直到插入為止
    linkedBlockingQueue.put("");
複製程式碼

取出方法:

    // 如果佇列為空,直接返回null
    Object o3 = linkedBlockingQueue.poll();
    // 如果佇列為空,一直阻塞到給定的時間
    Object o1 = linkedBlockingQueue.poll(1000, TimeUnit.MILLISECONDS);
    // 阻塞,直到取出資料
    Object o = linkedBlockingQueue.take();
    // 獲取但不移除此佇列的頭;如果此佇列為空,則返回 null。
    Object peek = linkedBlockingQueue.peek();

複製程式碼

那麼這些方法內部是如何實現的呢?

2. 原始碼分析

阻塞佇列,重點看 put 阻塞方法和 take 阻塞方法。

put 方法:
    public void put(E e) throws InterruptedException {
        if (e == null) throw new NullPointerException();
        // Note: convention in all put/take/etc is to preset local var
        // holding count negative to indicate failure unless set.
        int c = -1;
        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.
             */
            while (count.get() == capacity) {
                notFull.await();
            }
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }
複製程式碼

該方法步驟如下:

  1. 根據給定的值建立一個 Node 物件,該物件有2個屬性,一個是 item,一個是 Node 型別的 next,是連結串列結構的節點。
  2. 獲取 put 的鎖,注意,這裡,put 鎖和 take 鎖是分開的。也就是說,當你插入的時候和取出的時候用的不是一把鎖,可以高效併發,但是如果兩個執行緒同時插入就會阻塞。
  3. 獲取連結串列的長度。
  4. 使用中斷鎖,如果呼叫了執行緒的中斷方法,那麼,處於阻塞中的執行緒就會丟擲異常。
  5. 判斷如果當前連結串列長度達到了設定的長度,預設是 int 最大型,就呼叫 put 鎖的夥伴 Condition 物件 notFull 讓當前執行緒掛起等待。 直到 take 方法中會呼叫 notFull 物件的 signal 方法喚醒。
  6. 呼叫 enqueue 方法,將剛剛建立的 Node 節點連線到連結串列上。
  7. 將連結串列長度變數 count 加一。 判斷如果加一後,連結串列長度還小於連結串列規定的容量,那麼就喚醒其他等待在 notFull 物件上的執行緒,告訴他們可以取資料了。
  8. 放開鎖,讓其他執行緒爭奪鎖(非公平鎖)。
  9. 如果c是0,表示佇列已經有一個資料了,通知喚醒掛在 notEmpty 的執行緒,告訴他們可以取資料了。
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;
    }

複製程式碼

步驟如下:

  1. 獲取鏈長度,獲取 take 鎖。
  2. 呼叫可中斷的 lock 方法。開始鎖住。
  3. 如果佇列是空,則掛起執行緒。開始等待。
  4. 如果不為空,則呼叫 dequeue 方法,拿到頭節點的資料,並將頭節點更新。
  5. 將佇列長度減一。判斷如果佇列長度大於1,通知等待在 notEmpty 上的執行緒,可以拿資料了。
  6. 解鎖。
  7. 如果變數 c 和 容量相同,而剛剛又消費了一個節點,說明佇列不滿了,則通知生產者可以新增資料了。
  8. 返回資料。
boolean 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 {
            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;
    }

複製程式碼

該方法會阻塞給定的時間,如果時間到了,則返回false。 和 put 方法很相似,步驟如下:

  1. 將時間轉成納秒。
  2. 獲取 put 鎖。
  3. 呼叫可中斷鎖方法。
  4. 如果容量滿了,並且設定的等待時間小於0,返回 false,表示插入失敗,反之,呼叫 notFull 方法等待給定的時間,並返回一個負數,當第二次迴圈的時候,繼續判斷,如果還是滿的並且小於0,返回false。
  5. 如果容量沒有滿,或者等待過程被喚醒,則呼叫 enqueue 插入資料。
  6. 獲取當前連結串列長度。
  7. 判斷連結串列長度+1是否小於設定的容量。如果小於,則連結串列沒有滿,通知生產者可以新增資料了。
  8. 釋放鎖。 如果 c 等於 0,表示之前沒有資料,但是現在已經加入一個資料了,可以通知其他的消費者來消費了。
  9. 返回 true。
E poll(long timeout, TimeUnit unit) 原始碼分析
    public E poll(long timeout, TimeUnit unit) throws InterruptedException {
        E x = null;
        int c = -1;
        long nanos = unit.toNanos(timeout);
        final AtomicInteger count = this.count;
        final ReentrantLock takeLock = this.takeLock;
        takeLock.lockInterruptibly();
        try {
            while (count.get() == 0) {
                if (nanos <= 0)
                    return null;
                nanos = notEmpty.awaitNanos(nanos);
            }
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }
複製程式碼

該方法會阻塞給定的時間,如果取不到資料,返回null。

步驟其實和上面的差不多,樓主偷個懶,就不解釋了。

總結

從原始碼分析中,我們可以看到,整個阻塞佇列就是由重入鎖和Condition 組合實現的,和我們之前用 synchronized 加上 wait 和 notify 實現很相似,只是樓主的那個例子沒有使用佇列,因此無法將鎖分開,也就是我們之前說的鎖分離的技術。那麼,整體的效能當然不能和 Doug Lea 大神的比了。

好了。今天的併發原始碼分析,就到這裡。

good luck!!!!

相關文章