[TOC]##1. 前言 若是你認真看過我前幾天寫的這篇博客本身動手構建無鎖的併發容器(棧和隊列)的隊列部分,那麼我要向你表示道歉。由於在實現隊列的出隊方法時我犯了一個低級錯誤:隊列的出隊方向是在隊列頭部,而個人實現是在隊列尾部。儘管代碼可以正確執行,但明顯不符合隊列規範。因此那部分代碼寫做"基於雙向鏈表的無鎖隊列"其實讀做「基於雙向鏈表的無鎖棧」。固然,「隊列是從一端入隊而從另外一端出隊的,在一邊進出的那是棧」這種常識我確定是有的,至於爲何會犯這種低級錯誤思來想去只能歸咎於連續高溫致使的倦怠。前段時間的我,就好像一隻被困在土裏的非洲肺魚,人生的所有意義都在等待雨季的來臨。最近,久違的雨水帶來了些許涼意,也沖走了這種精神上的疲倦,趁這個機會要好好糾正下之前的錯誤。代碼見github上beautiful-concurrenthtml
##2. 基於雙向鏈表實現的無鎖隊列 鏈表節點的定義以下git
/** * 鏈表節點的定義 * @param <E> */ private static class Node<E> { //指向前一個節點的指針 public volatile Node pre; //指向後一個結點的指針 public volatile Node next; //真正要存儲在隊列中的值 public E item; public Node(E item) { this.item = item; } @Override public String toString() { return "Node{" + "item=" + item + '}'; } }
基於雙向鏈表實現無鎖隊列時,結點指針不須要被原子的更新,只須要用volatile修飾保證可見性。github
###2.1 入隊方法 首先仍是來看下隊列的入隊方法,這部分代碼參考了Doug Lea在AQS中對線程加入同步隊列這部分邏輯的實現,因此正確性是沒有問題的安全
/** * 將元素加入隊列尾部 * * @param e 要入隊的元素 * @return true:入隊成功 false:入隊失敗 */ public boolean enqueue(E e) { //建立一個包含入隊元素的新結點 Node<E> newNode = new Node<>(e); //死循環 for (; ; ) { //記錄當前尾結點 Node<E> taild = tail.get(); //當前尾結點爲null,說明隊列爲空 if (taild == null) { //CAS方式更新隊列頭指針 if (head.compareAndSet(null, newNode)) { //非同步方式更新尾指針 tail.set(newNode); return true; } } else { //新結點的pre指針指向原尾結點 newNode.pre = taild; //CAS方式將尾指針指向新的結點 if (tail.compareAndSet(taild, newNode)) { //非同步方式使原尾結點的next指針指向新加入結點 taild.next = newNode; return true; } } } }
這裏分了兩種狀況來討論,隊列爲空和隊列不爲空,經過隊列尾指針所指向的元素進行判斷:併發
1.隊列爲空:隊列尾指針指向的結點爲null,這部分邏輯在if分句中 首先以CAS方式更新隊列頭指針指向新插入的結點,若執行成功則以非同步的方式將尾指針也指向該結點,結點入隊成功;若CAS更新頭指針失敗則要從新執行for循環,整個過程以下圖所示 ide
2.隊列不爲空:隊列尾指針指向的結點不爲null。則分三步實現入隊邏輯,整個過程以下圖所示 性能
僅考慮入隊情形,整個過程是線程安全,儘管有些步驟沒有進行同步。咱們分隊列爲空和不爲空兩種狀況來進行論證:測試
head.compareAndSet(null, newNode)
更新頭指針的操做成功,那麼tail.set(newNode)
這句無論其什麼時候執行,其餘線程將由於tail爲null只能進入該if分句中,而且更新頭指針的CAS操做必然失敗,由於此時head已經不爲null。因此僅就入隊情形而言,隊列爲空時的操做是線程安全的。tail.compareAndSet(taild, newNode)
執行成功,那麼此時結點已經成功加入隊列,taild.next = newNode;
這步什麼時候執行僅就入隊的情形而言沒有任何關係(可是會影響出隊的邏輯實現,這裏先賣個關子)。###2.2 出隊方法優化
/** * 將隊列首元素從隊列中移除並返回該元素,若隊列爲空則返回null * * @return */ public E dequeue() { //死循環 for (; ; ) { //當前頭結點 Node<E> tailed = tail.get(); //當前尾結點 Node<E> headed = head.get(); if (tailed == null) { //尾結點爲null,說明隊列爲空,直接返回null return null; } else if (headed == tailed) { //尾結點和頭結點相同,說明隊列中只有一個元素,此時要更新頭尾指針 //CAS方式更新尾指針爲null if (tail.compareAndSet(tailed, null)) { //頭指針更新爲null head.set(null); return headed.item; } } else { //走到這一步說明隊列中元素結點的個數大於1,只要更新隊列頭指針指向原頭結點的下一個結點就行 //可是要注意頭結點的下一個結點可能爲null,因此要先確保新的隊列頭結點不爲null //隊列頭結點的下一個結點 Node headedNext = headed.next; if (headedNext != null && head.compareAndSet(headed, headedNext)) headedNext.pre=null; //help gc return headed.item; } } }
出隊的邏輯實現主要分三種狀況討論:隊列爲空,隊列中恰好一個元素結點和隊列中元素結點個數大於1。 其實上次代碼中出錯的部分主要是隊列中結點個數大於1這種狀況,而其餘兩種狀況無論從哪邊出隊操做都是同樣的。下面就分狀況討論下出隊實現中須要注意的點this
1.隊列爲空,判斷標準是tail即尾指針是否指向null,由於入隊的時候就是以tail指針來判斷隊列狀態的,因此這裏要保持一致性,哪怕空隊列的入隊過程當中頭指針已經成功指向新結點但沒來得及更新尾指針,此時出隊也會返回null。
2.隊列中恰好只有一個元素:頭尾指針恰好指向同一個結點。首先以CAS方式更新尾指針指向null,執行成功再以正常方式設置頭指針爲null,這麼作會有併發問題嗎?考慮這種極端情形:恰好CAS更新尾指針爲null而後失去了CPU執行權,以下圖所示:
分兩種狀況討論: 1.出隊情形 由於tail已經爲null,程序會判斷隊列爲空,因此以後執行出隊的線程將返回null 2.入隊情形 由於tail爲null,因此執行入隊邏輯的線程會進入if分句,由於此時head不爲null,因此執行圖示的CAS操做時會失敗並不斷自旋
綜上所示,隊列中剛好只有一個元素結點的出隊邏輯是線程安全的。
headedNext != null
確保頭結點的下一個結點不爲null。你可能會問:等等,執行這部分代碼的前提是隊列中元素結點的個數至少爲2,那麼頭結點的下一個結點確定不爲null啊。若是隻考慮出隊的狀況,這麼想沒錯,可是此時可能處於隊列入隊的中間狀態,以下圖所示 如上圖所示,隊列中有3個元素結點,可是負責第二個結點入隊的線程已經成功執行尾指針的更新操做但沒來得及更新前一個節點的next指針便失去了CPU執行權,回想下入隊的流程,其實這種狀況是可能存在而且容許的。若是此時沒有經過headedNext != null
進行判斷便更新head指針指向頭結點的下一個結點,那麼就會出現下面這種狀況
此時出隊線程仍是會執行最後一個else分句這部分代碼,雖然此時隊列不爲空,但head指向了null,對其執行CAS更新操做將會拋出空指針異常,看來咱們上次對head指針的更新操做太草率了,沒有考慮到頭結點的next指針可能爲null這種入隊操做致使的特殊狀況。因此在對head指針進行CAS更新前要得到所記錄頭結點的下一個結點headedNext,並經過headedNext !=null
保證更新後的頭結點不爲null。若是這種狀況發生,出隊線程將經過自旋等待,直到形成這種狀況的入隊線程成功執行 taild.next = newNode;
,此時當前出隊線程的出隊過程才能執行成功,並正確設置頭指針指向原隊列頭結點的下一個結點。
完整的代碼見githubbeautiful-concurrent
##3. 性能測試 開啓200個線程,每一個線程混合進行10000次入隊和出隊操做,將上述流程重複進行100次統計出執行的平均時間(毫秒),完整的測試代碼已經放到github上beautiful-concurrent。測試結果以下圖所示
最後的測試結果然是出人意料。修復原來的隊列在一端進出的bug後,性能居然也有了很大的提升。基於雙向鏈表實現的無鎖隊列LockFreeLinkedQueue在併發環境下的性能排在了第二位,超出了咱們本身實現的基於單向鏈的無鎖隊列LockFreeSingleLinkedQueue不少,甚至接近於ConcurrentLinkedQueue的表現,要知道後者實現比咱們的複雜了不少,通過了不少優化。原來的錯誤實現由於出隊和入隊在一端進行,因此無緣無故增長了沒必要要的CSA競爭,致使併發性能下降這個好理解;那爲何比基於單向鏈表的隊列表現還要好。畢竟後者沒有prev指針,少了不少指針操做。關於這點,多是由於單向鏈表實現時的CAS競爭過多,致使對CPU的有效利用率不高。而雙向鏈表因其結構的特殊性,反而必定程度減小了CAS競爭。因此這也是個教訓,若是能保證線程安全,儘可能不要使用任何同步操做,若是不得不進行同步,那麼越輕量級越好,volatile就比CAS"輕"得多。在拓寬下思路,若是咱們對其進行相似於ConcurrentLinkedQueue的優化,好比不須要每次入隊都更新隊列尾指針,性能是否還會有飛躍,甚至超出ConcurrentLinkedQueue自己?這多是個有意思的嘗試,先挖個坑好了,之後有時間再填。
##4.總結 這篇文章是對前面文章錯誤的修正,之因此獨立成篇也是但願那些原來被我"誤導"過的同窗更有機會看到。此次對隊列的出隊過程進行了詳細的圖文分析,而沒有像上次那樣偷懶,只講了個大概,否則也不會出現"隊列在一端進出"這種低級錯誤,不知道上篇文章被人踩了一腳是否是這個緣由,若是能在發現錯誤的時候在下面留言給我指出來就太感謝了。畢竟寫技術博客的好處在於不只是系統梳理技術知識自我提升的過程,也是一個和他人分享討論共同進步的過程。而這一過程不只須要做者本身努力,也須要讀者共同參與。