Java併發編程:ConcurrentLinkedQueue

簡介

在併發編程中我們有時候需要使用線程安全的隊列。如果我們要實現一個線程安全的隊列有兩種實現方式一種是使用阻塞算法,另一種是使用非阻塞算法。使用阻塞算法的隊列可以用一個鎖(入隊和出隊用同一把鎖)或兩個鎖(入隊和出隊用不同的鎖)等方式來實現,而非阻塞的實現方式則可以使用循環CAS的方式來實現,下面我們一起來研究下Doug Lea是如何使用非阻塞的方式來實現線程安全隊列ConcurrentLinkedQueue的。

ConcurrentLinkedQueue是一個基於鏈接節點的無界線程安全隊列,它採用先進先出的規則對節點進行排序,當我們添加一個元素的時候,它會添加到隊列的尾部,當我們獲取一個元素時,它會返回隊列頭部的元素。它採用了“wait-free”算法來實現,該算法在Michael & Scott算法上進行了一些修改。

ConcurrentLinkedQueue的類圖如下:

ConcurrentLinkedQueue由head節點和tail節點組成,每個節點(Node)由節點元素(item)和指向下一個節點的引用(next)組成,節點與節點之間就是通過這個next關聯起來,從而組成一張鏈表結構的隊列。

ConcurrentLinkedQueue源碼詳解

我們前面介紹了,ConcurrentLinkedQueue的節點都是Node類型的:

private static class Node<E> {
    volatile E item;
    volatile Node<E> next;
 
    Node(E item) {
        UNSAFE.putObject(this, itemOffset, item);
    }
 
    boolean casItem(E cmp, E val) {
        return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
    }
 
    void lazySetNext(Node<E> val) {
        UNSAFE.putOrderedObject(this, nextOffset, val);
    }
 
    boolean casNext(Node<E> cmp, Node<E> val) {
        return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
    }
 
