Java之美[從菜鳥到高手演變]之執行緒同步的引入

dawn009發表於2015-04-21

從上一章(Java之美[從菜鳥到高手演變]之多執行緒簡介) 中,我們瞭解了關於多執行緒開發的一些概念,本章我們將透過具體事例引入執行緒同步問題,後續會不斷的提出執行緒同步的方法。我們知道,採用多執行緒可以合理利用 CPU的空閒資源,從而在不增加硬體的情況下,提高程式的效能!聽上去很有誘惑力,可是為什麼我們的專案不都採用多執行緒開發呢?原因如下:

1、多執行緒開發會帶來執行緒安全問題。多個執行緒同時對一個物件進行讀寫操作,必然會帶來資料不一致的問題。2、在單核的情況下,經過了執行緒同步的多線 程應用,未必比單執行緒應用效能要高,因為維護多執行緒所耗的資源並不少。(現在的單核環境已經不多了,不過此處為了說明並不是所有地方都用多執行緒好)。3、 編寫正確的多執行緒應用非常不易。4、只有在需要資源共享的情況下,才會用到多執行緒。想要解決第一個問題,我們需要用到執行緒同步,這也是做多執行緒開發的最難 的一點!本章我將介紹一些執行緒安全的問題,逐步引入執行緒同步的方法。

在閱讀過程中有任何問題,請及時聯絡:egg。

郵箱:xtfggef@gmail.com 微博:

轉載請說明出處:http://blog.csdn.net/zhangerqing

我們來看個小例子:

  1. public class Generator {  
  2.   
  3.     private int value = 1;  
  4.       
  5.     public int getValue(){  
  6.         return value++;  
  7.     }  
  8. }  

getValue方法的目的是每次呼叫,生成不同的值,但是我們來看看這種情況:如果現在又多個執行緒同時呼叫,會發生什麼?我們假設有兩個線 程:A、B。對於value++來說,相當於value=value+1,過程分為三步:1、獲得value的值。2、value的值加1。3、給 value賦值。如果現在A執行緒在進行完第一步後,CPU將時間片分給B執行緒,那麼B執行緒就會和A執行緒取得同樣的值,這樣的話,最後的結果很可能二者獲得 相同的值,很明顯與我們想要的結果不符。為什麼會造成這樣的結果,因為在沒有同步的情況下,編譯器、硬體、執行時事實上對時間和活動順序是很隨意的。如何 才能解決這個問題,這就是我們今天要討論的問題:上鎖!此處最簡單的處理方法是在getValue方法上加synchronized關鍵字,變為:

  1. public class Generator {  
  2.   
  3.     private int value = 1;  
  4.       
  5.     public synchronized int getValue(){  
  6.         return value++;  
  7.     }  
  8. }  

該類就是現程安全的了。具體為什麼,我們後面的內容會放出,此處只為了引出執行緒安全問題。看完這個例子,我們再來重新理解下執行緒安全問題,一般情況下,如果一個物件的狀態是可變的,同時它又是共享的(即 至少可被多於一個執行緒同時訪問),則它存線上程安全問題,總結來說:無論何時,只要有多於一個的執行緒訪問給定的狀態變數,而且其中某個執行緒會寫入該變數, 此時必須使用同步來協調執行緒對該變數的訪問。(如果所有執行緒都是讀取,不涉及寫入,那麼也就無需擔心執行緒安全問題)有時我們存在僥倖心理:自己寫的程式也 沒有按照上面的原理來實現同步,可是依然執行的好好的!不過,這種想法或者習慣是不好的,沒有進行同步的多執行緒程式(前提是需要同步)永遠都是不安全的, 也許只是暫時沒有出問題而已,甚至可能幾年內都不可能出問題,但是,這是未知數,程式存在安全隱患,任何時刻都有可能breakdown!誰都不希望自己 的應用是這樣的吧?想排除隱患有以下三個方法:1、禁止跨執行緒訪問變數。2、使狀態變數為不可變。3、使用同步。(前兩個方法實際就是放棄使用多執行緒,這 不符合我們的個性,我們需要解決問題,而非逃避問題)。相信說了這麼多,有不少讀者已經很急切的想知道:如此神秘的執行緒同步到底有哪些方法,下面我將一一 介紹。

執行緒同步的主要方法

原子性

