併發編程系列之併發容器:ConcurrentLinkedQueue

前言

上節我們介紹了線程安全的HashMap,今天我們再來介紹一個線程安全的併發容器:ConcurrentLinkedQueue,它是一個線程安全的隊列,在Java中如果要實現一個線程安全的隊列由2種方式:一個是使用阻塞算法的隊列,用一個鎖(入隊和出隊列共享同一把鎖)或者兩個鎖(入隊和出隊各持一把鎖)來實現,另一個是使用非阻塞的CAS方式來實現,今天我們我們就來一起看看如何使用非阻塞方式實現ConcurrentLinkedQueue,OK,讓我們揚帆起航,開始今天的併發之旅吧。

 

什麼是ConcurrentLinkedQueue?

ConcurrentLinkedQueue是一個適用於高併發場景的隊列,通過無鎖的方式,實現了高併發下狀態下的高性能,性能一般要比BlockingQueue(阻塞隊列)性能好,它是一個基於鏈表節點的無界限安全隊列(不允許Null元素存在,有null存在則鏈表就會斷了)。該隊列的元素遵循先進先出(FIFO)原則,頭是最先加入的,尾是最近加入的,單向管道一頭進另一頭出,它採用了無等待算法來實現(CAS)。

 

ConcurrentLinkedQueue的結構(tair應該爲tail,圖是網上引的所以特意說明下

 

初始化時head爲空,tair=head,我們可以從源碼看出這點:

private transient volatile Node<E> tail;
public ConcurrentLinkedQueue() {
      head = tail = new Node<E>(null);
  }

 

ConcurrentLinkedQueue主要方法:

 

 

入隊列

入隊列的過程:入隊列就是將入隊列的節點添加到隊列的尾部的過程,例如我們添加向一個空隊列,我們來看下,節點的插入過程,如下圖所示:

從上面的示例我們可以看出,tail不一定是隊列的尾節點,再元素入隊列的時候,會先判斷當前tail節點的next節點是否爲空,如果爲空,則將新增元素節點設置爲tail的next節點,tail保持不變,如果不爲空,則將新增節點設置爲tail,當前tail的next則爲空。

多線程環境下,情況比上面要更復雜,例如一個線程正在入隊,那麼它必須先獲取尾節點,然後設置尾節點的下一個節點爲入隊節點,但這時可能有另外一個線程插隊了,先完成了入隊,那麼隊列的尾節點就會發生變化,此時當前線程就必須暫停入隊操作,然後重新獲取尾節點,再進行入隊,下面我們來看下源碼實現:

public boolean offer(E e) {
      // 檢查入隊節點是否爲空,爲空則拋出異常(進入checkNotNull方法可查閱)
      checkNotNull(e);
      // 入隊前,創建一個入隊節點newNode
      final Node<E> newNode = new Node<E>(e);
      // 死循環,入隊不成功反覆入隊 p用來表示隊列的尾節點,默認情況下等於tail節點,t是一個指向tail的引用
      for (Node<E> t = tail, p = t;;) {
          // 獲得p節點的下一個節點q
          Node<E> q = p.next;
          // 如果q爲空,,說明p爲尾節點,則將新增節點newNode設置爲tail的next節點
          if (q == null) {
              // 將新節點以CAS方式加到隊列末尾
              if (p.casNext(null, newNode)) {
                  // 如果p和tail引用p不同,說明引用t指向的節點不是最新尾節點,讓t指向最新的節點
                  if (p != t)
                      // 更新tail節點引用
                      casTail(t, newNode);  
                  return true;
              }
          }
          // 說明p節點和其尾節點都是null,這表明這個隊列剛剛初始化,因此返回head節點
          else if (p == q)
              p = (t != (t = tail)) ? t : head;
          // 否則說明p不爲空,將新增的尾節點節點設置爲tail節點
          else
              p = (p != t && t != (t = tail)) ? t : q;
      }
  }

入隊還有一個方法:add,我們再來看下add方法,其本質還是offer,如下:

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

小結:從源碼我們得出入隊列主要做2件事:定位出尾節點和使用CAS算法能將入隊節點設置成尾節點的next節點,如不成功則重試。此外我們從源碼能看出來offer方法總是返回true,所以我們不能感覺返回值來判斷是否真正入隊列成功,如果要判斷,可以入隊列之前和完成之後分別獲取下隊列的大小,再相比較;

 

出隊列

出隊列過程:出隊列就是一個從隊列裏返回頭節點元素,並清空該節點對元素的引用的過程,和入隊列過程相反,這裏就不舉例子說明了,我們直接往下看,出隊列有2個方法,poll和peek都是取頭元素節點,區別在於前者會刪除元素,後者不會,我們看源碼如下:

public E poll() {
      // 設置起始點
      restartFromHead:
      for (;;) {
     // h指向head,p表示頭結點,需要出隊的節點,q表示next節點
          for (Node<E> h = head, p = h, q;;) {
              E item = p.item;
    // 如果p節點裏面有元素,則該元素直接出隊列,並將p元素節點CAS更新爲NUll
              if (item != null && p.casItem(item, null)) {
                  // p不爲h的時候說明h還在前一個項爲NUll的位置,而p在後一個項有item的位置,則更新h指向
                  if (p != h)
                      // 若p的後驅不爲null,則將h指向p的後驅,因爲當前p的項也是要出隊的
                      // 若p的後驅爲NUll,則將h指向p,此時他們的item都爲Null
                      updateHead(h, ((q = p.next) != null) ? q : p);
                  return item;
              }
              // p.item=null p的後驅爲null,用P更新h值
              else if ((q = p.next) == null) {
                  updateHead(h, p);
                  return null;
              }
              else if (p == q)
                  continue restartFromHead;
              // q在p後面,p中節點已經出隊,則讓p指向q  
              else
                  p = q;
          }
      }
  }

我們再看下peek的源碼,跟poll差不多,只是減少了刪除出隊列的節點操作,如下:

public E peek() {
       restartFromHead:
       for (;;) {
           for (Node<E> h = head, p = h, q;;) {
               E item = p.item;
               if (item != null || (q = p.next) == null) {
                   updateHead(h, p);
                   return item;
               }
               else if (p == q)
                   continue restartFromHead;
               else
                   p = q;
           }
       }
   }

小結:出隊列過程中首先獲取頭節點的元素,然後判斷頭節點元素是否爲空,如果爲空,表示另外一個線程已經進行了一次出隊操作,將該節點的元素取走,如果不爲空,則使用CAS的方式將頭節點的引用設置成null,如果CAS成功,則直接返回頭節點的元素,如果不成功,表示另外一個線程已經進行了一次出隊操作更新了head節點,導致元素髮生了變化,需要重新獲取頭節點。

 

Size方法

這裏我將size方法做個說明,主要是因爲size比較特殊:用循環獲取,判斷節點是否有值。然後累加,這是一種非常低效的方式,我們在判斷一個ConcurrentLinkedQueue是否爲空時,儘量使用isEmpty()方法,而避免使用size()方法降低程序執行效率,源代碼如下:

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;
   }

 

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