第 15 章 原子變數與非阻塞同步機制
@@@ 在 java.util.concurrent 包的許多類中,例如 Semaphore 和 ConcurrentLinkedQueue ,都提供
了比 synchronized 機制更高的效能和可伸縮性。這種效能提升的主要來源:原子變數與非阻塞的同步機制。
@@@ 非阻塞演算法:
---------- 這種演算法用底層的原子機器指令(例如比較並交換指令)代替鎖來確保資料在併發訪問中的一致性。
---------- 非阻塞演算法被廣泛地用於在作業系統和 JVM 中實現執行緒 / 程序排程機制 、 垃圾回收機制以及鎖和
其他併發資料結構。
@@@ 與基於鎖的方案相比,非阻塞演算法在設計和實現上都要複雜得多,但它在可伸縮性和活躍性問題上
卻擁有巨大的優勢。
@@@ 在非阻塞演算法中不存在死鎖和其他活躍性問題。
@@@ 非阻塞演算法不會受到單個執行緒失敗的影響。
@@@ 從 Java 5.0 開始,可以使用原子變數類(例如 AtomicInteger 和 AtomicReference)來構建高效的
非阻塞演算法
@@@ 原子變數提供了與 volatile 型別變數相同的記憶體語義,此外還支援原子的更新操作,從而使它們更加
適用於實現計數器 、 序列發生器和統計資料收集等,同時還能比基於鎖的方法提供更高的可伸縮性。
》》鎖的劣勢
@@@ 現代的許多 JVM 都對非競爭鎖獲取和鎖釋放等操作進行了極大的優化,但如果有多個執行緒同時
請求鎖,那麼 JVM 就需要藉助作業系統的功能。如果出現了這種情況,那麼一些執行緒將被掛起並且在稍後
恢復執行。
@@@ 與鎖相比,volatile 變數
或執行緒排程等操作。然而,volatile 變數同樣存在一些侷限:雖然它們提供了相似的可見性保證,但不能用於
構建原子的複合操作。因此,當一個變數依賴其他的變數時,或者當變數的新值依賴於舊值時,就不能使用
volatile 變數。
@@@ 鎖還存在其他一些缺點:
--------- 當一個執行緒正在等待鎖時,它不能做任何其他事情。
@@@ 鎖定方式對於細粒度的操作(例如遞增計數器)來說仍然是一種高開銷的機制。在管理執行緒之間的
競爭時應該有一種粒度更細的技術,類似於 volatile 變數的機制,同時還要支援原子的更新操作。幸運的是,
在現代的處理器中提供了這種機制。
》》硬體對併發的支援
@@@ 在針對多處理器操作而設計的處理器中提供了一些特殊指令,用於管理對共享資料的併發訪問。
@@@ 現在,幾乎所有的現代處理器中都包含了某種形式的原子讀----改----寫指令,例如比較並交換
(Compare-and-Swap)或者關聯載入 / 條件儲存(Load-Linked / StoreConditional)。
作業系統和 JVM 使用這些指令來實現鎖和併發的資料結構,但在 Java 5.0 之前,在 Java 類中
還不能直接使用這些指令。
### 比較並交換
@@@ 在大多數處理器架構(包括 IA32 和 Sparc)中採用的方法是實現一個比較並交換(CAS)指令。
(在其他處理器中,例如 PowerPC ,採用一對指令來完成相同的功能:關聯載入和條件儲存)。
@@@ CAS 包含了 3 個運算元-------需要讀寫的記憶體位置 V 、 進行比較的值 A 、擬寫入的新值 B
-------- 當且僅當 V 的值等於 A 時,CAS 才會通過原子方式用新值 B 來更新 V 的值,否則不會執行任何
操作。
-------- 無論位置 V 的值是否等於 A ,都將返回 V 原有的值。(這種變化被稱為比較並設定,無論操作
是否成功都會返回)。
@@@ 當多個執行緒嘗試使用 CAS 同時更新同一個變數時,只有其中一個執行緒能更新變數的值,而其他
執行緒都將失敗。然而,失敗的執行緒並不會被掛起(這與獲取鎖的情況不同:當獲取鎖失敗時,執行緒將被
掛起),而是被告知這次競爭中失敗。
@@@ 由於一個執行緒在競爭 CAS 時失敗不會阻塞,因此它可以決定是否重新嘗試,或者執行一些恢復
操作,也或者不執行任何操作。
@@@ CAS 的典型使用模式是:首先從 V 中讀取值 A ,並根據 A 計算新值 B , 然後再通過 CAS 以
原子方式將 V 中的值由 A 變成 B (只要在這期間沒有任何執行緒將 A 的值修改為其他值)。由於 CAS 能
檢測到來自其他執行緒的干擾,因此即使不使用鎖也能夠實現原子的讀---改---寫操作序列。
### 非阻塞的計數器
@@@ 通常,反覆地重試是一種合理的策略,但在一些競爭很激烈的情況下,更好的方式是在重試之前
首先等待一段時間或者回退,從而避免造成活鎖問題。
@@@ 在實際情況中,如果僅需要一個計數器或序列生成器,那麼可以直接使用 AtomicInteger 或
AtomicLong ,它們能提供原子的遞增方法以及其他算術方法。
@@@ 由於 CAS 在大多數情況下都能成功執行(假設競爭程度不高),因此硬體能夠正確地預測 while
迴圈中的分支,從而把複雜控制邏輯的開銷降至最低。
@@@ 雖然 Java 語言的鎖定語法比較簡潔,但 JVM 和操作在管理鎖時需要完成的工作卻並不簡單。在
實現鎖定時需要遍歷 JVM 中一條非常複雜的程式碼路徑,並可能導致作業系統級的鎖定 、 執行緒掛起以及
上下文切換等操作。
@@@ CAS 的主要缺點是:它將使呼叫者處理競爭問題(通過重試 、 回退 、 放棄),而在鎖中能自動
處理競爭問題(執行緒在鎖之前將一直阻塞)。
@@@ CAS 的效能會隨著處理器數量的不同而變化很大。
@@@ CAS 的執行效能不僅在不同體系架構之間變化很大,甚至在相同處理器的不同版本之間也會發生
改變。
@@@ 一個很管用的經驗法則是:在大多數處理器上,在無競爭的鎖獲取和釋放的 “ 快速程式碼路徑 ” 上的
開銷,大約是 CAS 開銷的兩倍。
### JVM 對 CAS 的支援
@@@ 在 Java 5.0 中引入了底層的支援,在 int 、 long 和物件的引用等型別上都公開了 CAS 操作,
並且 JVM 把它們編譯為底層硬體提供的最有效方法。
----------- 在支援 CAS 的平臺上,執行時把它們編譯為相應的(多條)機器指令。
----------- 在最壞的情況下,如果不支援 CAS 指令,那麼 JVM 將使用自旋鎖。
----------- 在原子變數類(例如 java.util.concurrent.atomic 中的 AtomicXxx)中使用了底層 JVM 支援,為
數字型別和引用型別提供一種高效的 CAS 操作,而在 java.util.concurrent 中的大多數類在實現時
則直接或間接地使用了原子變數類。
》》原子變數類
@@@ 原子變數比鎖的粒度更細,量級更輕,並且對於在多處理器系統上實現高效能的併發程式碼來說是非常
關鍵的。
@@@ 在使用基於原子變數而非鎖的演算法中,執行緒在執行時更不易出現延遲,並且如果遇到競爭,也更容易
恢復過來。
@@@ 原子變數類相當於一種泛化的 volatile 變數,能夠支援原子的和有條件的讀--改---寫操作。
@@@ AtomicInteger 表面上非常像一個擴充套件的 Counter 類,但在發生競爭的情況下能提供更高的可伸縮性,
因為它直接利用了硬體對併發的支援。
@@@ 共有 12 個原子變數類,可分為 4組:標量類(Scalar)、更新器類 、陣列類 、 複合變數類。
-------- 最常用的原子變數就是標量類:AtomicInteger 、 AtomicLong 、AtomicBoolean 、AtomicReference 。
-------- 原子陣列類(只支援 Integer 、 Long 和 Reference 版本)中的元素可以實現原子更新。原子陣列類
為陣列的元素提供了 volatile 型別的訪問語義,這是普通陣列所不具備的特性。
@@@ 基本型別的包裝類是不可修改的,而原子變數類是可修改的。
### 原子變數是一種 “ 更好的 volatile ”
### 效能比較:鎖與原子變數
@@@ 偽隨機數字生成器(PRNG)
@@@ 在高度競爭的情況下,鎖的效能將超過原子變數的效能,但在更真實的情況下,原子變數的效能
將超過鎖的效能。這是因為鎖在發生競爭時會掛起執行緒,從而降低 CPU 的使用率和共享記憶體總線上的同步
訊號量。
@@@ 任何一個真實的程式都不會除了競爭鎖或原子變數,其他什麼工作都不做。在實際情況中,原子
變數在可伸縮性上要高於鎖,因為在應對常見的競爭程度時,原子變數的效率會更高。
@@@ 在中低程度的競爭下,原子變數能夠提供更高的可伸縮性,而在高強度的競爭下,鎖能夠更有效
地避免競爭。
》》非阻塞演算法
@@@ 如果在某種演算法中,一個執行緒的失敗或掛起不會導致其他執行緒也失敗或掛起,那麼這種演算法就被
稱為非阻塞演算法。如果在演算法的每個步驟中都存在某個執行緒能夠執行下去,那麼這種演算法也被稱為無鎖
(Lock--Free)演算法。
@@@ 如果在演算法中僅將 CAS 用於協調執行緒之間的操作,並且能正確地實現,那麼它既是一種無阻塞
演算法,又是一種無鎖演算法。
@@@ 在非阻塞演算法中通常不會出現死鎖和優先順序反轉問題(但可能會出現飢餓和活鎖問題,因為在
演算法中會反覆地重試)。
@@@ CasCounter 是一種非阻塞演算法。
在許多常見的資料結構中都可以使用非阻塞演算法,包括棧 、 佇列 、 優先佇列以及散列表等。
### 非阻塞的棧
@@@ 在實現相同功能的前提下,非阻塞演算法通常比基於鎖的演算法更為複雜。
@@@ 建立非阻塞演算法的關鍵在於,找出如何將原子修改的範圍縮小到單個變數上,同時還要維護
資料的一致性。
@@@ 棧是最簡單的鏈式資料結構:每個元素僅指向一個元素,並且每個元素也只被一個元素引用。
@@@ 非阻塞演算法的所有特性:某項工作的完成具有不確定性,必須重新執行。
### 非阻塞的連結串列
@@@ 構建非阻塞演算法的技巧在於:將執行原子修改的範圍縮小到單個變數上。
@@@ 連結佇列比棧更為複雜,因為它必須支援對頭節點和尾節點的快速訪問。
@@@ 在為連結佇列構建非阻塞演算法時,可以使用技巧:即使在一個包含多個步驟的更新操作中,
也要確保資料結構總是處於一致的狀態。
還可以使用其他技巧:如果當 B 執行緒到達時發現 A 執行緒正在修改資料結構,那麼在資料結構
中應該有足夠多的資訊,使得 B 執行緒能完成 A 執行緒的更新操作。如果 B 執行緒 “幫助” A 執行緒完成了更新
操作,那麼 B 可以執行自己的操作,而不用等待 A 的操作完成。當 A 恢復後再試圖完成其操作時,會
發現 B 執行緒已經替它完成了。
@@@ 在許多佇列演算法中,空佇列通常都包含一個 “ 哨兵(Sentinel)節點 ” 或者 “ 啞(Dummy)節點 ” ,
並且頭節點和尾節點在初始化時都指向該哨兵節點。尾節點通常要麼指向哨兵節點(如果佇列為空),即
佇列的最後一個元素,要麼(當有操作正在執行更新時)指向倒數第二個元素。
@@@ LinkedQueue.put 方法在插入新元素之前,將首先檢查佇列是否處於中間狀態。如果是,
那麼另一個執行緒正在插入元素。
### 原子的域更新器
@@@ 在 ConcurrentLinkedQueue 中沒有使用原子引用來表示每個 Node ,而是使用普通的 volatile
型別引用,並通過基於反射的 AtomicReferenceFieldUpdater 來進行更新。
@@@ 原子域更新器類表示現有的 volatile 域的一種基於反射的 “ 檢視 ” ,從而能夠在已有的 volatile
域上使用 CAS 。
在更新器類中沒有建構函式,要建立一個更新器物件,可以呼叫 newUpdater 工廠方法,並制定
類和域的名字。
域更新器類沒有與某個特定的例項關聯在一起,因而可以更新目標類的任意例項中的域。
@@@ 在 ConcurrentLinkedQueue 中,使用 nextUpdater 的 compareAndSet 方法來更新 Node 的
next 域。這個方法有點繁瑣,但完全是為了提升效能。
@@@ 在幾乎所有情況下,普通原子變數的效能都很不錯,只有在很少的情況下才需要使用原子的
域更新器。(如果在執行原子更新的同時還需要維持現有類的序列化形式,那麼原子的域更新器將
非常有用)。
### ABA 問題
@@@ ABA 問題是一種異常現象:如果在演算法中的節點可以被迴圈使用,那麼在使用 “ 比較並交換 ”
指令時就可能出現這種問題(主要在沒有垃圾回收機制的環境中)。
@@@ 在某些演算法中,如果 V 的值首先由 A 變成 B ,再由 B 變成 A ,那麼仍然被認為是發生了
變化,並需要重新執行演算法中的某些步驟。
@@@ 如果在演算法中採用自己的方式來管理節點物件的記憶體,那麼可能出現 ABA 問題。
@@@ 如果通過垃圾回收器來管理連結串列節點仍然無法避免 ABA 問題,那麼還有一個相對簡單的
解決方案:不是更新某個引用的值,而是更新兩個值,包括一個引用和一個版本號。即使這個值
由 A 變為 B , 然後又變成 A ,版本號也將是不同的。
@@@ AtomicStampedReference 將更新一個 “ 物件---引用 ” 二元組,通過在引用上加上 “ 版本號 ”,
從而避免 ABA 問題。
@@@ AtomicMarkableReference 將更新一個 “ 物件引用----布林值 ” 二元組,在某些演算法中將通過
這種二元組使節點儲存在連結串列中同時又將其標記為 “ 已刪除的節點 ” 。
》》小結
@@@ 非阻塞演算法通過底層的併發原語(例如比較並交換而不是鎖)來維持執行緒的安全性。這些底層
的原語通過原子變數類向外公開,這些類也用做一種 “ 更好的 volatile 變數 ” ,從而為整數和物件引用
提供原子的更新操作。
@@@ 非阻塞演算法在設計和實現時非常困難,但通常能夠提供更高的可伸縮性,並能更好地防止活躍
性故障的發生。在 JVM 從一個版本升級到下一個版本的過程中,併發效能的主要提升都來自於(在
JVM 內部以及平臺類庫中)對非阻塞演算法的使用。