Java並發——原子變量和原子操作與阻塞算法
十五年前,多處理器系統是高度專用系統,要花費數十萬美元(大多數具有兩個到四個處理器)。現在,多處理器系統很便宜,而且數量很多,幾乎每個主要微處理器都內置了多處理支持,其中許多系統支持數十個或數百個處理器。
要使用多處理器系統的功能,通常需要使用多線程構造應用程序。但是正如任何編寫並發應用程序的人可以告訴你的那樣,要獲得好的硬件利用率,只是簡單地在多個線程中分割工作是不夠的,還必須確保線程確實大部分時間都在工作,而不是在等待更多的工作,或等待鎖定共享數據結構。
問題:線程之間的協調
如果線程之間 不需要協調,那麽幾乎沒有任務可以真正地並行。以線程池為例,其中執行的任務通常相互獨立。如果線程池利用公共工作隊列,則從工作隊列中刪除元素或向工作隊列添加元素的過程必須是線程安全的,並且這意味著要協調對頭、尾或節點間鏈接指針所進行的訪問。正是這種協調導致了所有問題。
標準方法:鎖定
在 Java 語言中,協調對共享字段的訪問的傳統方法是使用同步,確保完成對共享字段的所有訪問,同時具有適當的鎖定。通過同步,可以確定(假設類編寫正確)具有保護一組給定變量的鎖定的所有線程都將擁有對這些變量的獨占訪問權,並且以後其他線程獲得該鎖定時,將可以看到對這些變量進行的更改。弊端是如果鎖定競爭太厲害(線程常常在其他線程具有鎖定時要求獲得該鎖定),會損害吞吐量,因為競爭的同步非常昂貴。(Public Service Announcement:對於現代 JVM 而言,無競爭的同步現在非常便宜。
基於鎖定的算法的另一個問題是:如果延遲具有鎖定的線程(因為頁面錯誤、計劃延遲或其他意料之外的延遲),則 沒有
還可以使用可變變量來以比同步更低的成本存儲共享變量,但它們有局限性。雖然可以保證其他變量可以立即看到對可變變量的寫入,但無法呈現原子操作的讀-修改-寫順序,這意味著(比如說)可變變量無法用來可靠地實現互斥(互斥鎖定)或計數器。
使用鎖定實現計數器和互斥
假如開發線程安全的計數器類,那麽這將暴露 get()
、 increment()
和 decrement()
操作。清單 1 顯示了如何使用鎖定(同步)實現該類的例子。註意所有方法,甚至需要同步 get()
,使類成為線程安全的類,從而確保沒有任何更新信息丟失,所有線程都看到計數器的最新值。
清單 1. 同步的計數器類
1 2 3 4 5 6 |
public class SynchronizedCounter {
private int value;
public synchronized int getValue() { return value; }
public synchronized int increment() { return ++value; }
public synchronized int decrement() { return --value; }
}
|
increment()
和 decrement()
操作是原子的讀-修改-寫操作,為了安全實現計數器,必須使用當前值,並為其添加一個值,或寫出新值,所有這些均視為一項操作,其他線程不能打斷它。否則,如果兩個線程試圖同時執行增加,操作的不幸交叉將導致計數器只被實現了一次,而不是被實現兩次。(註意,通過使值實例變量成為可變變量並不能可靠地完成這項操作。)
許多並發算法中都顯示了原子的讀-修改-寫組合。清單 2 中的代碼實現了簡單的互斥, acquire()
方法也是原子的讀-修改-寫操作。要獲得互斥,必須確保沒有其他人具有該互斥( curOwner = Thread.currentThread()
),然後記錄您擁有該互斥的事實( curOwner = Thread.currentThread()
),所有這些使其他線程不可能在中間出現以及修改 curOwner field
。
清單 2. 同步的互斥類
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public class SynchronizedMutex {
private Thread curOwner = null;
public synchronized void acquire() throws InterruptedException {
if (Thread.interrupted()) throw new InterruptedException();
while (curOwner != null)
wait();
curOwner = Thread.currentThread();
}
public synchronized void release() {
if (curOwner == Thread.currentThread()) {
curOwner = null;
notify();
} else
throw new IllegalStateException("not owner of mutex");
}
}
|
清單 1 中的計數器類可以可靠地工作,在競爭很小或沒有競爭時都可以很好地執行。然而,在競爭激烈時,這將大大損害性能,因為 JVM 用了更多的時間來調度線程,管理競爭和等待線程隊列,而實際工作(如增加計數器)的時間卻很少。,
鎖定問題
使用鎖定,如果一個線程試圖獲取其他線程已經具有的鎖定,那麽該線程將被阻塞,直到該鎖定可用。此方法具有一些明顯的缺點,其中包括當線程被阻塞來等待鎖定時,它無法進行其他任何操作。如果阻塞的線程是高優先級的任務,那麽該方案可能造成非常不好的結果(稱為 優先級倒置的危險)。
使用鎖定還有一些其他危險,如死鎖(當以不一致的順序獲得多個鎖定時會發生死鎖)。甚至沒有這種危險,鎖定也僅是相對的粗粒度協調機制,同樣非常適合管理簡單操作,如增加計數器或更新互斥擁有者。如果有更細粒度的機制來可靠管理對單獨變量的並發更新,則會更好一些;在大多數現代處理器都有這種機制。
硬件同步原語
如前所述,大多數現代處理器都包含對多處理的支持。當然這種支持包括多處理器可以共享外部設備和主內存,同時它通常還包括對指令系統的增加來支持多處理的特殊要求。特別是,幾乎每個現代處理器都有通過可以檢測或阻止其他處理器的並發訪問的方式來更新共享變量的指令。
比較並交換 (CAS)
支持並發的第一個處理器提供原子的測試並設置操作,通常在單位上運行這項操作。現在的處理器(包括 Intel 和 Sparc 處理器)使用的最通用的方法是實現名為 比較並轉換或 CAS 的原語。(在 Intel 處理器中,比較並交換通過指令的 cmpxchg 系列實現。PowerPC 處理器有一對名為“加載並保留”和“條件存儲”的指令,它們實現相同的目地;MIPS 與 PowerPC 處理器相似,除了第一個指令稱為“加載鏈接”。)
CAS 操作包含三個操作數 —— 內存位置(V)、預期原值(A)和新值(B)。如果內存位置的值與預期原值相匹配,那麽處理器會自動將該位置值更新為新值。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。(在 CAS 的一些特殊情況下將僅返回 CAS 是否成功,而不提取當前值。)CAS 有效地說明了“我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。”
通常將 CAS 用於同步的方式是從地址 V 讀取值 A,執行多步計算來獲得新值 B,然後使用 CAS 將 V 的值從 A 改為 B。如果 V 處的值尚未同時更改,則 CAS 操作成功。
類似於 CAS 的指令允許算法執行讀-修改-寫操作,而無需害怕其他線程同時修改變量,因為如果其他線程修改變量,那麽 CAS 會檢測它(並失敗),算法可以對該操作重新計算。清單 3 說明了 CAS 操作的行為(而不是性能特征),但是 CAS 的價值是它可以在硬件中實現,並且是極輕量級的(在大多數處理器中):
清單 3. 說明比較並交換的行為(而不是性能)的代碼
1 2 3 4 5 6 7 8 9 10 11 12 |
public class SimulatedCAS {
private int value;
public synchronized int getValue() { return value; }
public synchronized int compareAndSwap(int expectedValue, int newValue) {
int oldValue = value;
if (value == expectedValue)
value = newValue;
return oldValue;
}
}
|
使用 CAS 實現計數器
基於 CAS 的並發算法稱為 無鎖定算法,因為線程不必再等待鎖定(有時稱為互斥或關鍵部分,這取決於線程平臺的術語)。無論 CAS 操作成功還是失敗,在任何一種情況中,它都在可預知的時間內完成。如果 CAS 失敗,調用者可以重試 CAS 操作或采取其他適合的操作。清單 4 顯示了重新編寫的計數器類來使用 CAS 替代鎖定:
清單 4. 使用比較並交換實現計數器
1 2 3 4 5 6 7 8 9 10 11 12 |
public class CasCounter {
private SimulatedCAS value;
public int getValue() {
return value.getValue();
}
public int increment() {
int oldValue = value.getValue();
while (value.compareAndSwap(oldValue, oldValue + 1) != oldValue)
oldValue = value.getValue();
return oldValue + 1;
}
}
|
無鎖定且無等待算法
如果每個線程在其他線程任意延遲(或甚至失敗)時都將持續進行操作,就可以說該算法是 無等待的。與此形成對比的是,無鎖定算法要求僅 某個線程總是執行操作。(無等待的另一種定義是保證每個線程在其有限的步驟中正確計算自己的操作,而不管其他線程的操作、計時、交叉或速度。這一限制可以是系統中線程數的函數;例如,如果有 10 個線程,每個線程都執行一次 CasCounter.increment()
操作,最壞的情況下,每個線程將必須重試最多九次,才能完成增加。)
再過去的 15 年裏,人們已經對無等待且無鎖定算法(也稱為 無阻塞算法)進行了大量研究,許多人通用數據結構已經發現了無阻塞算法。無阻塞算法被廣泛用於操作系統和 JVM 級別,進行諸如線程和進程調度等任務。雖然它們的實現比較復雜,但相對於基於鎖定的備選算法,它們有許多優點:可以避免優先級倒置和死鎖等危險,競爭比較便宜,協調發生在更細的粒度級別,允許更高程度的並行機制等等。
很多情況下我們只是需要一個簡單的、高效的、線程安全的遞增遞減方案。註意,這裏有三個條件:簡單,意味著程序員盡可能少的操作底層或者實現起來要比較容易;高效意味著耗用資源要少,程序處理速度要快;線程安全也非常重要,這個在多線程下能保證數據的正確性。這三個條件看起來比較簡單,但是實現起來卻難以令人滿意。
通常情況下,在Java裏面,++i或者--i不是線程安全的,這裏面有三個獨立的操作:獲得變量當前值,為該值+1/-1,然後寫回新的值。在沒有額外資源可以利用的情況下,只能使用加鎖才能保證讀-改-寫這三個操作是“原子性”的。
Java 5新增了AtomicInteger類,該類包含方法getAndIncrement()以及getAndDecrement(),這兩個方法實現了原子加以及原子減操作,但是比較不同的是這兩個操作沒有使用任何加鎖機制,屬於無鎖操作。
在JDK 5之前Java語言是靠synchronized關鍵字保證同步的,這會導致有鎖(後面的章節還會談到鎖)。
鎖機制存在以下問題:
(1)在多線程競爭下,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,引起性能問題。
(2)一個線程持有鎖會導致其它所有需要此鎖的線程掛起。
(3)如果一個優先級高的線程等待一個優先級低的線程釋放鎖會導致優先級倒置,引起性能風險。
volatile是不錯的機制,但是volatile不能保證原子性。因此對於同步最終還是要回到鎖機制上來。
獨占鎖是一種悲觀鎖,synchronized就是一種獨占鎖,會導致其它所有需要鎖的線程掛起,等待持有鎖的線程釋放鎖。而另一個更加有效的鎖就是樂觀鎖。所謂樂觀鎖就是,每次不加鎖而是假設沒有沖突而去完成某項操作,如果因為沖突失敗就重試,直到成功為止。
CAS 操作
上面的樂觀鎖用到的機制就是CAS,Compare and Swap。
CAS有3個操作數,內存值V,舊的預期值A,要修改的新值B。當且僅當預期值A和內存值V相同時,將內存值V修改為B,否則什麽都不做。
非阻塞算法 (nonblocking algorithms)
一個線程的失敗或者掛起不應該影響其他線程的失敗或掛起的算法。
現代的CPU提供了特殊的指令,可以自動更新共享數據,而且能夠檢測到其他線程的幹擾,而 compareAndSet() 就用這些代替了鎖定。
拿出AtomicInteger來研究在沒有鎖的情況下是如何做到數據正確性的。
private volatile int value;
首先毫無疑問,在沒有鎖的機制下需要借助volatile原語,保證線程間的數據是可見的(共享的),這樣獲取變量值的時候才能直接讀取。
public final int get() {
return value;
}
然後來看看++i是怎麽做到的。
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
在這裏采用了CAS操作,每次從內存中讀取數據然後將此數據和+1後的結果進行CAS操作,如果成功就返回結果,否則重試直到成功為止。
而compareAndSet利用JNI來完成CPU指令的操作。
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
整體的過程就是這樣子的,利用CPU的CAS指令,同時借助JNI來完成Java的非阻塞算法。其它原子操作都是利用類似的特性完成的。
而整個J.U.C都是建立在CAS之上的,因此對於synchronized阻塞算法,J.U.C在性能上有了很大的提升。參考資料的文章中介紹了如果利用CAS構建非阻塞計數器、隊列等數據結構。
CAS看起來很爽,但是會導致“ABA問題”。
CAS算法實現一個重要前提需要取出內存中某時刻的數據,而在下時刻比較並替換,但是在這個時間差內任何變化都可能發生。
比如說一個線程one從內存位置V中取出A,這時候另一個線程two也從內存中取出A,並且two進行了一些操作變成了B,然後two又將V位置的數據變成A,這時候線程one進行CAS操作發現內存中仍然是A,然後one操作成功。盡管線程one的CAS操作成功,但是不代表這個過程就是沒有問題的。如果鏈表的頭在變化了兩次後恢復了原值,但是不代表鏈表就沒有變化。要解決"ABA問題",我們需要增加一個版本號,在更新變量值的時候不應該只更新一個變量值,而應該更新兩個值,分別是變量值和版本號,AtomicStampedReference支持在兩個變量上進行原子的條件更新,可以使用該類進行更新操作。
在不只一個線程訪問一個互斥的變量時,所有線程都必須使用同步,否則就可能會發生一些非常糟糕的事情。Java 語言中主要的同步手段就是 synchronized
關鍵字(也稱為內在鎖),它強制實行互斥,確保執行 synchronized
塊的線程的動作,能夠被後來執行受相同鎖保護的 synchronized
塊的其他線程看到。在使用得當的時候,內在鎖可以讓程序做到線程安全,但是在使用鎖定保護短的代碼路徑,而且線程頻繁地爭用鎖的時候,鎖定可能成為相當繁重的操作。
非阻塞的計數器
清單 1 中的 Counter
是線程安全的,但是使用鎖的需求帶來的性能成本困擾了一些開發人員。但是鎖是必需的,因為雖然增加看起來是單一操作,但實際是三個獨立操作的簡化:檢索值,給值加 1,再寫回值。(在 getValue
方法上也需要同步,以保證調用 getValue
的線程看到的是最新的值。雖然許多開發人員勉強地使自己相信忽略鎖定需求是可以接受的,但忽略鎖定需求並不是好策略。)
在多個線程同時請求同一個鎖時,會有一個線程獲勝並得到鎖,而其他線程被阻塞。JVM 實現阻塞的方式通常是掛起阻塞的線程,過一會兒再重新調度它。由此造成的上下文切換相對於鎖保護的少數幾條指令來說,會造成相當大的延遲。
清單 1. 使用同步的線程安全的計數器
1 2 3 4 5 6 7 8 9 |
public final class Counter {
private long value = 0;
public synchronized long getValue() {
return value;
}
public synchronized long increment() {
return ++value;
}
}
|
清單 2 中的 NonblockingCounter
顯示了一種最簡單的非阻塞算法:使用 AtomicInteger
的 compareAndSet()
(CAS)方法的計數器。compareAndSet()
方法規定 “將這個變量更新為新值,但是如果從我上次看到這個變量之後其他線程修改了它的值,那麽更新就失敗”
清單 2. 使用 CAS 的非阻塞算法
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class NonblockingCounter {
private AtomicInteger value;
public int getValue() {
return value.get();
}
public int increment() {
int v;
do {
v = value.get();
while (!value.compareAndSet(v, v + 1));
return v + 1;
}
}
|
原子變量類之所以被稱為原子的,是因為它們提供了對數字和對象引用的細粒度的原子更新,但是在作為非阻塞算法的基本構造塊的意義上,它們也是原子的。非阻塞算法作為科研的主題,已經有 20 多年了,但是直到 Java 5.0 出現,在 Java 語言中才成為可能。
現代的處理器提供了特殊的指令,可以自動更新共享數據,而且能夠檢測到其他線程的幹擾,而 compareAndSet()
就用這些代替了鎖定。(如果要做的只是遞增計數器,那麽 AtomicInteger
提供了進行遞增的方法,但是這些方法基於 compareAndSet()
,例如 NonblockingCounter.increment()
)。
非阻塞版本相對於基於鎖的版本有幾個性能優勢。首先,它用硬件的原生形態代替 JVM 的鎖定代碼路徑,從而在更細的粒度層次上(獨立的內存位置)進行同步,失敗的線程也可以立即重試,而不會被掛起後重新調度。更細的粒度降低了爭用的機會,不用重新調度就能重試的能力也降低了爭用的成本。即使有少量失敗的 CAS 操作,這種方法仍然會比由於鎖爭用造成的重新調度快得多。
NonblockingCounter
這個示例可能簡單了些,但是它演示了所有非阻塞算法的一個基本特征 —— 有些算法步驟的執行是要冒險的,因為知道如果 CAS 不成功可能不得不重做。非阻塞算法通常叫作樂觀算法,因為它們繼續操作的假設是不會有幹擾。如果發現幹擾,就會回退並重試。在計數器的示例中,冒險的步驟是遞增 —— 它檢索舊值並在舊值上加一,希望在計算更新期間值不會變化。如果它的希望落空,就會再次檢索值,並重做遞增計算。
非阻塞堆棧
非阻塞算法稍微復雜一些的示例是清單 3 中的 ConcurrentStack
。ConcurrentStack
中的 push()
和 pop()
操作在結構上與 NonblockingCounter
上相似,只是做的工作有些冒險,希望在 “提交” 工作的時候,底層假設沒有失效。push()
方法觀察當前最頂的節點,構建一個新節點放在堆棧上,然後,如果最頂端的節點在初始觀察之後沒有變化,那麽就安裝新節點。如果 CAS 失敗,意味著另一個線程已經修改了堆棧,那麽過程就會重新開始。
清單 3. 使用 Treiber 算法的非阻塞堆棧
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 |
public class ConcurrentStack< E > {
AtomicReference< Node <E>> head = new AtomicReference< Node <E>>();
public void push(E item) {
Node< E > newHead = new Node< E >(item);
Node< E > oldHead;
do {
oldHead = head.get();
newHead.next = oldHead;
} while (!head.compareAndSet(oldHead, newHead));
}
public E pop() {
Node< E > oldHead;
Node< E > newHead;
do {
oldHead = head.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
} while (!head.compareAndSet(oldHead,newHead));
return oldHead.item;
}
static class Node< E > {
final E item;
Node< E > next;
public Node(E item) { this.item = item; }
}
}
|
性能考慮
在輕度到中度的爭用情況下,非阻塞算法的性能會超越阻塞算法,因為 CAS 的多數時間都在第一次嘗試時就成功,而發生爭用時的開銷也不涉及線程掛起和上下文切換,只多了幾個循環叠代。沒有爭用的 CAS 要比沒有爭用的鎖便宜得多(這句話肯定是真的,因為沒有爭用的鎖涉及 CAS 加上額外的處理),而爭用的 CAS 比爭用的鎖獲取涉及更短的延遲。
在高度爭用的情況下(即有多個線程不斷爭用一個內存位置的時候),基於鎖的算法開始提供比非阻塞算法更好的吞吐率,因為當線程阻塞時,它就會停止爭用,耐心地等候輪到自己,從而避免了進一步爭用。但是,這麽高的爭用程度並不常見,因為多數時候,線程會把線程本地的計算與爭用共享數據的操作分開,從而給其他線程使用共享數據的機會。(這麽高的爭用程度也表明需要重新檢查算法,朝著更少共享數據的方向努力。)
非阻塞的鏈表
目前為止的示例(計數器和堆棧)都是非常簡單的非阻塞算法,一旦掌握了在循環中使用 CAS,就可以容易地模仿它們。對於更復雜的數據結構,非阻塞算法要比這些簡單示例復雜得多,因為修改鏈表、樹或哈希表可能涉及對多個指針的更新。CAS 支持對單一指針的原子性條件更新,但是不支持兩個以上的指針。所以,要構建一個非阻塞的鏈表、樹或哈希表,需要找到一種方式,可以用 CAS 更新多個指針,同時不會讓數據結構處於不一致的狀態。
在鏈表的尾部插入元素,通常涉及對兩個指針的更新:“尾” 指針總是指向列表中的最後一個元素,“下一個” 指針從過去的最後一個元素指向新插入的元素。因為需要更新兩個指針,所以需要兩個 CAS。在獨立的 CAS 中更新兩個指針帶來了兩個需要考慮的潛在問題:如果第一個 CAS 成功,而第二個 CAS 失敗,會發生什麽?如果其他線程在第一個和第二個 CAS 之間企圖訪問鏈表,會發生什麽?
對於非復雜數據結構,構建非阻塞算法的 “技巧” 是確保數據結構總處於一致的狀態(甚至包括在線程開始修改數據結構和它完成修改之間),還要確保其他線程不僅能夠判斷出第一個線程已經完成了更新還是處在更新的中途,還能夠判斷出如果第一個線程走向 AWOL,完成更新還需要什麽操作。如果線程發現了處在更新中途的數據結構,它就可以 “幫助” 正在執行更新的線程完成更新,然後再進行自己的操作。當第一個線程回來試圖完成自己的更新時,會發現不再需要了,返回即可,因為 CAS 會檢測到幫助線程的幹預(在這種情況下,是建設性的幹預)。
這種 “幫助鄰居” 的要求,對於讓數據結構免受單個線程失敗的影響,是必需的。如果線程發現數據結構正處在被其他線程更新的中途,然後就等候其他線程完成更新,那麽如果其他線程在操作中途失敗,這個線程就可能永遠等候下去。即使不出現故障,這種方式也會提供糟糕的性能,因為新到達的線程必須放棄處理器,導致上下文切換,或者等到自己的時間片過期(而這更糟)。
清單 4 的 LinkedQueue
顯示了 Michael-Scott 非阻塞隊列算法的插入操作,它是由 ConcurrentLinkedQueue
實現的:
清單 4. Michael-Scott 非阻塞隊列算法中的插入
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 |
public class LinkedQueue < E > {
private static class Node < E > {
final E item;
final AtomicReference< Node <E>> next;
Node(E item, Node< E > next) {
this.item = item;
this.next = new AtomicReference< Node <E>>(next);
}
}
private AtomicReference< Node <E>> head
= new AtomicReference< Node <E>>(new Node< E >(null, null));
private AtomicReference< Node <E>> tail = head;
public boolean put(E item) {
Node< E > newNode = new Node< E >(item, null);
while (true) {
Node< E > curTail = tail.get();
Node< E > residue = curTail.next.get();
if (curTail == tail.get()) {
if (residue == null) /* A */ {
if (curTail.next.compareAndSet(null, newNode)) /* C */ {
tail.compareAndSet(curTail, newNode) /* D */ ;
return true;
}
} else {
tail.compareAndSet(curTail, residue) /* B */;
}
}
}
}
}
|
像許多隊列算法一樣,空隊列只包含一個假節點。頭指針總是指向假節點;尾指針總指向最後一個節點或倒數第二個節點。圖 1 演示了正常情況下有兩個元素的隊列:
圖 1. 有兩個元素,處在靜止狀態的隊列
如 清單 4 所示,插入一個元素涉及兩個指針更新,這兩個更新都是通過 CAS 進行的:從隊列當前的最後節點(C)鏈接到新節點,並把尾指針移動到新的最後一個節點(D)。如果第一步失敗,那麽隊列的狀態不變,插入線程會繼續重試,直到成功。一旦操作成功,插入被當成生效,其他線程就可以看到修改。還需要把尾指針移動到新節點的位置上,但是這項工作可以看成是 “清理工作”,因為任何處在這種情況下的線程都可以判斷出是否需要這種清理,也知道如何進行清理。
隊列總是處於兩種狀態之一:正常狀態(或稱靜止狀態,圖 1 和 圖 3)或中間狀態(圖 2)。在插入操作之前和第二個 CAS(D)成功之後,隊列處在靜止狀態;在第一個 CAS(C)成功之後,隊列處在中間狀態。在靜止狀態時,尾指針指向的鏈接節點的 next 字段總為 null,而在中間狀態時,這個字段為非 null。任何線程通過比較 tail.next
是否為 null,就可以判斷出隊列的狀態,這是讓線程可以幫助其他線程 “完成” 操作的關鍵。
圖 2. 處在插入中間狀態的隊列,在新元素插入之後,尾指針更新之前
插入操作在插入新元素(A)之前,先檢查隊列是否處在中間狀態,如 清單 4 所示。如果是在中間狀態,那麽肯定有其他線程已經處在元素插入的中途,在步驟(C)和(D)之間。不必等候其他線程完成,當前線程就可以 “幫助” 它完成操作,把尾指針向前移動(B)。如果有必要,它還會繼續檢查尾指針並向前移動指針,直到隊列處於靜止狀態,這時它就可以開始自己的插入了。
第一個 CAS(C)可能因為兩個線程競爭訪問隊列當前的最後一個元素而失敗;在這種情況下,沒有發生修改,失去 CAS 的線程會重新裝入尾指針並再次嘗試。如果第二個 CAS(D)失敗,插入線程不需要重試 —— 因為其他線程已經在步驟(B)中替它完成了這個操作!
圖 3. 在尾指針更新後,隊列重新處在靜止狀態
參考資料:https://www.ibm.com/developerworks/cn/java/j-jtp04186/
https://www.ibm.com/developerworks/cn/java/j-jtp11234/
Java並發——原子變量和原子操作與阻塞算法