執行緒安全

juzhenxing發表於2020-10-03

定義

當多個執行緒同時訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那就稱這個物件是執行緒安全的。

java語言中的執行緒安全

  1. 不可變
  • 只要一個不可變的物件被正確地構建出來(即沒有發生this引用逃逸的情況),那其外部的可見狀態永遠都不會改變,永遠都不會看到它在多個執行緒之中處於不一致的狀態。
  • Java語言中,如果多執行緒共享的資料是一個基本資料型別,那麼只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。
  • 如果共享資料是一個物件,由於Java語言目前暫時還沒有提供值型別的支援,那就需要物件自行保證其行為不會對其狀態產生任何影響才行。如果讀者沒想明白這句話所指的意思,不妨類比java.lang.String類的物件例項,它是一個典型的不可變物件,使用者呼叫它的substring()、replace()和concat()這些方法都不會影響它原來的值,只會返回一個新構造的字串物件
  • 在Java類庫API中符合不可變要求的型別,除了上面提到的String之外,常用的還有列舉型別及java.lang.Number的部分子類,如Long和Double等數值包裝型別、BigInteger和BigDecimal等大資料型別。但同為Number子型別的原子類AtomicInteger和AtomicLong則是可變的,讀者不妨看看這兩個原子類的原始碼,想一想為什麼它們要設計成可變的。
  1. 絕對執行緒安全
  • 絕對的執行緒安全能夠完全滿足Brian Goetz給出的執行緒安全的定義,這個定義其實是很嚴格的,一個類要達到“不管執行時環境如何,呼叫者都不需要任何額外的同步措施”可能需要付出非常高昂的,甚至不切實際的代價
  • 在Java API中標註自己是執行緒安全的類,大多數都不是絕對的執行緒安全。很明顯,儘管Vector的get()、remove()和size()方法都是同步的,但是在多執行緒的環境中,如果不在方法呼叫端做額外的同步措施,使用這段程式碼仍然是不安全的。因為如果另一個執行緒恰好在錯誤的時間裡刪除了一個元素,導致序號i已經不再可用,再用i訪問陣列就會丟擲一個ArrayIndexOutOfBoundsException異常。
  1. 相對執行緒安全
  • 相對執行緒安全就是我們通常意義上所講的執行緒安全,它需要保證對這個物件單次的操作是執行緒安全的,我們在呼叫的時候不需要進行額外的保障措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。
  • 在Java語言中,大部分聲稱執行緒安全的類都屬於這種型別,例如Vector、HashTable、Collections的synchronizedCollection()方法包裝的集合等。
  1. 執行緒相容
  • 執行緒相容是指物件本身並不是執行緒安全的,但是可以通過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用。
  • 我們平常說一個類不是執行緒安全的,通常就是指這種情況。
  • Java類庫API中大部分的類都是執行緒相容的,如與前面的Vector和HashTable相對應的集合類ArrayList和HashMap等。
  1. 執行緒對立
  • 執行緒對立是指不管呼叫端是否採取了同步措施,都無法在多執行緒環境中併發使用程式碼。
  • 由於Java語言天生就支援多執行緒的特性,執行緒對立這種排斥多執行緒的程式碼是很少出現的,而且通常都是有害的,應當儘量避免。
  • 一個執行緒對立的例子是Thread類的suspend()和resume()方法。如果有兩個執行緒同時持有一個執行緒物件,一個嘗試去中斷執行緒,一個嘗試去恢復執行緒,在併發進行的情況下,無論呼叫時是否進行了同步,目標執行緒都存在死鎖風險——假如suspend()中斷的執行緒就是即將要執行resume()的那個執行緒,那就肯定要產生死鎖了。也正是這個原因,suspend()和resume()方法都已經被宣告廢棄了。
  • 常見的執行緒對立的操作還有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等。

