從上一章(Java之美[從菜鳥到高手演變]之多線程簡介)中,我們瞭解了關於多線程開發的一些概念,本章我們將通過具體案例引入線程同步問題,後續會不斷的提出線程同步的方法。我們知道,採用多線程可以合理利用CPU的空閑資源,從而在不增加硬體的情況下,提高程式的效能!聽上去很有誘惑力,可是為什麼我們的項目不都採用多線程開發呢?原因如下:
1、多線程開發會帶來安全執行緒問題。多個線程同時對一個對象進行讀寫操作,必然會帶來資料不一致的問題。2、在單核的情況下,經過了線程同步的多線程應用,未必比單線程應用效能要高,因為維護多線程所耗的資源並不少。(現在的單核環境已經不多了,不過此處為了說明並不是所有地方都用多線程好)。3、編寫正確的多線程應用非常不易。4、只有在需要資源共用的情況下,才會用到多線程。想要解決第一個問題,我們需要用到線程同步,這也是做多線程開發的最難的一點!本章我將介紹一些安全執行緒的問題,逐步引入線程同步的方法。
在閱讀過程中有任何問題,請及時聯絡:egg。
郵箱:xtfggef@gmail.com 微博:http://weibo.com/xtfggef
轉載請說明出處:http://blog.csdn.net/zhangerqing
我們來看個小例子:
public class Generator {private int value = 1;public int getValue(){return value++;}}
getValue方法的目的是每次調用,產生不同的值,但是我們來看看這種情況:如果現在又多個線程同時調用,會發生什嗎?我們假設有兩個線程:A、B。對於value++來說,相當於value=value+1,過程分為三步:1、獲得value的值。2、value的值加1。3、給value賦值。如果現在A線程在進行完第一步後,CPU將時間片分給B線程,那麼B線程就會和A線程取得同樣的值,這樣的話,最後的結果很可能二者獲得相同的值,很明顯與我們想要的結果不符。為什麼會造成這樣的結果,因為在沒有同步的情況下,編譯器、硬體、運行時事實上對時間和活動順序是很隨意的。如何才能解決這個問題,這就是我們今天要討論的問題:上鎖!此處最簡單的處理方法是在getValue方法上加synchronized關鍵字,變為:
public class Generator {private int value = 1;public synchronized int getValue(){return value++;}}
該類就是現程安全的了。具體為什麼,我們後面的內容會放出,此處只為了引出安全執行緒問題。看完這個例子,我們再來重新理解下安全執行緒問題,一般情況下,如果一個對象的狀態是可變的,同時它又是共用的(即至少可被多於一個線程同時訪問),則它存線上程安全問題,總結來說:無論何時,只要有多於一個的線程訪問給定的狀態變數,而且其中某個線程會寫入該變數,此時必須使用同步來協調線程對該變數的訪問。(如果所有線程都是讀取,不涉及寫入,那麼也就無需擔心安全執行緒問題)有時我們存在僥倖心理:自己寫的程式也沒有按照上面的原理來實現同步,可是依然啟動並執行好好的!不過,這種想法或者習慣是不好的,沒有進行同步的多線程程式(前提是需要同步)永遠都是不安全的,也許只是暫時沒有出問題而已,甚至可能幾年內都不可能出問題,但是,這是未知數,程式存在安全隱患,任何時刻都有可能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走)。這樣就回引發錯誤。此處讀者朋友們可以閱讀我的一篇關於設計模式文章的介紹,裡面說到單例模式時,有這樣的一段代碼:
public static Singleton getInstance() {if (instance == null) {instance = new Singleton();}return instance;}
和之前的value++類似,有可能兩個線程同時檢測到instance為null,CPU通過切換時間片來執行兩條線程,結果最後返回了兩個不同的執行個體,這是我們不想看到的結果。我們還來看個value++這個例子,稍作修改:
public class Generator {private long value = 1;public void getValue(){value++;}}
我們如何通過原子變數,將其轉為安全執行緒的呢?在java.util.concurrent.atomic包下有一些將數字和對象引用進行原始狀態轉換的類,我們改改這個程式:
public class Generator {private final AtomicLong value = new AtomicLong(0);public void getValue(){value.incrementAndGet();}}
這樣這個類就是安全執行緒的了。此處我們通過原子變數來解決,之前我們使用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呢?其實,用顯示鎖會比較複雜,且容易出錯,如下面的代碼:
Lock lock = new ReentrantLock();...lock.lock();try{ ...}finally{ lock.unlock();}
當我們忘記在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 著
持續更新中...