ConcurrentLinkedQueue詳解

一、 前言

常用的併發隊列有阻塞隊列和非阻塞隊列,前者使用鎖實現,後者則使用CAS非阻塞算法實現,使用非阻塞隊列一般性能比較好,下面就看看常用的非阻塞ConcurrentLinkedQueue是如何使用CAS實現的。ConcurrentLinkedQueue是無界隊列。

對於使用鎖實現阻塞隊列的也分兩種情況,一種是使用一把鎖,例如ArrayBlockingQueue;還有一種是LinkedBlockingQueue採用了鎖分離的技術,也就是將put和take對應兩把鎖。

二、 ConcurrentLinkedQueue類圖結構

如圖ConcurrentLinkedQueue中有兩個volatile類型的Node節點分別用來存在列表的首尾節點,其中head節點存放鏈表第一個item爲null的節點,tail則並不是總指向最後一個節點。Node節點內部則維護一個變量item用來存放節點的值,next用來存放下一個節點,從而鏈接爲一個單向無界列表。

1
2
3
public ConcurrentLinkedQueue() {
    head = tail = new Node<E>(null);
}

如上代碼初始化時候會構建一個item爲NULL的空節點作爲鏈表的首尾節點。

三、offer操作

offer操作是在鏈表末尾添加一個元素,下面看看實現原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public boolean offer(E e) {
    //e爲null則拋出空指針異常
    checkNotNull(e);
 
   //構造Node節點構造函數內部調用unsafe.putObject,後面統一講
    final Node<E> newNode = new Node<E>(e);
 
 
    //從尾節點插入
    for (Node<E> t = tail, p = t;;) {
 
        Node<E> q = p.next;
 
        //如果q=null說明p是尾節點則插入
        if (q == null) {
 
            //cas插入(1)
            if (p.casNext(null, newNode)) {
                //cas成功說明新增節點已經被放入鏈表,然後設置當前尾節點(包含head,1,3,5.。。個節點爲尾節點)
                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
        }
        else if (p == q)//(2)
            //多線程操作時候,由於poll時候會把老的head變爲自引用,然後head的next變爲新head,所以這裏需要
            //重新找新的head,因爲新的head後面的節點纔是激活的節點
            p = (t != (t = tail)) ? t : head;
        else
            // 尋找尾節點(3)
            p = (p != t && t != (t = tail)) ? t : q;
    }
}

從構造函數知道一開始有個item爲null的哨兵節點,並且head和tail都是指向這個節點,然後當一個線程調用offer時候首先

如圖首先查找尾節點,q==null,p就是尾節點,所以執行p.casNext通過cas設置p的next爲新增節點,這時候p==t所以不重新設置尾節點爲當前新節點。由於多線程可以調用offer方法,所以可能兩個線程同時執行到了(1)進行cas,那麼只有一個會成功(假如線程1成功了),成功後的鏈表爲:

失敗的線程會循環一次這時候指針爲:

這時候會執行(3)所以p=q,然後在循環後指針位置爲:

所以沒有其他線程干擾的情況下會執行(1)執行cas把新增節點插入到尾部,沒有干擾的情況下線程2 cas會成功,然後去更新尾節點tail,由於p!=t所以更新。這時候鏈表和指針爲:

假如線程2cas時候線程3也在執行,那麼線程3會失敗,循環一次後,線程3的節點狀態爲:

這時候p!=t ;並且t的原始值爲told,t的新值爲tnew ,所以told!=tnew,所以 p=tnew=tail;

然後在循環一下後節點狀態:

q==null所以執行(1)。

現在就差p==q這個分支還沒有走,這個要在執行poll操作後纔會出現這個情況。poll後會存在下面的狀態

這個時候添加元素時候指針分佈爲:

所以會執行(2)分支 結果 p=head
然後循環,循環後指針分佈:

所以執行(1),然後p!=t所以設置tail節點。現在分佈圖:

自引用的節點會被垃圾回收掉。

四、 add操作

add操作是在鏈表末尾添加一個元素,下面看看實現原理。
其實內部調用的還是offer

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

五、poll操作

poll操作是在鏈表頭部獲取並且移除一個元素,下面看看實現原理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public E poll() {
    restartFromHead:
 
    //死循環
    for (;;) {
 
        //死循環
        for (Node<E> h = head, p = h, q;;) {
 
            //保存當前節點值
            E item = p.item;
 
            //當前節點有值則cas變爲null(1)
            if (item != null && p.casItem(item, null)) {
                //cas成功標誌當前節點以及從鏈表中移除
                if (p != h) // 類似tail間隔2設置一次頭節點(2)
                    updateHead(h, ((q = p.next) != null) ? q : p);
                return item;
            }
            //當前隊列爲空則返回null(3)
            else if ((q = p.next) == null) {
                updateHead(h, p);
                return null;
            }
            //自引用了,則重新找新的隊列頭節點(4)
            else if (p == q)
                continue restartFromHead;
            else//(5)
                p = q;
        }
    }
}
    final void updateHead(Node<E> h, Node<E> p) {
        if (h != p && casHead(h, p))
            h.lazySetNext(h);
    }

當隊列爲空時候:

可知執行(3)這時候有兩種情況,第一沒有其他線程添加元素時候(3)結果爲true然後因爲h!=p爲false所以直接返回null。第二在執行q=p.next前,其他線程已經添加了一個元素到隊列,這時候(3)返回false,然後執行(5)p=q,然後循環後節點分佈:

這時候執行(1)分支,進行cas把當前節點值值爲null,同時只有一個線程會成功,cas成功 標示該節點從隊列中移除了,然後p!=h,調用updateHead方法,參數爲h,p;h!=p所以把p變爲當前鏈表head節點,然後h節點的next指向自己。現在狀態爲:

cas失敗 後 會再次循環,這時候分佈圖爲:

這時候執行(3)返回null.

現在還有個分支(4)沒有執行過,那麼什麼時候會執行那?

這時候執行(1)分支,進行cas把當前節點值值爲null,同時只有一個線程A會成功,cas成功 標示該節點從隊列中移除了,然後p!=h,調用updateHead方法,假如執行updateHead前另外一個線程B開始poll這時候它p指向爲原來的head節點,然後當前線程A執行updateHead這時候B線程鏈表狀態爲:

所以會執行(4)重新跳到外層循環,獲取當前head,現在狀態爲:

六、peek操作

peek操作是獲取鏈表頭部一個元素(只讀取不移除),下面看看實現原理。
代碼與poll類似,只是少了castItem.並且peek操作會改變head指向,offer後head指向哨兵節點,第一次peek後head會指向第一個真的節點元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
        }
    }
}

