在 java.util.concurrent
包的許多類中,例如 Semaphore
和 ConcurrentLinkedQueue
,都提供了比 synchronized
機制更高的性能和可伸縮性。本章將介紹這種性能提升的主要來源:原子變量和非阻塞的同步機制。
15.1 鎖的劣勢
現代的許多 JVM 都對非競爭鎖獲取和釋放等操作進行了極大的優化,但如果有多個線程同時請求鎖,那么 JVM 就需要借助操作系統的功能。如果出現了這種情況,那么一些線程將被掛起并且在稍后恢復運行。當線程恢復執行時,必須等待其他線程執行完它們的時間片以后,才能被調度執行。在掛起和恢復線程等過程中存在著很大的開銷,并且通常存在著較長時間的中斷。如果在基于鎖的類中包含有細粒度的操作(在被鎖保護的代碼塊中只包含少量操作),那么當在鎖上存在著激烈的競爭時,調度開銷與工作開銷的比值會非常高。
與鎖相比,volatile
變量是一種更輕量級的同步機制,因為在使用這些變量時不會發生上下文切換或線程調度等操作。然而,volatile
變量同樣存在一些局限,比如不能支持 讀-改-寫 的原子操作。
鎖還存在其他一些缺點。當一個線程正在等待鎖時,它不能做任何其他事情。如果一個線程在持有鎖的情況下被延遲執行(例如發生了缺頁錯誤、調度延遲、或者其他類似情況),那么所有需要這個鎖的線程都無法執行下去。如果被阻塞的線程優先級比較高,而持有鎖的線程優先級比較低,那么這將是一個嚴重的問題——也被稱為 優先級反轉(Priority Inversion)。
15.2 硬件對并發的支持
獨占鎖是一項悲觀鎖技術——它假設最壞的情況,并且只有在確保其他線程不會造成干擾的情況下才能執行下去。
對于細粒度的操作,還有另一個種更高效的方法,也是一種樂觀的方法,通過這種方法可以在不發生干擾的情況下完成更新操作。這種方法需要借助沖突檢查機制來判斷在更新過程中是否存在來自其他線程的干擾,如果存在,這個操作將失敗,并且可以重試(也可以不重試)。
15.2.1 比較并交換
在大多數處理器架構中采用的方法是實現一個比較并交換(CAS)指令。CAS 包含了 3 個操作數——需要讀寫的內存位置 V、進行比較的值 A 和 擬寫入的新值 B。當且僅當 V 的值等于 A 時,CAS 才會通過原子方式用新值 B 來更新 V 的值,否則不會執行任何操作。無論位置 V 的值是否等于 A,都將返回 V 原有的值。CAS 是一項樂觀的技術,它希望能成功地執行更新操作,并且如果有另一個線程在最近一次檢查后更新了該變量,那么 CAS 能檢測到這個錯誤。程序清單 15-1 說明了 CAS 語義。
// 程序清單 15-1
public class SimulatedCAS {
private int value;
public synchronized int get() {
return value;
}
public synchronized int compareAndSwap(int expectedValue,
int newValue) {
int oldValue = value;
if (oldValue == expectedValue) {
value = newValue;
}
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue,
int newValue) {
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
當多個線程嘗試使用 CAS 同時更新同一個變量時,只有其中一個線程能更新變量的值,而其他線程都將失敗。然而,失敗的線程并不會被掛起(這與獲取鎖的情況不同:當獲取鎖失敗時,線程將被掛起),而是被告知在這次競爭中失敗,并可以再次嘗試。
15.2.2 非阻塞的計數器
程序清單 15-2 中的 CasCounter
使用 CAS 實現了一個線程安全的計數器:
// 程序清單 15-2
public class CasCounter {
private SimulatedCAS value;
public int getValue() {
return value.get();
}
public int increment() {
int v;
do {
v = value.get();
} while (v != value.compareAndSwap(v, v + 1));
return v + 1;
}
}
遞增操作采用了標準形式——讀取舊值,根據它計算出新值(加 1),并使用 CAS 來設置這個新值。如果 CAS 失敗,那么該操作將立即重試。CasCounter
不會阻塞,但如果其他線程同時更新計數器,那么會多次執行重試操作。
初看起來,基于 CAS 的計數器似乎比基于鎖的計數器在性能上更差一些,因為它需要執行更多的操作和更復雜的控制流,并且還依賴看似復雜的 CAS 操作。但實際上,當競爭程度不高時,基于 CAS 的計數器在性能上遠遠超過了基于鎖的計數器,而在沒有競爭時甚至更高。如果要快速獲取無競爭的鎖,那么至少需要一次 CAS 操作再加上與其他所相關的操作,因此基于鎖的計數器即使在最好的情況下也會比基于 CAS 的計數器在一般情況下能執行更多的操作。
一個很管用的經驗法則時:在大多數處理器上,在無競爭的鎖獲取和釋放的 “快速代碼路徑” 上的開銷,大約是 CAS 開銷的兩倍。
15.2.3 JVM 對 CAS 的支持
在 Java 5.0 之前,如果不便攜明確地代碼,那么就無法執行 CAS。在 Java 5.0 中引入了底層的支持,在 int
、long
和對象的引用等類型上都公開了 CAS 操作。在原子變量類(例如 java.util.concurrent.atomic
中的 AtomicXxx
)中使用了這些底層的 JVM 支持為數字類型和引用類型提供了一種高效的 CAS 操作,而在 java.util.concurrent
中的大多數類在實現時則直接或間接地使用了這些原子變量類。
15.3 原子變量類
共有 12 個原子變量類,可分為 4 組:標量類(Scalar)、更新器類、數組類以及復合變量類。最常用的原子變量類就是標量類:AtomicInteger
、AtomicLong
、AtomicBoolean
以及 AtomicReference
。
15.3.1 原子變量是一種 “更好的 volatile
”
略
15.4 非阻塞算法
如果在某種算法中,一個線程的失敗或掛起不會導致其他線程也失敗或掛起,那么這種算法就被稱為非阻塞算法。如果在算法的每個步驟中都存在某個線程能夠執行下去,那么這種算法也被稱為無鎖(Lock-Free)算法。如果在算法中僅將 CAS 用于協調線程之間的操作,并且能正確地實現,那么它既是一種物阻塞算法,又是一種無鎖算法。到目前為止,我們已經看到了一個非阻塞算法:CasCounter
。在許多常見的數據結構中都可以使用非阻塞算法,包括棧、隊列、優先隊列以及散列表等。
15.4.1 非阻塞的棧
// 程序清單 15-6
// 使用 Treiber 算法構造的非阻塞棧
public class ConcurrentStack<E> {
AtomicReference<Node<E>> top = new AtomicReference<>();
public void push(E item) {
Node<E> newHead = new Node<>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null) {
return null;
}
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node <E> {
public final E item;
public Node<E> next;
public Node(E item) {
this.item = item;
}
}
}
在 CasCounter
和 ConcurrentStack
中說明了非阻塞算法的所有特性:某項工作的完成具有不確定性,必須重新執行。
15.4.2 非阻塞的鏈表
到目前為止,我們已經看到了兩個非阻塞算法,計數器和棧,它們很好地說明了 CAS 的基本使用模式:在更新某個值時存在不確定性,以及在更新失敗時重新嘗試。
鏈接隊列比棧更為復雜,因為它必須支持對頭節點和尾節點的快速訪問。因此,它需要單獨維護頭指針和尾指針。有兩個指針指向位于尾部的節點:當前最后一個元素的 next
指針,以及尾節點。當成功地插入一個新元素時,這兩個指針都需要采用原子操作來更新。初看起來,這個操作無法通過原子變量來實現。在更新這兩個指針時需要不同的 CAS 操作,并且如果第一個 CAS 成功,但第二個 CAS 失敗,那么隊列將處于不一致的狀態。而且,即使這兩個 CAS 都成功了,那么在執行這兩個 CAS 之間,仍可能由另一個線程會訪問這個隊列。因此,在為鏈接隊列構建非阻塞算法時,需要考慮到這兩種情況。
我們需要使用一些技巧。第一個技巧是,即使在一個包含多個步驟的更新操作中,也要確保數據結構總是處于一致的狀態。這樣,當線程 B 到達時,如果發現線程 A 正在執行更新,那么線程 B 就可以知道有一個操作已部分完成,并且不能立即開始執行自己的更新操作。然后 B 可以等待(通過反復檢查隊列的狀態)并直到 A 完成更新,從而使兩個線程不會相互干擾。
// 程序清單 15-7
// Michael-Scott 非阻塞算法中的插入算法
public class LinkedQueue<E> {
private static class Node <E> {
final E item;
final AtomicReference<Node<E>> next;
public Node(E item, Node<E> next) {
this.item = item;
this.next = new AtomicReference<>(next);
}
}
private final Node<E> dummy = new Node<>(null, null);
private final AtomicReference<Node<E>> head = new AtomicReference<>(dummy);
private final AtomicReference<Node<E>> tail = new AtomicReference<>(dummy);
public boolean put(E item) {
Node<E> newNode = new Node<>(item, null);
while (true) {
Node<E> curTail = tail.get();
Node<E> tailNext = curTail.next.get();
if (curTail == tail.get()) {
if (tailNext != null) {
// 隊列處于中間狀態,推進尾節點
tail.compareAndSet(curTail, tailNext);
} else {
// 處于穩定狀態,嘗試插入新節點
if (curTail.next.compareAndSet(null, newNode)) {
// 插入操作成功,嘗試推進尾節點
tail.compareAndSet(curTail, newNode);
return true;
}
}
}
}
}
}
在 ConcurrentLinkedQueue
中使用的正是上面的算法。在許多隊列算法中,空隊列通常都包含一個 “哨兵(Sentinel)節點” 或者 “啞(Dummy)結點”,并且頭節點和尾節點在初始化時都指向該哨兵節點。
當插入一個新的元素時,需要更新兩個指針。首先更新當前最后一個元素的 next
指針,將新節點鏈接到列表隊尾(處于中間狀態),然后更新尾節點,將其指向這個新元素(最終的穩定狀態)。
實現這兩個技巧時的關鍵點在于:當隊列處于穩定狀態時,尾節點的 next
域將為空,如果隊列處于中間狀態,那么 tail.next
將為非空。因此,任何線程都能夠通過檢查 tail.next
來獲取隊列當前的狀態。而且,當隊列處于中間狀態時,可以通過將尾節點向前移動一個節點,從而結束其他線程正在執行的插入元素操作,并使得隊列恢復為穩定狀態。
15.4.3 原子的域更新器
略
15.4.4 ABA 問題
在 CAS 操作中將判斷 “V 的值是否仍然為 A?”,并且如果是的話就繼續執行更新操作。在大多數情況下,包括本章給出的示例,這種判斷是完全足夠的。然而,有時候還需要直到 “自從上次看到 V 的值為 A 以來,這個是是否發生了變化?”在某些算法中,如果 V 的值首先由 A 變成 B,再由 B 變成 A,那么仍然被認為是發生了變化,并需要重新執行算法中的某些步驟。
有一個相對簡單的解決方案:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本號。即使這個值由 A 變為 B,然后又變為 A,版本號也將是不同的。AtomicStampedReference
(以及 AtomicMarkableReference
) 支持在兩個變量上執行原子的條件更新。AtomicStampedReference
將更新一個 “對象 - 引用” 二元組,通過在引用上加上 “版本號”,從而避免 ABA 問題。