1. 程式人生 > >Java併發程式設計實戰 - 學習筆記

Java併發程式設計實戰 - 學習筆記

第2章 執行緒安全性

1. 基本概念

什麼是執行緒安全性?可以這樣理解:一個類在多執行緒環境下,無論執行時環境怎樣排程,無論多個執行緒之間的執行順序是什麼,且在主調程式碼中不需要進行任何額外的同步,如果該類都能呈現出預期的、正確的行為,那麼該類就是執行緒安全的。
既然這樣,那麼安全由執行緒安全類組成的程式,就一定是執行緒安全的程式嗎?也不見得。而且執行緒安全類中也可以包含非執行緒安全的類。
執行緒安全性雖然是一個程式碼上使用的術語,但它只是與狀態相關的,因此只能應用於封裝了這個(些)狀態的整個程式碼(不能再多也不能再少),它可能是一個物件,也可能是整個程式。因此,只有當類中僅包含自己的狀態時,執行緒安全類才是有意義的。

2. 相關術語

競態條件(Race Condition):在併發程式設計中,由於不恰當的執行時序而導致錯誤的結果。
競態條件的典型案例:

  • 先檢查後執行(Check-Then-Act),如
if (instance == null)
    instance = new Singleton();
  • 讀取-修改-寫入,如
counter++; // 雖然看上去很像原子操作

原子操作:兩個操作A和B,如果從執行A的執行緒來看,當另一個執行緒執行操作B時,要麼將B執行完,要麼完全沒有執行,那麼B對A來說就是原子的。(書上說這時A和B對彼此來說都是原子的,really?)
像上面說的“先檢查後執行”以及“讀取-修改-寫入”都是複合操作

,需要保證操作的原子性以確保執行緒安全。
原子變數類(java.util.concurrent.atomic包下的,如AtomicLong)對外提供的操作都是原子的,因此這些類也是執行緒安全的。
當在無狀態的類中新增一個狀態,並且該狀態完全由執行緒安全的物件來管理,那麼該類仍是執行緒安全的。但是當狀態由一個變成多個時,既然每個狀態都由執行緒安全類管理,該類也不見得是執行緒安全的。多個狀態之間可能需要滿足一定的不變性條件,要在原子操作中對涉及不變性條件的所有狀態進行更新,使它們始終維持不變性條件,才能保證執行緒安全。

3. 用鎖來保護狀態

如果使用同步來協調對某個變數的訪問,那麼對該物件的的有訪問都要使用同步(並不是只有寫操作才需要同步)。如果是基於鎖的同步,那麼對該變數的所有同步都要使用同一個鎖。當類的不變性條件涉及多個變數時,那麼這些變數都要使用同一個鎖來保護。

4. 活躍性與效能

當使用鎖時,應該清楚程式碼塊所實現的功能,以及該程式碼塊是否含有耗時較長的操作(如密集型計算或是阻塞操作),如果持有鎖的時間較長,則可能會帶來活躍性與效能的問題。
通常,要判斷同步程式碼塊的合理大小,我們要在多個設計需求之間進行權衡,包括安全性(這個必須被滿足)、簡單性(如對整個方法加synchronized進行同步)、併發性(即效能)。
有時候,在簡單性和效能之間會發生衝突。粗暴地將整個方法或是一大片程式碼塊加鎖,雖然簡單,但可能會影響併發性;而如果將同步程式碼塊分得過細(例如將一個cnt++操作放在它自己的同步程式碼塊中),效能也不見得會好,因為獲取和釋放鎖都需要一定的開銷。另外,一味地為了效能而犧牲簡單性,可能也會破壞安全性。儘量如此,在簡單性和效能之間,一般也能找到某種合理的平衡。

第3章 物件的共享

1. 可見性

在沒有同步的情況下,編譯器、處理器和執行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多執行緒程式中,想對記憶體操作的執行順序進行判斷,幾乎無法得出正確的結論。但有一種簡單的方法可以解決這個問題:只要有資料會被多個執行緒共享,就使用正確的同步。
一個只有單個狀態的類(狀態私有,通過getter和setter被外界訪問),如果對該狀態的get和set方法都加synchronized進行同步,則可以實現這個類的執行緒安全。不能只對set方法進行同步,呼叫get的執行緒仍然可能看見失效值。
對沒有同步的狀態進行多執行緒的訪問,可能會得到一個失效值,但這個值至少是之前某個執行緒設定的值,而不是一個隨機值,這種安全性保證也被稱為最低安全性(out-of-thin-air-safety)。最低安全性適用於絕大多數變數,但是有一個例外:非volatile型別的64位數值變數(long和double)。Java記憶體模型要求對變數的讀取和寫入操作都是原子操作,但對於非volatile型別的double和long變數,JVM允許對變數的高32位和低32位執行分開的讀或寫操作。因此如果讀和寫在不同的執行緒中進行,執行緒讀取的一個變數值,可能是由某個值的低32位和另一個值的高32位組成,這是一個沒有意義的值。可以用volatile修飾,或是加鎖來解決這個問題。
內建鎖可以用於確保某個執行緒以一種可以預測的方式來檢視另一個執行緒的執行結果。比如執行緒A和B,執行緒A先執行一段同步程式碼塊,執行緒B接著執行由同一個鎖保護的同步程式碼塊,在這種情況下可以保證,在鎖被執行緒A釋放之前,執行緒A執行的所有操作結果,線上程B獲取鎖之後都可以看到。如果沒有同步,就無法實現上述保證。因此加鎖的含義不僅僅是互斥,還是為了記憶體可見性。為了確保所有執行緒都能看見共享變數的最新值,所有執行讀或寫操作的執行緒都必須使用同一個鎖來進行同步。
volatile關鍵字也可以實現類似的可見性效果。當一個變數被volatile修飾時,編譯器和執行時都會知道這個變數是共享的,因此不會將該變數的相關操作和其它記憶體操作一起重排序。volatile變數不會被快取在暫存器或是對其它處理器不可見的地方,因此對變數的讀取總是能獲取到最新寫入的值。
volatile變數對可見性的影響要比volatile變數本身更加重要,因為它可以影響到其它變數的可見性。當執行緒A首先寫入一個volatile變數並且執行緒B隨後讀取該變數時,在寫入volatile變數之前對執行緒A可見的所有變數值,線上程B讀取volatile變數後對執行緒B都是可見的。因此,從記憶體可見性的角度來看,寫入volatile變數相當於退出同步程式碼塊,讀取volatile變數相當於進入同步程式碼塊。
volatile變數的正確使用方式包括:確保它們自身狀態的可見性,以及它們所引用物件的狀態的可見性,以及標識一些重要的程式生命週期事件的發生(如初始化和關閉)。
儘管如此,volatile的語義並不能保證操作的原子性(如count++),除非你能確保只有一個執行緒會對該變數進行寫操作。並不建議過度依賴volatile所提供的可見性,它通常比使用鎖的程式碼更加脆弱,也更難以理解。
當且僅當滿足以下所有條件時,才應該使用volatile變數:

  • 對變數的寫入操作不依賴物件的當前值,或者只有一個執行緒會對變數進行寫操作。
  • 該變數不會與其它變數一起被納入不變性條件之中。
  • 在訪問該變數時不需要加鎖。

2. 釋出與逸出