    private static final sun.misc.Unsafe UNSAFE;
    private static final long itemOffset;
    private static final long nextOffset;
 
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class<?> k = Node.class;
            itemOffset = UNSAFE.objectFieldOffset
                (k.getDeclaredField("item"));
            nextOffset = UNSAFE.objectFieldOffset
                (k.getDeclaredField("next"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Node類也比較簡單,不再解釋,ConcurrentLinkedQueue類有下面兩個構造方法:

// 默認構造方法,head節點存儲的元素爲空,tail節點等於head節點
public ConcurrentLinkedQueue() {
    head = tail = new Node<E>(null);
}
 
// 根據其他集合來創建隊列
public ConcurrentLinkedQueue(Collection<? extends E> c) {
    Node<E> h = null, t = null;
    // 遍歷節點
    for (E e : c) {
        // 若節點爲null,則直接拋出NullPointerException異常
        checkNotNull(e);
        Node<E> newNode = new Node<E>(e);
        if (h == null)
            h = t = newNode;
        else {
            t.lazySetNext(newNode);
            t = newNode;
        }
    }
    if (h == null)
        h = t = new Node<E>(null);
    head = h;
    tail = t;
}

默認情況下head節點存儲的元素爲空,tail節點等於head節點。

head = tail = new Node<E>(null);

下面我們主要來看一下ConcurrentLinkedQueue的入隊與出隊操作。

入隊操作

入隊列就是將入隊節點添加到隊列的尾部。爲了方便理解入隊時隊列的變化,以及head節點和tail節點的變化,每添加一個節點我就做了一個隊列的快照圖:

上圖所示的元素添加過程如下:

  • 添加元素1:隊列更新head節點的next節點爲元素1節點。又因爲tail節點默認情況下等於head節點,所以它們的next節點都指向元素1節點。
  • 添加元素2:隊列首先設置元素1節點的next節點爲元素2節點,然後更新tail節點指向元素2節點。
  • 添加元素3:設置tail節點的next節點爲元素3節點。
  • 添加元素4:設置元素3的next節點爲元素4節點,然後將tail節點指向元素4節點。

入隊操作主要做兩件事情,第一是將入隊節點設置成當前隊列尾節點的下一個節點。第二是更新tail節點,如果tail節點的next節點不爲空,則將入隊節點設置成tail節點,如果tail節點的next節點爲空,則將入隊節點設置成tail的next節點,所以tail節點不總是尾節點,理解這一點很重要。

上面的分析讓我們從單線程入隊的角度來理解入隊過程,但是多個線程同時進行入隊情況就變得更加複雜,因爲可能會出現其他線程插隊的情況。如果有一個線程正在入隊,那麼它必須先獲取尾節點,然後設置尾節點的下一個節點爲入隊節點,但這時可能有另外一個線程插隊了,那麼隊列的尾節點就會發生變化,這時當前線程要暫停入隊操作,然後重新獲取尾節點。

下面我們來看ConcurrentLinkedQueue的add(E e)入隊方法:

 

public boolean add(E e) {
    return offer(e);
}
 
public boolean offer(E e) {
    // 如果e爲null,則直接拋出NullPointerException異常
    checkNotNull(e);
    // 創建入隊節點
    final Node<E> newNode = new Node<E>(e);
 
    // 循環CAS直到入隊成功
    // 1、根據tail節點定位出尾節點(last node);2、將新節點置爲尾節點的下一個節點;3、casTail更新尾節點
    for (Node<E> t = tail, p = t;;) {
        // p用來表示隊列的尾節點,初始情況下等於tail節點
        // q是p的next節點
        Node<E> q = p.next;
        // 判斷p是不是尾節點,tail節點不一定是尾節點,判斷是不是尾節點的依據是該節點的next是不是null
        // 如果p是尾節點
        if (q == null) {
            // p is last node
            // 設置p節點的下一個節點爲新節點,設置成功則casNext返回true;否則返回false,說明有其他線程更新過尾節點
            if (p.casNext(null, newNode)) {
                // Successful CAS is the linearization point
                // for e to become an element of this queue,
                // and for newNode to become "live".
                // 如果p != t,則將入隊節點設置成tail節點,更新失敗了也沒關係,因爲失敗了表示有其他線程成功更新了tail節點
                if (p != t) // hop two nodes at a time
                    casTail(t, newNode);  // Failure is OK.
                return true;
            }
            // Lost CAS race to another thread; re-read next
        }
        // 多線程操作時候,由於poll時候會把舊的head變爲自引用,然後將head的next設置爲新的head
        // 所以這裏需要重新找新的head,因爲新的head後面的節點纔是激活的節點
        else if (p == q)
            // We have fallen off list.  If tail is unchanged, it
            // will also be off-list, in which case we need to
            // jump to head, from which all live nodes are always
            // reachable.  Else the new tail is a better bet.
            p = (t != (t = tail)) ? t : head;
        // 尋找尾節點
        else
            // Check for tail updates after two hops.
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

從源代碼角度來看整個入隊過程主要做兩件事情:

  • 第一是定位出尾節點
  • 第二是使用CAS算法能將入隊節點設置成尾節點的next節點,如不成功則重試。

第一步定位尾節點。tail節點並不總是尾節點,所以每次入隊都必須先通過tail節點來找到尾節點,尾節點可能就是tail節點,也可能是tail節點的next節點。代碼中循環體中的第一個if就是判斷tail是否有next節點,有則表示next節點可能是尾節點。獲取tail節點的next節點需要注意的是p節點等於q節點的情況,出現這種情況的原因我們後續再來介紹。

第二步設置入隊節點爲尾節點。p.casNext(null, newNode)方法用於將入隊節點設置爲當前隊列尾節點的next節點,q如果是null表示p是當前隊列的尾節點,如果不爲null表示有其他線程更新了尾節點,則需要重新獲取當前隊列的尾節點。

tail節點不一定爲尾節點的設計意圖

對於先進先出的隊列入隊所要做的事情就是將入隊節點設置成尾節點,doug lea寫的代碼和邏輯還是稍微有點複雜。那麼我用以下方式來實現行不行?

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

 

讓tail節點永遠作爲隊列的尾節點,這樣實現代碼量非常少,而且邏輯非常清楚和易懂。但是這麼做有個缺點就是每次都需要使用循環CAS更新tail節點。如果能減少CAS更新tail節點的次數,就能提高入隊的效率。

在JDK 1.7的實現中,doug lea使用hops變量來控制並減少tail節點的更新頻率,並不是每次節點入隊後都將 tail節點更新成尾節點,而是當tail節點和尾節點的距離大於等於常量HOPS的值(默認等於1)時才更新tail節點,tail和尾節點的距離越長使用CAS更新tail節點的次數就會越少,但是距離越長帶來的負面效果就是每次入隊時定位尾節點的時間就越長,因爲循環體需要多循環一次來定位出尾節點,但是這樣仍然能提高入隊的效率,因爲從本質上來看它通過增加對volatile變量的讀操作來減少了對volatile變量的寫操作,而對volatile變量的寫操作開銷要遠遠大於讀操作,所以入隊效率會有所提升。

在JDK 1.8的實現中,tail的更新時機是通過p和t是否相等來判斷的,其實現結果和JDK 1.7相同,即當tail節點和尾節點的距離大於等於1時,更新tail。

ConcurrentLinkedQueue的入隊操作整體邏輯如下圖所示:

出隊操作

出隊列的就是從隊列裏返回一個節點元素,並清空該節點對元素的引用。讓我們通過每個節點出隊的快照來觀察下head節點的變化:

從上圖可知,並不是每次出隊時都更新head節點,當head節點裏有元素時,直接彈出head節點裏的元素,而不會更新head節點。只有當head節點裏沒有元素時,出隊操作纔會更新head節點。採用這種方式也是爲了減少使用CAS更新head節點的消耗,從而提高出隊效率。讓我們再通過源碼來深入分析下出隊過程。

public E poll() {
    restartFromHead:
    for (;;) {
        // p節點表示首節點,即需要出隊的節點
        for (Node<E> h = head, p = h, q;;) {
            E item = p.item;
 
            // 如果p節點的元素不爲null,則通過CAS來設置p節點引用的元素爲null,如果成功則返回p節點的元素
            if (item != null && p.casItem(item, null)) {
                // Successful CAS is the linearization point
                // for item to be removed from this queue.
                // 如果p != h,則更新head
                if (p != h) // hop two nodes at a time
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
            // 如果頭節點的元素爲空或頭節點發生了變化,這說明頭節點已經被另外一個線程修改了。
            // 那麼獲取p節點的下一個節點,如果p節點的下一節點爲null,則表明隊列已經空了
            else if ((q = p.next) == null) {
                // 更新頭結點
                updateHead(h, p);
                return null;
            }
            // p == q,則使用新的head重新開始
            else if (p == q)
                continue restartFromHead;
            // 如果下一個元素不爲空,則將頭節點的下一個節點設置成頭節點
            else
                p = q;
        }
    }
}

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

在入隊和出隊操作中,都有p == q的情況,那這種情況是怎麼出現的呢?我們來看這樣一種操作:

在彈出一個節點之後,tail節點有一條指向自己的虛線,這是什麼意思呢?我們來看poll()方法,在該方法中,移除元素之後,會調用updateHead方法:

final void updateHead(Node<E> h, Node<E> p) {
    if (h != p && casHead(h, p))
        // 將舊的頭結點h的next域指向爲h
        h.lazySetNext(h);
}

我們可以看到,在更新完head之後,會將舊的頭結點h的next域指向爲h,上圖中所示的虛線也就表示這個節點的自引用。

如果這時,再有一個線程來添加元素,通過tail獲取的next節點則仍然是它本身,這就出現了p == q的情況,出現該種情況之後,則會觸發執行head的更新,將p節點重新指向爲head,所有“活着”的節點(指未刪除節點),都能從head通過遍歷可達,這樣就能通過head成功獲取到尾節點,然後添加元素了。

其他相關方法

peek()方法

// 獲取鏈表的首部元素(只讀取而不移除)
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;
        }
    }
}

從源碼中可以看到,peek操作會改變head指向,執行peek()方法後head會指向第一個具有非空元素的節點。

size()方法

public int size() {
    int count = 0;
    // first()獲取第一個具有非空元素的節點,若不存在,返回null
    // succ(p)方法獲取p的後繼節點,若p == p的後繼節點,則返回head
    for (Node<E> p = first(); p != null; p = succ(p))
        if (p.item != null)
            // Collection.size() spec says to max out
            // 最大返回Integer.MAX_VALUE
            if (++count == Integer.MAX_VALUE)
                break;
    return count;
}

size()方法用來獲取當前隊列的元素個數,但在併發環境中,其結果可能不精確,因爲整個過程都沒有加鎖,所以從調用size方法到返回結果期間有可能增刪元素,導致統計的元素個數不精確。

remove(Object o)方法

public boolean remove(Object o) {
    // 刪除的元素不能爲null
    if (o != null) {
        Node<E> next, pred = null;
 
        for (Node<E> p = first(); p != null; pred = p, p = next) {
            boolean removed = false;
            E item = p.item;
 
            // 節點元素不爲null
            if (item != null) {
                // 若不匹配,則獲取next節點繼續匹配
                if (!o.equals(item)) {
                    next = succ(p);
                    continue;
                }
 
                // 若匹配,則通過CAS操作將對應節點元素置爲null
                removed = p.casItem(item, null);
            }
 
            // 獲取刪除節點的後繼節點
            next = succ(p);
            // 將被刪除的節點移除隊列
            if (pred != null && next != null) // unlink
                pred.casNext(p, next);
            if (removed)
                return true;
        }
    }
    return false;
}

contains(Object o)方法

public boolean contains(Object o) {
    if (o == null) return false;
 
    // 遍歷隊列
    for (Node<E> p = first(); p != null; p = succ(p)) {
        E item = p.item;
        // 若找到匹配節點,則返回true
        if (item != null && o.equals(item))
            return true;
    }
    return false;
}

該方法和size方法類似,有可能返回錯誤結果,比如調用該方法時,元素還在隊列裏面,但是遍歷過程中,該元素被刪除了,那麼就會返回false。

總結


ConcurrentLinkedQueue 的非阻塞算法實現可概括爲下面 5 點:

使用 CAS 原子指令來處理對數據的併發訪問,這是非阻塞算法得以實現的基礎。
head/tail 並非總是指向隊列的頭 / 尾節點,也就是說允許隊列處於不一致狀態。 這個特性把入隊 / 出隊時,原本需要一起原子化執行的兩個步驟分離開來,從而縮小了入隊 / 出隊時需要原子化更新值的範圍到唯一變量。這是非阻塞算法得以實現的關鍵。
由於隊列有時會處於不一致狀態。爲此,ConcurrentLinkedQueue 使用三個不變式來維護非阻塞算法的正確性。
以批處理方式來更新 head/tail,從整體上減少入隊 / 出隊操作的開銷。
爲了有利於垃圾收集,隊列使用特有的 head 更新機制;爲了確保從已刪除節點向後遍歷,可到達所有的非刪除節點,隊列使用了特有的向後推進策略。

參考資料

《Java併發編程的藝術》

非阻塞算法在併發容器中的實現

Java併發編程(七)ConcurrentLinkedQueue的實現原理和源碼分析

 

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