Java併發——非阻塞隊列之ConcurrentLinkedQueue源碼解析

前言

在Java併發體系中,很多併發場景都離不開隊列。比如Java中的定時任務框架、線程池框架、MQ等。本篇文章將記錄我的隊列學習之旅中的無阻塞隊列源碼學習。

線程安全性

首先,隊列必須是線程安全的,否則,在併發場編程中,就失去了使用隊列的意義了。隊列實現線程安全的方式有兩種:非阻塞隊列和阻塞隊列。本篇文章我們先研究非阻塞隊列。

非阻塞隊列

在Java中要實現非阻塞的線程安全,一定繞不開自旋和CAS這一對好搭配。基於這個認知,我們來深入剖析一波Doug Lea大神筆下的ConcurrentLinkedQueue的源碼。

ConcurrentLinkedQueue的結構

ConcurrentLinkedQueue是一個基於鏈表的無界線程安全隊列。採用FIFO的方式。它的結構如下:

ConcurrentLinkedQueue結構

入隊源碼解析

我們以兩個線程來模擬它的入隊過程,這個過程很艱難,但是弄懂了之後,將發現不過如此!
源碼中的one表示第一次循環,two表示第二次循環,three表示第三次循環。

public boolean offer(E e) {
    // 線程1、線程2同時進入,
    // 首先,校驗不爲空,如果爲空,則拋出NPE
    checkNotNull(e);
    // 線程1、線程2同時將當前元素構建一個新的Node節點
    final Node<E> newNode = new Node<E>(e);

    // 這是一個死循環,以保證元素最終一定入隊成功。
    
    // one:初始化時,t變量指向tail,p變量指向t,即p->t->tail.
    
    // two:線程2由於第一次循環CAS操作失敗了,因此將進行第二次循環,
    // two:此時,依然是p->t->tail。因爲線程1並沒有改變tail的值,所以tail依然是沒有改變的。
    
    // three:線程2的第三次循環來咯,此時p節點已經指向了真正的尾節點了。
    for (Node<E> t = tail, p = t;;) {
        // one:一開始時,q變量指向p節點的next節點,即:指向tail的next節點,此時next節點爲空。
        
        // two:此時q = p.next,雖然tail沒有變化,但是tail的next節點已經不爲空了!
        // two:因爲線程1已經通過CAS操作設置成功了!因此,此時線程2將跳轉到else分支
        
        // three:由於p已經指向了真正的尾節點,因此p.next == null成立,因此,此時線程2將進入if分支。
        Node<E> q = p.next;
        
        // one:此時,線程1和線程2都會執行到這裏,進入if分支。
        if (q == null) {
           // one:線程1和線程2同時通過CAS操作設置tail節點的next節點
           // one:此時必然只有一個線程CAS操作成功。假設線程1執行成功,線程2執行失敗。
           
           // three:線程2通過CAS操作設置尾節點的next節點。
            if (p.casNext(null, newNode)) {
                // one:線程1執行成功後,此時p == t 這個條件是成立的,因此不會通過CAS操作更新tail節點指向尾節點,退出循環。
                // one:而線程2執行失敗了,因此,將指向第二輪循環。
                
                // three:線程2如果執行成功後,此時p指向的是真正的尾節點,而t節點依然指向的是tail節點,由於線程1並沒有更新tail節點爲尾節點,因此p!=t成立!
                if (p != t) 
                    // three: 線程2通過CAS操作更新tail節點指向尾部節點,
                    // 就算更新失敗了也沒有關係,說明又有其他線程更新成功了,
                    // 線程2退出循環。
                    casTail(t, newNode);  // Failure is OK.
                return true;
            }
        }
        else if (p == q)
            // 這個分支的成立條件只有一種情況,那就是初始化時的情況,此時p=q=null.
            p = (t != (t = tail)) ? t : head;
        else
            // two: 此時將通過for循環尋找到真正的尾節點並賦值給p變量。
            // 找到後,進入第三次循環three.
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

通過以上的源碼分析,我們知道了元素是如何入隊的,並且能夠理解爲什麼tail節點並不總是尾節點了。
以圖片的形式展示入隊的過程如下:

ConcurrentLinkedQueue入隊過程

以上的入隊過程爲什麼要設計的這麼複雜呢?tail節點能不能必須是尾節點呢?我們帶着這樣的問題來嘗試進行優化:

public boolean offer(E e) {
    checkNotNull(e);
   Node<E> n = new Node<>(e);
   for(;;) {
       Node<E> t = tail;
       if(t.casNext(null, t) && casTail(t, n)) {
           return true;
       }
   }
}

讓tail節點永遠指向隊列的尾節點,這樣實現的代碼量很少,並且語義更加清晰。但是Doug Lea大神卻偏偏不按照我們普通人的思路實現。因爲這樣做有一個明顯的缺陷:每次都要通過循環CAS更新tail節點。如果能夠減少循環次數,就能夠提高入隊效率。
所以Doug Lea大神采用了“間隔式”的方式來更新tail節點。即:當tail節點和尾節點超過一定距離才更新tail節點。JDK1.7之前,用的是HOPS變量來控制距離,而JDK1.8則通過p == t來判斷,其本質是和HOPS變量的作用一樣的。其實這種處理方式從本質上來說就是通過增加對volatile變量的讀操作來減少volatile變量的寫操作,而讀操作比寫操作的效率要高很多,所以入隊的效率纔會有所提升!
通過以上解析,入隊其實就是解決兩個問題:1.更新tail節點;2.找到尾節點。其中對於更新tail節點,Doug Lea大神做了特殊優化,優化的方式就是“間隔式”的更新tail變量。

出隊源碼解析

同樣的,我們以兩個線程來模擬隊列出隊的過程。有了入隊的解析過程,相信出隊的解析會輕鬆一些,因爲出隊和入隊的一些思想是一樣的。源碼如下:

public E poll() {
        restartFromHead:
        // 死循環
        // 線程1和纖程2同時進入循環。
        for (;;) {
            // one: 一開始,p->h->head,即指向頭節點
            
            // two: 線程2由於cas操作失敗,因此將進行第二次循環,此時依然是p->h->head
            
            // three: 線程2將進行第三筆循環執行,此時h = head,然而,p = head.next,即下個節點了。
            for (Node<E> h = head, p = h, q;;) {
            
                // one: 線程1、線程2同時獲取到head的元素item
                
                // two: 線程2執行獲取head的item,由於線程1已經將head節點的item設置成null了,因此線程2獲取的item = null,因此進入第一個else if分支。
                
                // three: 線程2將獲取到下個節點的元素,
                E item = p.item;

                // one: 線程1、線程2同時通過CAS操作來設置頭節點的item爲null,
                // 此時,必然只有一個線程設置成功,另一個線程設置失敗,假設是線程1設置成功。線程2設置失敗,此時線程2進行第二次循環。
                
                // three: 線程2通過CAS操作成功。
                if (item != null && p.casItem(item, null)) {
                    // one: 線程1執行到這裏,並且此時p = h = head,因此判斷條件不成立,返回item.
                    
                    // three: 線程2執行到這裏,p != h 判斷條件成立。因此cas更新hdead節點。如果p.next爲空,則就爲p節點自己,否則就是p.next節點。
                    if (p != h) // hop two nodes at a time
                        updateHead(h, ((q = p.next) != null) ? q : p);
                    return item;
                }
                
                // two: 線程2執行到這個判斷分支,目的是設置q = head節點的next節點。
                // 如果q == null成立,則說明已經到了隊列尾部,此時直接更新頭結點並返回null。
                // 如果q != null,線程2將繼續執行第二個else if分支。
                else if ((q = p.next) == null) {
                    updateHead(h, p);
                    return null;
                }
                // two: 線程2繼續判斷p == q,如果條件成立,則說明又有其他線程更新了head節點,則使用新的head重新循環。
                // 如果條件不成立,則繼續執行else分支。爲了當前例子的分析,假設這裏條件不成立。
                else if (p == q)
                    continue restartFromHead;
                
                // two: 線程2執行到此處,執行p = q,實際上就是將head節點的下個節點賦值給p變量,即:p = head.next.此時線程2將繼續進行第3次循環。
                else
                    p = q;
            }
        }
    }

通過以上的分析,我們發現,出隊和入隊的處理思想居然是一致的。即head節點不總是指向頭節點。也是採用“間隔式”的方式更新。更具體一點就是:如果head節點的元素不爲空,則直接取head節點的元素且不會更新head節點,當head節點的元素爲空時,纔會更新head節點指向頭節點。

以圖片的形式展示出隊的過程如下:

ConcurrentLinkedQueue出隊過程

其他方法說明

方法名 說明
peek() 獲取元素而不刪除元素,但是也會像poll那樣更新head節點
size() 當前隊列中的有效元素個數,這個方法得到的結果不一定精確,因爲它沒有使用同步鎖,在統計的過程中,隊列可以進行入隊、出隊和刪除元素操作。因此不是線程安全的
remove(Obj o) 刪除元素

總結

ConcurrentLinkedQueue是非阻塞隊列的經典實現。非阻塞隊列的應用場景大多是多端消費的場景。

架構師之美

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