1. 程式人生 > >學習筆記之Java執行緒安全雜談(上)——執行緒安全概念和基本方案

學習筆記之Java執行緒安全雜談(上)——執行緒安全概念和基本方案

執行緒安全問題絕對是併發開發中一個重點中的重點,這篇就來說說執行緒安全相關的一些問題。執行緒安全是什麼概念?這個概念說簡單也簡單,說複雜也複雜,“安全”的概念是什麼,用我個人的話說的淺顯些,就是類/物件本身在多執行緒併發執行的場景下,能夠保證程式的邏輯是可以接受的而不是被擾亂的,保證業務處理不出問題,這個定義並不標準,但執行緒安全的實際概念確實很難簡單而又準確地表達,我們從下面的故事說起。

我們前面也提到了一些“主執行緒”“新執行緒”,當然我們也會用到執行緒池裡的執行緒來完成任務。那麼,如果這些任務都“各司其職”“互不干涉”,那自然是一切安好。但這個條件貌似太苛刻了,在很多場景下,為了更好完成很多工,各個執行緒必然需要相互合作,共享一些資料,比如一個Java程式執行,在堆記憶體中有一塊物件O,當前執行緒P建立了這個物件並持有這個物件的引用,這時候我們需要充分利用CPU資源開幾個額外的執行緒來處理任務,並把物件O傳給他們,比如有兩個執行緒A和B。那現在線上程P,執行緒A和B中都持有堆中唯一物件O的引用,也就都可以對O進行操作。那是不是這就會有問題了呢?也不一定,如果物件O的引用只在這3個執行緒中持有,而且這三個執行緒對物件O的操作都是隻讀的,也就是說O自從建立起就沒在變過,那麼可以想象也不會有什麼問題存在。但如果有執行緒會改變物件O,問題就不這麼簡單了。

1. 問題所在。繼續說上面的故事,讓我們更具體化一些,物件O中有一個A和B執行緒都可見的int變數a,當前初始值是0。現在A和B同時都嘗試對O.a進行加1的操作,A執行緒去讀得到0,於是加1變成1,但還沒來得急寫回去,B也做了同樣的操作也以為應該把1寫回去。那麼A和B執行緒把0寫成1這個過程是對的麼?如果P執行緒建立A和B執行緒的目的就是最終讓0變成1,那麼沒問題,但如果我們的目的是讓A和B分別幫助物件O“成長”,讓a成為A執行緒和B執行緒某操作次數的記錄,那顯然,向上述的過程,如果A和B都把1寫會去,a就漏掉了一次。這是一個“read-and-update”的過程(也有的叫做“read-modify-write

”)。

剛剛那個例子是讀取然後就進行更新寫回,現在看另外一個情況。前面描述的共享物件O除了上面情況下用到的屬性a,現在還有一個java.lang.Object型別的屬性b,初始情況下b的引用為null。現在的需求是對b做Lazy Init,第一個訪問到b的執行緒負責建立和初始化,而後續不允許再發生變化,這個和單例模式物件的實現有類似之處。簡單來說就是如下的程式碼:

1 2 3 4 if ( O.b == null ) { O.b = new Object(); }

和上面一樣,現在A和B共享物件O,並同時使用到b屬性,會不會有問題?按照剛剛描述的需求必然會,因為破壞了第二個要求引用“不允許在發生變化”。當A先發現屬性b為空的時候,建立一個物件Object1給b,而建立這個物件是需要一系列操作的,還沒有建立完成b引用依舊為null的時候,B執行緒來了,於是也進入了if程式碼塊,又建立了一個物件Object2,則b會先被賦予Object1,而後Object2 。這個例子中Object物件的建立僅僅是一個代表,其實實際的操作可以更復雜,操作的結果會使if的條件不成立。這又是一類情況,叫“check then act”

以上兩個例子是我寫這篇整理的時候臨時編出來的,不知道是否完全合適,但read and update和check then act卻囊括了執行緒安全問題中的很多競爭情況。

2. 解決簡單問題的一個方案——保證原子性。從上面的情況可以看到,之所以出現了問題是因為read和update之間以及check和act的操作持續足夠久,而且中間允許別的執行緒插進來進行操作,也就是可以理解為我們做了一系列操作,每個操作之間給別的執行緒留有餘地。那麼如果我們保證這一系列操作“足夠快”“足夠小”,那麼就不會給別的執行緒可入之機。那一個方案就是,操作的原子化。JavaSE5之後,java.util.concurrent.atomic包中給出了豐富的原子資料類,提供了原子保證的操作方法。在Sun JDK中,這些都是基於sun.misc.Unsafe類實現的。需要注意的是,是沒有對應於Float和Double的原子類的。

3. 對於略為複雜的情況,原子類並不能解決問題,這時候往往需要鎖來解決。所謂“複雜”的情況有很多,較為常見的一種情況就是,如果一個操作需要涉及到兩個變數,而這兩個變數又不能同時被一個原子類保證,那我們所能做的恐怕就只有考慮加鎖使用。加鎖保證了一系列操作的原子性,保證了被鎖物件的“不可見性”和順序性,關於這三個特性更多的討論,我們放在後邊。

4. 到鎖為止,上面的一個故事基本上算是講完了,但實際情況中考慮的問題可能不僅僅是這麼簡單。我們現在回頭看下執行緒安全的更準確描述和設計中的考慮。《Java Concurrency in Practice》書中給了一個描述更為準確也比較好理解的定義,就是“如果一個類在多執行緒執行中,在不考慮執行環境的排程干預,也不需要呼叫程式碼的協調同步,仍然保證正確地執行,那麼這個類就是執行緒安全的”。按照這個定義,這本書的作者Brian Goetz在其文章中又進一步把執行緒安全分了5個級別:

  • 不可變的,這個沒什麼可說的
  • 絕對安全/無條件的執行緒安全,通常來講這個類“怎麼用怎麼安全”,但我加了雙引號,其實還是要注意的
  • 相對安全/有條件的,方法單個使用是沒問題的,但為了處理某些業務按照順序連續呼叫需要同步
  • 執行緒相容/非執行緒安全,需要外部同步保證
  • 執行緒對立,沒法協調做到安全

更具體的,要看原文描述了。IBM developerworks上翻譯成中文的文章:

其它關於執行緒安全方面在設計實踐中需要的思考我簡要整理了下:

  • 對於一個類,能做到不可變的就做到不可變,能做到不被其它類引用到就不公開出來
  • 能只讀的就只讀,保證安全
  • 需要被公開使用的要注意被公開場景物件狀態的正確性,還要保證同時其它物件的公開性,尤其在使用容器類的時候
  • 能夠用臨時變數做操作的就用方法內的臨時變數,而不用類的屬性,保證是執行緒記憶體在的,用棧中資料而非堆中共享的,保證類的無狀態性
  • 用好ThreadLocal類
  • 做好邏輯上的需求分析。通常需要考慮狀態轉換的問題,比如從狀態1不能直接到狀態3,必需經過狀態2;還有就是多狀態的約束,比如一個三位的二進位制狀態,不是8種全有,而只有101、110、111、000是合法的,那麼要維護好三個狀態位。同時,還要搞清楚狀態的所有者是誰,維護好狀態所有者
  • 用好原子類和鎖

……