大家應該還記得我們之前說過的value++那個小程式,此處的value++就是非原子操作,它是先取值、再加1、最後賦值的一種機制,是一種“讀-寫-改”的操作,原子操作需要保證,在對物件進行修改的過程中,物件的狀態不能被改變!這個現象我們用一個名詞:競爭條件來 描述。換句話說,當計算結果的正確性依賴於執行時中相關的時序或者多執行緒的交替時,會產生競爭條件。(即想得到正確的答案,要依賴於一定的運氣。正如 value++中的情況,如果我的運氣足夠好,在對value進行操作時,無其它任何執行緒同時對其操作)正如《JAVA CONCURRENCY IN PRACTICE》一書中所述的例子:你打算中午12點到學習附近的星巴克見一個朋友,當你到達後,發現這裡有兩個星巴克,而你不確定和朋友約了哪 個,12:10的時候,你在星巴克A依然沒有見到你的朋友,於是你向B走去,到了發現他也不在星巴克B,此時有幾種可能:你的朋友遲到了,沒有到任何一 個;你的朋友在你離開後到達了A;你的朋友先到了B,在你去B找他的時候,他卻來了A找你;不妨我們假設一種最糟糕的情況:你們就這麼來來回回走了很多 趟,依然沒有發現對方,因為你們沒有做好約定!這個例子就是說,當你期望改變系統狀態時(你去B找你的朋友),系統狀態可能已經改變(你的朋友也正從B走 來,而你卻不知)。這個例子闡釋清楚了引發競爭條件的真正原因:為了獲取期望的結果(去B找到朋友),需要依賴相關事件的分時(朋友在B等待,直到你的出 現)。這種競爭條件被稱作:檢查再執行(check-then-act):你觀察的事情為真(你的朋友不在星巴克A),你會基於你的觀察執行一些動作(去 B找你的朋友),不料,在你從觀察到執行動作的時候,之前的觀察結果已無效(你的朋友可能已經出現在A或者正往A走)。這樣就回引發錯誤。此處讀者朋友們 可以閱讀我的一篇關於設計模式文章的介紹,裡面說到單例模式時,有這樣的一段程式碼:

  1. public static Singleton getInstance() {  
  2.     if (instance == null) {  
  3.         instance = new Singleton();  
  4.     }  
  5.     return instance;  
  6. }  
和之前的value++類似,有可能兩個執行緒同時檢測到instance為null,CPU透過切換時間片來執行兩條執行緒,結果最後返回了兩個不同的例項,這是我們不想看到的結果。我們還來看個value++這個例子,稍作修改:
  1. public class Generator {  
  2.   
  3.     private long value = 1;  
  4.       
  5.     public void getValue(){  
  6.         value++;  
  7.     }  
  8. }  
我們如何透過原子變數,將其轉為執行緒安全的呢?在java.util.concurrent.atomic包下有一些將數字和物件引用進行原始狀態轉換的類,我們改改這個程式:
  1. public class Generator {  
  2.   
  3.     private final AtomicLong value = new AtomicLong(0);  
  4.       
  5.     public void getValue(){  
  6.         value.incrementAndGet();  
  7.     }  
  8. }  
這樣這個類就是執行緒安全的了。此處我們透過原子變數來解決,之前我們使用synchronized關鍵字來解決的,兩個方法都行。

加鎖

內部鎖(synchronized)

