java 连等_java并发之LBQ和ABQ(1)
前言
本文是java并發系列的第一篇,先從JDK的源碼說起(JDK1.7),當然也會穿插一些1.8的內容,因為1.8在并發這塊的實現有一些優化,后面會提到。后面的篇幅會慢慢打開來看可能會涉及到一些相對深入一點的知識比如cpu cache line等等這些。
好了言歸正傳,本篇文章先從JUC包中的兩個隊列講起,LinkedBlockingQueue和ArrayBlockingQueue。我們知道有個很經典的算法是生產者——消費者模式,其中兩端共享一個存儲隊列(通常是有界隊列),這個隊列很好的起到了緩沖的作用,某種程度上可以降低兩端速率不匹配的風險。
阻塞隊列
阻塞隊列工作的過程大概如下:
1. 生產者向隊列put數據,一旦隊列滿了則生產者wait放棄生產,如果生產的是隊列中的第一個數據則喚醒消費者。
2. 消費者從隊列take數據,一旦隊列為空則消費者wait放棄消費,如果消費的是隊列滿后的第一個數據則喚醒生產者。
生產者和消費者按照兩端的線程個數大概可以分為single-writer、muti-writer等幾種模式,JDK的LBQ和ABQ是最通用的實現,也就是面對mutil-writer——>muti-reader這種也能夠正常的工作。這就要求它必須實現對資源爭用的控制也就是要有鎖。
LinkedBlockingQueue
下面先從LBQ說起,它的實現采用了一種被稱為two-lock的算法,來源于這篇論文,結合源碼看可能更清晰
/*** Head of linked list.* Invariant: head.item == null*/
private transient Node head;
/*** Tail of linked list.* Invariant: last.next == null*/
private transient Node last;
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
正如算法名所描述的一樣,它的內部采用了兩把鎖,寫鎖和讀鎖,各維護一個條件變量。讀寫鎖的分離使得它的性能通常會好于ABQ。這個算法的另一個關鍵點是dummy head也就是虛擬頭結點,正是由于這個結構的存在,使得生產者和消費者可以獨立運作互不影響,這是一個很巧妙的設計。
初始化
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node(null);
}
我們看它的構造方法會初始化一個Node節點,并傳遞參數null且將last和head都指向它。LBQ內部是一個單向鏈表的實現,Node節點就是它的內部節點,同時還維護了一頭一尾。這里要提一點的是不帶參的構造函數默認指定了隊列大小為Integer.MAX_VALUE,有撐爆內存的風險,因此使用的時候最好自己指定一個長度。現在假設我們新構造了一個LBQ實例,還未put任何數據,那么它的結構會是下面這樣的,虛邊框表示這是一個虛擬節點并不存儲數據。
入隊
而當我們put一個元素的時候查看其put方法最終的關鍵操作在enqueue函數中
private void enqueue(Node node) {
// assert putLock.isHeldByCurrentThread();
// assert last.next == null;
last = last.next = node;
}
當看到這段代碼的時候心中不禁產生了一個疑問,入隊能寫的這么簡單?理解這個入隊邏輯的關鍵是我們要清楚java里面的連等賦值是從右開始向左執行的,上面的賦值操作翻譯過來就是把新增的節點置為最后一個節點,并且把last指針指向它。假設新增a節點,操作完后的結構是這樣的可以看到構造新節點后僅僅需要改變兩個指針便可完成入隊操作,所以它的入隊操作是一個O(1)的時間復雜度。
出隊
出隊的核心邏輯如下:
private E dequeue() {
// assert takeLock.isHeldByCurrentThread();
// assert head.item == null;
Node h = head;
Node first = h.next;
h.next = h; // help GC
head = first;
E x = first.item;
first.item = null;
return x;
}
出隊涉及到頭節點和其next節點,而且我們注意到針對next節點的操作也僅僅是將該節點的值取出來并置空,假設這個時候有入隊操作,入隊操作是將節點的next指針賦值,這兩個操作是不存在競爭關系的,這也是寫讀可以并發的原因。入隊是在尾部,出隊是在頭部,即使這兩個操作都在操作同一個節點,由于其操作的并不是一塊內存(同一個節點不同的屬性)也就不存在競爭的關系。出隊操作需要移動幾次指針其復雜度也是O(1)。
另一點需要注意的是刪除的節點其next將會指向自己,這個注釋中也寫了目的是help GC。這里需要結合下面的迭代器去理解。
迭代器
LBQ中提供的是一種稱為"weakly consistent"的迭代器,和"fast-fail"形成對比,wci迭代器在迭代過程中允許原有的隊列結構發生改變,并且會反饋給用戶這種變化,其不會拋出ConcurrentModificationException異常,什么意思呢?我們來舉一個例子,假設隊列當前已有一個元素,現在開啟一個線程1不停的put元素速度是1s一個,而有另外一個線程2在用迭代器遍歷這個隊列速度也是1s一個。那么按照weakly consistent的思想,理想情況下線程2永遠也不會迭代完,并且總是能夠獲取到新put進來的元素。
那么這個迭代器和GC又有什么關系呢?LBQ的實現和ABQ不同,結構的變化它總是需要去申請和釋放內存的,這也導致它的性能不太好預測,因為GC總是不好預測的。我們知道迭代的過程中不是一直有鎖保護的,所以這也給了其他線程操作該隊列的條件。當構造一個迭代器準備迭代和開始迭代的時候,在這期間隊列結構有可能已經被別的線程修改了
上面這張圖黃色的head是構造迭代器那一刻隊列頭的位置,黑色的head是開始遍歷那一刻隊列頭的位置,可見該隊列的結構已經發生了變化有(個節點出隊了)。那么如果我們要實現弱一致性的迭代就需要沿著最開始的head節點順著next指針向后遍歷,如果節點的內容是空就跳過,這中場景迭代器某個時刻看到的結構是下面這樣的
實際上不光是迭代器看到的是這樣的,JVM的GC眼中也是這種結構,就是已經刪除的節點仍然在引用隊列中正常的節點。那么這會帶來什么問題呢?我們都知道JVM采用的是分代GC算法,分代GC有個需要考慮的問題是對象之間的跨代引用也就是tenure區引用young區的對象,由于LBQ是鏈表結構,越先入隊的節點其“年齡”越大,所以是有可能出現這種跨代引用的。由于在做minor gc的時候會把類似的處在tenure區的對象當做GC root來處理,所以某種程度會造成GC和內存的壓力。因此這才有在刪除的時候會把已刪除的節點next指針指向自己。
關于LBQ的內容就這么多,ABQ下一篇再講。
總結
以上是生活随笔為你收集整理的java 连等_java并发之LBQ和ABQ(1)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: μC/OS-II软件定时器的分析与测试
- 下一篇: Crazepony的理念