1. 程式人生 > >第 15 章 原子變數與非阻塞同步機制

第 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 內部以及平臺類庫中)對非阻塞演算法的使用。