Java提供了完善的內建鎖機制:synchronized塊。在方法前synchronized關鍵字或者在方法中加synchronized語句塊,鎖住的都是方法中包含的物件,如果執行緒想獲得所,那麼就需要進入有synchronized關鍵字修飾的方法或塊。如果大家讀過我前面的一篇博文關於HashMap的(http://blog.csdn.net/zhangerqing/article/details/8193118),裡面有關於synchronized鎖住物件的分析, 採用synchronized有時會帶來一定的效能下降。但是,無疑synchronized是最簡單實用的同步機制,基本可以滿足日常需求。內部鎖扮演 了互斥鎖(即mutex)的角色,意味著同一時刻至多隻能有一個執行緒可以擁有鎖,當執行緒A想去請求一個被執行緒B佔用的鎖時,必然會發生阻塞,知道B釋放該 鎖,如果B永不釋放鎖,A將一直等待下去。這種機制是一種基於呼叫的機制(每呼叫,即per-invocation),就是說不管哪個執行緒,如果呼叫宣告 為synchronized的方法,就可獲得鎖(前提是鎖未被佔用)。還有另一種機制,是基於每執行緒的(per-thread),就是我們下面要介紹的重 進入——Reentrancy。

重進入(Reentrancy)

重進入是一種基於per-thread的機制,並不是一種獨立的同步方法 。基本實現是這樣的:每個鎖關聯一個請求計數器和一個佔有它的執行緒,當計數器為0時,鎖是未被佔有的,執行緒請求時,JVM將記錄鎖的佔有者,並將計數器增 1,當同一執行緒再次請求這個鎖時,計數器遞增;執行緒退出時,計數器減1,直到計數器為0時,鎖被釋放。

可見性和過期資料

可見性,可以說是一種原始概念,並不是一種單獨的同步方法,就是說,同步可以實現資料的可見性,和避免過期資料的出現。如之前我們講的星巴克的例 子,當我從星巴克A離開去B找朋友的時候,我並不知道朋友及星巴克A發生了什麼,這就是不可見的,反過來講,如果我能清楚的知道:在我去B之前,朋友絕對 不會離開B,(也就是說,我對整個狀態一清二楚)(事實上,這需要提前約定好),這就是可見的了,因此也不會發生其他問題,朋友會在B一直等我,直到我的 出現!再舉一個例子,如兩個執行緒A和B,A寫資料data,B讀取資料data,某一個時刻二者同時得到data,在A提交寫之前,B已經讀取,這樣就回 造成B所讀取的資料不是最新的,是過期的,這就是過期資料,過期資料會對程式造成不好的影響。關於可見性方面,同步機制看下面的Volatile變數。

顯示鎖

如果大家還記得ConcurrentHashMap,那麼理解顯示鎖就比較容易了,顧名思義,顯示鎖表面意思就是現實的呼叫鎖,且釋放鎖。它提供了 與synchronized基本一致的機制。但是有synchronized不能達到的效果,如:定時鎖的等待、可中斷鎖的等待、公平性、及實現非塊結構 的鎖。但是為什麼還用synchronized呢?其實,用顯示鎖會比較複雜,且容易出錯,如下面的程式碼:

  1. Lock lock = new ReentrantLock();  
  2. ...  
  3. lock.lock();  
  4. try{  
  5.      ...  
  6. }finally{  
  7.      lock.unlock();  
  8. }  

當我們忘記在finally裡釋放鎖(這種機率很大,而且很難察覺),那麼我們的程式將陷入困境。而是用內部鎖synchronized簡單方便,無需顧忌太多,所以,這就是為什麼synchronized依然用的人很多,依然是很多時候執行緒同步的首選!

讀寫鎖

有的時候,資料是需要被頻繁讀取的,但不排除偶爾的寫入,我們只要保證:在讀取執行緒讀取資料的時候,能夠讀到最新的資料就不會問題。此時符合讀-寫鎖的特點:一個資源能夠被多個執行緒讀取,或者一個執行緒寫入,二者不同時進行。這種特點,在特定的情況下有很好的效能!

Volatile變數

這是一種輕量級的同步機制,和前面說的可見性有很大關係,可以說,volatile變數,可以保證變數資料的可見性。在Java中設定變數值的操 作,對於變數值的簡單讀寫操作沒有必要進行同步,都是原子操作。只有long和double型別的變數是非原子操作的。JVM將二者(long和 double都是64位的)的讀寫劃分為兩個32位的操作,這樣就有可能就會不安全,只有宣告為volatile,才會使得64位的long和 double成為現場安全的。當一個變數宣告為volatile型別後,編譯器會對其進行監控,保證其不會與其它記憶體操作一起被重排序(重排序:舉個例 子,num=num+1;flag=true;JVM在執行這兩條語句時,不一定先執行num=num+1,也許在num+1賦值給num之前,flag 就已經為true了,這就是一種重排序),同時,volatile變數不會被進行快取,所以,每當讀取volatile變數時,總能得到最新的值!為什麼 會這樣?我們來看下面這段話:在當前的Java記憶體模型下,執行緒可以把變數儲存在本地記憶體(比如機器的暫存器)中,而不是直接在主存中進 行讀寫。這就可能造成一個執行緒在主存中修改了一個變數的值,而另外一個執行緒還繼續使用它在暫存器中的變數值的複製,造成資料的不一致。要解決這個問題,只 有把該變數宣告為volatile,這就指示JVM,這個變數是不穩定的,每次使用它都到主存中進行讀取。而且,當成員變數發生變化時,強迫執行緒將變化值 回寫到共享記憶體。這樣在任何時刻,兩個不同的執行緒總是看到某個成員變數的同一個值。Java語言規範中指出:為了獲得最佳速度,允許執行緒儲存共享成員變數 的私有複製,而且只當執行緒進入或者離開同步程式碼塊時才與共享成員變數的原始值對比。這樣當多個執行緒同時與某個物件互動時,就必須要注意到要讓執行緒及時的得 到共享成員變數的變化。而volatile關鍵字就是提示JVM:對於這個成員變數不能儲存它的私有複製,而應直接與共享成員變數互動。

此處注意:volatile關鍵字只能保證執行緒的可見性,但不能保證原子性,試圖用volatile保證原子性會很複雜!

一般情況,volatile關鍵字用於修飾一些變數,如:被當做完成標識、中斷、狀態等。滿足一下三個條件的情況,比較符合volatile的使用情景:

1、寫入變數時並不依賴變數的當前值(否則就和value++類似了),或者能夠確保只有單一執行緒修改變數的值。

2、變數不需要與其他的狀態變數共同參與不變約束。

3、訪問變數時,沒有其它原因需要加鎖。(畢竟加鎖是個耗效能的操作)

使用建議:在兩個或者更多的執行緒訪問的成員變數上使用volatile。當要訪問的變數已在synchronized程式碼塊中,或者為常量時,不必使用。由於使用volatile遮蔽掉了JVM中必要的程式碼最佳化,所以在效率上比較低,因此一定在必要時才使用此關鍵字。

Semaphore(訊號量)

訊號量的意思就是設定一個最大值,來控制有限個物件同時對資源進行訪問。因為有的時候有些資源並不是只能由一個執行緒同時訪問的,舉個例子,我這兒有 5個碗,只能滿足5個人同時用餐,那麼我可以設定一個最大值5,執行緒訪問時,用acquire() 獲取一個許可,如果沒有就等待,用完時用release() 釋放一個許可。這樣就保證了最多5個人同時用餐,不會造成安全問題,這是一種很簡單的同步機制。

臨界區

如果有多個執行緒試圖同時訪問臨界區,那麼在有一個執行緒進入後,其他所有試圖訪問此臨界區的執行緒將被掛起,並一直持續到進入臨界區的執行緒離開。臨界區 在被釋放後,其他執行緒可以繼續搶佔,並以此達到用原子方式操作共享資源的目的。在使用臨界區時,一般不允許其執行時間過長,只要進入臨界區的執行緒還沒有離 開,其他所有試圖進入此臨界區的執行緒都會被掛起而進入到等待狀態,並會在一定程度上影響程式的執行效能。尤其需要注意的是不要將等待使用者輸入或是其他一些 外界干預的操作包含到臨界區。如果進入了臨界區卻一直沒有釋放,同樣也會引起其他執行緒的長時間等待。

同步容器

Java為我們提供非常完整的執行緒同步機制,這包括jdk1.5後新增的java.util.concurrent包,裡面包含各種各樣出色的執行緒安全的容器(即集合類)。如ConcurrentHashMap,CopyOnWriteArrayList、LinkedBlockingDeque等,這些容器有的在效能非常出色,也是值得我們程式設計師慶幸的事兒!

Collections位集合類提供執行緒安全的支援

對於有些非執行緒安全的集合類,如HashMap,我們可以透過Collections的一些方法,使得HashMap變為執行緒安全的類,如:Collections.synchronizedMap(new HashMap());

excutor框架

Java中excutor只是一個介面,但它為一個強大的同步框架做好了基礎,其實現可以用於非同步任務執行,支援很多不同型別的任務執行策略。excutor框架適用於生產者-消費者模式,是一個非常成熟的框架,此處不多講,在後續的文章中,我會細細分析它!

事件驅動

事件驅動的意思就是一件事情辦完後,喚醒其它執行緒去幹另一件。這樣就保證:1、資料可見性。在A執行緒執行的時候,B執行緒處於睡眠狀態,不可能對共享 變數進行修改。2、互斥性。相當於上鎖,不會有其它執行緒干擾。常用的方法有:sleep()、wait()、notify()等等。

以上這些就是一些執行緒同步的方法,此處我沒有詳細的介紹,是希望將詳細的分析留到後面,作專題。上一章和本章都是以基本概念為主,先帶領大家從理論 的層面瞭解多執行緒開發,慢慢地我們會進入到實踐環節,從下一章開始,將較為詳細的分析各種多執行緒同步的方法,以及在進行多執行緒程式設計時需要注意的問題。筆者 真心期望各位讀者能提出建議,積極補充!我們一起討論,共同進步!

本文參考:

《JAVA CONCURRENCY IN PRACTICE》 Brian Goetz 著


出處:http://blog.csdn.net/zhangerqing/article/details/8284609

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/29119536/viewspace-1589658/,如需轉載,請註明出處,否則將追究法律責任。

相關文章