七、size操作

獲取當前隊列元素個數,在併發環境下不是很有用,因爲使用CAS沒有加鎖所以從調用size函數到返回結果期間有可能增刪元素,導致統計的元素個數不精確。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public int size() {
    int count = 0;
    for (Node<E> p = first(); p != null; p = succ(p))
        if (p.item != null)
            // 最大返回Integer.MAX_VALUE
            if (++count == Integer.MAX_VALUE)
                break;
    return count;
}
 
//獲取第一個隊列元素(哨兵元素不算),沒有則爲null
Node<E> first() {
    restartFromHead:
    for (;;) {
        for (Node<E> h = head, p = h, q;;) {
            boolean hasItem = (p.item != null);
            if (hasItem || (q = p.next) == null) {
                updateHead(h, p);
                return hasItem ? p : null;
            }
            else if (p == q)
                continue restartFromHead;
            else
                p = q;
        }
    }
}
 
//獲取當前節點的next元素,如果是自引入節點則返回真正頭節點
final Node<E> succ(Node<E> p) {
    Node<E> next = p.next;
    return (p == next) ? head : next;
}

八、remove操作

如果隊列裏面存在該元素則刪除給元素,如果存在多個則刪除第一個,並返回true,否者返回false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public boolean remove(Object o) {
 
    //查找元素爲空,直接返回false
    if (o == null) return false;
    Node<E> pred = null;
    for (Node<E> p = first(); p != null; p = succ(p)) {
        E item = p.item;
 
        //相等則使用cas值null,同時一個線程成功,失敗的線程循環查找隊列中其他元素是否有匹配的。
        if (item != null &&
            o.equals(item) &&
            p.casItem(item, null)) {
 
            //獲取next元素
            Node<E> next = succ(p);
 
            //如果有前驅節點,並且next不爲空則鏈接前驅節點到next,
            if (pred != null && next != null)
                pred.casNext(p, next);
            return true;
        }
        pred = p;
    }
    return false;
}