執行緒安全的實現方法

  1. 互斥同步
  • 互斥同步(Mutual Exclusion & Synchronization)是一種最常見也是最主要的併發正確性保障手段。
  • 同步是指在多個執行緒併發訪問共享資料時,保證共享資料在同一個時刻只被一條(或者是一些,當使用訊號量的時候)執行緒使用。
  • 互斥是實現同步的一種手段, 臨界區(Critical Section)、互斥量(Mutex)和訊號量(Semaphore)都是常見的互斥實現方式。
  • 在Java裡面,最基本的互斥同步手段就是synchronized關鍵字,這是一種塊結構(BlockStructured)的同步語法。synchronized關鍵字經過Javac編譯之後,會在同步塊的前後分別形成monitorenter和monitorexit這兩個位元組碼指令
  • 這兩個位元組碼指令都需要一個reference型別的引數來指明要鎖定和解鎖的物件。如果Java原始碼中的synchronized明確指定了物件引數,那就以這個物件的引用作為reference;如果沒有明確指定,那將根據synchronized修飾的方法型別(如例項方法或類方法),來決定是取程式碼所在的物件例項還是取型別對應的Class物件來作為執行緒要持有的鎖。
  • 根據《Java虛擬機器規範》的要求,在執行monitorenter指令時,首先要去嘗試獲取物件的鎖。如果這個物件沒被鎖定,或者當前執行緒已經持有了那個物件的鎖,就把鎖的計數器的值增加一,而在執行monitorexit指令時會將鎖計數器的值減一。一旦計數器的值為零,鎖隨即就被釋放了。如果獲取物件鎖失敗,那當前執行緒就應當被阻塞等待,直到請求鎖定的物件被持有它的執行緒釋放為止。
  • 被synchronized修飾的同步塊對同一條執行緒來說是可重入的。這意味著同一執行緒反覆進入同步塊也不會出現自己把自己鎖死的情況。
  • 被synchronized修飾的同步塊在持有鎖的執行緒執行完畢並釋放鎖之前,會無條件地阻塞後面其他執行緒的進入。這意味著無法像處理某些資料庫中的鎖那樣,強制已獲取鎖的執行緒釋放鎖;也無法強制正在等待鎖的執行緒中斷等待或超時退出。
  • 對於程式碼特別簡單的同步塊(譬如被synchronized修飾的getter()或setter()方法),狀態轉換消耗的時間甚至會比使用者程式碼本身執行的時間還要長。因此才說,synchronized是Java語言中一個重量級的操作,有經驗的程式設計師都只會在確實必要的情況下才使用這種操作。
  • 重入鎖(ReentrantLock)是Lock介面最常見的一種實現,顧名思義,它與synchronized一樣是可重入的。
  • 在基本用法上,ReentrantLock也與synchronized很相似,只是程式碼寫法上稍有區別而已。不過,ReentrantLock與synchronized相比增加了一些高階功能,主要有以下三項:等待可中斷、可實現公平鎖及鎖可以繫結多個條件
  • 可中斷特性對處理執行時間非常長的同步塊很有幫助。
  • synchronized中的鎖是非公平的,ReentrantLock在預設情況下也是非公平的,但可以通過帶布林值的建構函式要求使用公平鎖。不過一旦使用了公平鎖,將會導致ReentrantLock的效能急劇下降,會明顯影響吞吐量
  • 在synchronized中,鎖物件的wait()跟它的notify()或者notifyAll()方法配合可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外新增一個鎖;而ReentrantLock則無須這樣做,多次呼叫newCondition()方法即可。
  • 效能已經不再是選擇synchronized或者ReentrantLock的決定因素。
  • 基於以下理由,筆者仍然推薦在synchronized與ReentrantLock都可滿足需要時優先使用synchronized:
    a. synchronized是在Java語法層面的同步,足夠清晰,也足夠簡單。
    b. Lock應該確保在finally塊中釋放鎖,否則一旦受同步保護的程式碼塊中丟擲異常,則有可能永遠不會釋放持有的鎖。
    c. 從長遠來看,Java虛擬機器更容易針對synchronized來進行優化,因為Java虛擬機器可以線上程和物件的後設資料中記錄synchronized中鎖的相關資訊,而使用J.U.C中的Lock的話,Java虛擬機器是很難得知具體哪些鎖物件是由特定執行緒所持有的。
  • 互斥同步面臨的主要問題是進行執行緒阻塞和喚醒所帶來的效能開銷,因此這種同步也被稱為阻塞同步(Blocking Synchronization)。從解決問題的方式上看,互斥同步屬於一種悲觀的併發策略
  1. 非阻塞同步
  • 隨著硬體指令集的發展,我們已經有了另外一個選擇:基於衝突檢測的樂觀併發策略
  • 這種樂觀併發策略的實現不再需要把執行緒阻塞掛起,因此這種同步操作被稱為非阻塞同步(Non-Blocking Synchronization),使用這種措施的程式碼也常被稱為無鎖(Lock-Free)程式設計
  • 硬體保證某些從語義上看起來需要多次操作的行為可以只通過一條處理器指令就能完成,這類指令常用的有:
    a. 測試並設定(Test-and-Set);
    b. 獲取並增加(Fetch-and-Increment);
    c. 交換(Swap);
    d. 比較並交換(Compare-and-Swap,下文稱CAS);
    e. 載入連結/條件儲存(Load-Linked/Store-Conditional,下文稱LL/SC)。
  • 在JDK 5之後,Java類庫中才開始使用CAS操作,該操作由sun.misc.Unsafe類裡面的compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供。HotSpot虛擬機器在內部對這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器CAS指令,沒有方法呼叫的過程, 或者可以認為是無條件內聯進去了。 這種被虛擬機器特殊處理的方法稱為固有函式(Intrinsics)優化,類似的固有函式還有Math類的一系列算數計算函式、Object的建構函式等,目前已有數百個
  • 直到JDK 9之後,Java類庫才在VarHandle類裡開放了面向使用者程式使用的CAS操作。
  • J.U.C包為了解決ABA問題,提供了一個帶有標記的原子引用類AtomicStampedReference,它可以通過控制變數值的版本來保證CAS的正確性。不過目前來說這個類處於相當雞肋的位置,大部分情況下ABA問題不會影響程式併發的正確性,如果需要解決ABA問題,改用傳統的互斥同步可能會比原子類更為高效。
  1. 無同步方案
  • 同步只是保障存在共享資料爭用時正確性的手段,如果能讓一個方法本來就不涉及共享資料,那它自然就不需要任何同步措施去保證其正確性,因此會有一些程式碼天生就是執行緒安全的
  • 可重入程式碼(Reentrant Code):這種程式碼又稱純程式碼(Pure Code),是指可以在程式碼執行的任何時刻中斷它,轉而去執行另外一段程式碼(包括遞迴呼叫它本身),而在控制權返回後,原來的程式不會出現任何錯誤,也不會對結果有所影響。
  • 可重入程式碼有一些共同的特徵,例如,不依賴全域性變數、儲存在堆上的資料和公用的系統資源,用到的狀態量都由引數中傳入,不呼叫非可重入的方法等。
  • 執行緒本地儲存(Thread Local Storage):如果一段程式碼中所需要的資料必須與其他程式碼共享,那就看看這些共享資料的程式碼是否能保證在同一個執行緒中執行
  • 很多Web服務端應用都可以使用執行緒本地儲存來解決執行緒安全問題。
  • Java語言中,如果一個變數要被多執行緒訪問,可以使用volatile關鍵字將它宣告為“易變的”;如果一個變數只要被某個執行緒獨享,Java中就沒有類似C++中__declspec(thread)這樣的關鍵字去修飾,不過我們還是可以通過java.lang.ThreadLocal類來實現執行緒本地儲存的功能。

相關文章