九、contains操作

判斷隊列裏面是否含有指定對象,由於是遍歷整個隊列,所以類似size 不是那麼精確,有可能調用該方法時候元素還在隊列裏面,但是遍歷過程中才把該元素刪除了,那麼就會返回false.

1
2
3
4
5
6
7
8
9
public boolean contains(Object o) {
    if (o == null) return false;
    for (Node<E> p = first(); p != null; p = succ(p)) {
        E item = p.item;
        if (item != null && o.equals(item))
            return true;
    }
    return false;
}

十、開源框架中使用

Tomcat中NioEndPoint中的每個poller裏面就維護一個ConcurrentLinkedQueue<Runnable>用來作爲緩衝存放任務。

10.1 Acceptor線程

accept線程作用是接受客戶端發來的連接請求並放入到事件隊列。

看下代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
protected class Acceptor extends AbstractEndpoint.Acceptor {
 
        @Override
        public void run() {
 
            int errorDelay = 0;
 
            // 一直循環直到接收到shutdown命令
            while (running) {
 
                ...
 
                if (!running) {
                    break;
                }
                state = AcceptorState.RUNNING;
 
                try {
                    //如果達到max connections個請求則等待
                    countUpOrAwaitConnection();
 
                    SocketChannel socket = null;
                    try {
                        // 從TCP緩存獲取一個完成三次握手的套接字,沒有則阻塞
                        // socket
                        socket = serverSock.accept();
                    } catch (IOException ioe) {
                        ...
                    }
                    // Successful accept, reset the error delay
                    errorDelay = 0;
                   if (running && !paused) {
                        if (!setSocketOptions(socket)) {
                            countDownConnection();
                            closeSocket(socket);
                        }
                    } else {
                        countDownConnection();
                        closeSocket(socket);
                    }
                   ....
                } catch (SocketTimeoutException sx) {
                    // Ignore: Normal condition
                ....
            }
            state = AcceptorState.ENDED;
        }
    }
 
 protected boolean setSocketOptions(SocketChannel socket) {
        // Process the connection
        try {
            //disable blocking, APR style, we are gonna be polling it
           ...
            getPoller0().register(channel);
        } catch (Throwable t) {
           ...
            return false;
        }
        return true;
}
 
public void register(final NioChannel socket) {
   ...
    addEvent(r);
}
 
public void addEvent(Runnable event) {
    events.offer(event);
    ...
}

10.2 Poll線程

poll線程作用是從事件隊列裏面獲取事件把鏈接套接字加入selector,並且監聽socket事件進行處理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public void run() {
    while (true) {
        try {
            ...
            if (close) {
               ...
            } else {
                hasEvents = events();
            }
            try {
                ...
            } catch ( NullPointerException x ) {...
            }
 
            Iterator<SelectionKey> iterator =
                keyCount > 0 ? selector.selectedKeys().iterator() : null;
            // 遍歷所有註冊的channel對感興趣的事件處理
            while (iterator != null && iterator.hasNext()) {
                SelectionKey sk = iterator.next();
                KeyAttachment attachment = (KeyAttachment)sk.attachment();
 
                if (attachment == null) {
                    iterator.remove();
                } else {
                    attachment.access();
                    iterator.remove();
                    processKey(sk, attachment);
                }
            }//while
 
            //process timeouts
            timeout(keyCount,hasEvents);
            if ( oomParachute > 0 && oomParachuteData == null ) checkParachute();
        } catch (OutOfMemoryError oom) {
            ...
        }
    }//while
    synchronized (this) {
        this.notifyAll();
    }
    stopLatch.countDown();
 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public boolean events() {
            boolean result = false;
 
            //從隊列獲取任務並執行
            Runnable r = null;
            while ( (r = events.poll()) != null ) {
                result = true;
                try {
                    r.run();
                    if ( r instanceof PollerEvent ) {
                        ((PollerEvent)r).reset();
                        eventCache.offer((PollerEvent)r);
                    }
                } catch ( Throwable x ) {
                    log.error("",x);
                }
            }
 
            return result;
        }
 
//如配置線程池則請求交給線程池處理。
public boolean processSocket(NioChannel socket, SocketStatus status, boolean dispatch) {
    try {
        KeyAttachment attachment = (KeyAttachment)socket.getAttachment();
        if (attachment == null) {
            return false;
        }
        attachment.setCometNotify(false); //will get reset upon next reg
        SocketProcessor sc = processorCache.poll();
        if ( sc == null ) sc = new SocketProcessor(socket,status);
        else sc.reset(socket,status);
        if ( dispatch && getExecutor()!=null ) getExecutor().execute(sc);
        else sc.run();
    } catch (RejectedExecutionException rx) {
       ...
    }
    return true;
}

十一、有意思的問題

10.1 一個判斷的執行結果分析

offer中有個 判斷 t != (t = tail)假如 t=node1;tail=node2;並且node1!=node2那麼這個判斷是true還是false那,答案是true,這個判斷是看當前t是不是和tail相等,相等則返回true否者爲false,但是無論結果是啥執行後t的值都是tail。

下面從字節碼來分析下爲啥?

  • 一個例子
1
2
3
4
5
6
7
8
9
public static void main(String[] args)  {
 
    int t = 2;
    int tail = 3;
 
    System.out.println(t != (t = tail));
 
 
}

結果爲:true;

  • 字節碼文件:

字節碼命令介紹可參考: http://blog.csdn.net/web_code/article/details/12164733

一開始棧爲空

  • 第0行指令作用是把值2入棧棧頂元素爲2

  • 第1行指令作用是將棧頂int類型值保存到局部變量t中。

  • 第2行指令作用是把值3入棧棧頂元素爲3

  • 第3行指令作用是將棧頂int類型值保存到局部變量tail中。

  • 第4調用打印命令
  • 第7行指令作用是把變量t中的值入棧

  • 第8行指令作用是把變量tail中的值入棧

  • 現在棧裏面元素爲3,2並且3位棧頂
  • 第9行指令作用是當前棧頂元素入棧,所以現在棧內容3,3,2

  • 第10行指令作用是把棧頂元素存放到t,現在棧內容3,2

  • 第11行指令作用是判斷棧頂兩個元素值,相等則跳轉 18。由於現在棧頂嚴肅爲3,2不相等所以返回true.
  • 第14行指令作用是把1入棧。

然後回頭分析下!=是雙目運算符,應該是首先把左邊的操作數入棧,然後在去計算了右側操作數。

10.2 Node的構造函數

另外對於每個節點Node在構造時候使用UnSafe.putObject設置item替代了直接對volatile的賦值,這個是爲了性能考慮?爲啥不直接賦值那,看看類註解怎麼說:

1
2
3
Node(E item) {
    UNSAFE.putObject(this, itemOffset, item);
}

When constructing a Node (before enqueuing it) we avoid paying for a volatile write to item by using Unsafe.putObject instead of a normal write. This allows the cost of enqueue to be”one-and-a-half”
CASes.

也就是說當構造Node節點時候(這時候節點還沒有放入隊列鏈表)爲了避免正常的寫volatile變量的開銷 使用了Unsafe.putObject代替。這使元素進隊列僅僅花費1.5個cas操作的耗時。這個是說使用Unsafe.putObject比直接給volatile變量賦值更高效?目前還沒有查到相關資料。

十二、總結

ConcurrentLinkedQueue使用CAS非阻塞算法實現使用CAS解決了當前節點與next節點之間的安全鏈接和對當前節點值的賦值。由於使用CAS沒有使用鎖,所以獲取size的時候有可能進行offer,poll或者remove操作,導致獲取的元素個數不精確,所以在併發情況下size函數不是很有用。另外第一次peek或者first時候會把head指向第一個真正的隊列元素。

下面總結下如何實現線程安全的,可知入隊出隊函數都是操作volatile變量:head,tail。所以要保證隊列線程安全只需要保證對這兩個Node操作的可見性和原子性,由於volatile本身保證可見性,所以只需要看下多線程下如果保證對着兩個變量操作的原子性。

對於offer操作是在tail後面添加元素,也就是調用tail.casNext方法,而這個方法是使用的CAS操作,只有一個線程會成功,然後失敗的線程會循環一下,重新獲取tail,然後執行casNext方法。對於poll也是這樣的。


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