【轉】http://zoroeye.iteye.com/blog/2017853html
1.線程安全性
1.1 什麼是線程安全性
在構建穩健的併發程序時,必須正確的使用線程和鎖。要編寫線程安全的代碼,其核心在於要對狀態訪問操做進行管理,特別是對共享的(Shared)且可變的(Mutable)狀態的訪問(也就是破壞其中任一個條件均可以保證線程安全,非共享或不可變的狀態都不存在線程安全問題)。
「共享」意味着變量能夠由多個線程同時訪問,而「可變」則意味着變量的值在其生命週期內能夠發生變化。前一篇《基礎-2 構建線程安全應用程序》提到過,final且 在構造函數完成以後才使用的變量是不會引發併發問題的,由於final不具備可變性。關於爲何要構造函數以後使用才安全後面會提到,由於this逃逸, 也就是指在構造函數返回以前其餘線程就持有該對象的引用。 調用還沒有構造徹底的對象的方法可能引起使人疑惑的錯誤, 所以應該避免this逃逸的發生。this逃逸常常發生在構造函數中啓動線程或註冊監聽器時,因此不要在構造函數裏調用start啓動線程。
從非正式的意義上說,對象的「狀態」是指存儲在狀態變量(如實例或靜態域)中的數據。對象的狀態可能包括其餘 依賴對象的域。例如,某個HashMap的狀態不只存儲在HashMap對象自己,還存儲在許多Map.Entry對象中。在對象的狀態中包含了任何可能 影響其外部可見行爲的數據。若是某個類是無狀態的,也就是不包含任何域,也不包含任何對其餘類中域的引用,全部臨時狀態都僅存在於線程棧上的局部變量中, 那這個類確定是線程安全的。無狀態對象確定是線程安全的。
一句話總結:要注意共享且可變的狀態的線程安全問題。
當多個線程訪問某個狀態變量而且其中有一個線程執行寫入操做時,必須採用同步機制來協同這些線程對變量的訪問。Java中的主要同步機制是關鍵字synchronized,它提供了一種獨佔的加鎖方式,但「同步」術語還包括volatile類型的變量,顯示鎖(Explicit Lock)以及原子變量。
若是多個線程訪問同一個可變的狀態變量時沒有使用合適的同步,那麼程序就會出現錯誤。有三種方式能夠修復這個問題:
1) 不在線程之間共享該狀態變量。
2) 將狀態變量修改成不可變的變量。
3) 在訪問狀態變量時使用同步。
一句話總結:想要併發安全,要麼破壞共享且可變的條件,要麼使用同步(,synchronized,volatile,lock,atomic variable)來處理。
若是在設計類的時候沒有考慮併發訪問的狀況,那麼在採用上述方法時可能須要對設計進行重大修改,所以要修復這個問題可謂是知易行難。若是從一開始就設計一個線程安全的類,那麼比在之後再將這個類改成線程安全的類要容易的多。
當設計線程安全的類時,良好的面向對象技術(好比封裝狀態變量在類內部),不可修改性,以及明晰的不變性規範(不變性條件:狀態變量之間的約束關 系,好比這兩個變量 int[] data; int size; 之間的關係應該是,data中的數據數目=size。當類的不變性條件設計多個狀態變量時,那麼不變性條件中的每一個變量都必須由同一個鎖來保護。所以能夠 在單個原子操做中訪問或更新這些變量,從而確保不變性條件不被破壞。)都能起到必定的幫助做用。在某些狀況中,良好的面向對象設計技術與實際狀況的需求並 不一致,這時,可能須要犧牲一些良好的設計原則,以換取性能或者對遺留代碼的向後兼容。實際作設計的時候,不少狀況是須要一些妥協的,就像犧牲空間換時間 犧牲時間換空間同樣,設計原則也是同樣。有時候,面向對象的抽象和封裝會下降程序的性能,可是編寫併發應用程序時,一種正確的編程方法是:首先使代碼正確運行,而後再提升性能。 即使如此,最好也是當性能測試結果和應用需求告訴你必須提升性能,以及測量結果代表這種優化在實際環境確實帶來性能提高時,才進行優化。(在編寫併發代碼 時,應該始終遵循這個原則,因爲併發錯誤是很是難以重現和調試的,所以若是隻是在某段不多執行的代碼路徑上得到了性能提高,極可能被程序運行時存在的失敗 風險而抵消)。
目前,咱們看到了「線程安全類」和「線程安全程序」兩個術語,兩者的含義基本相同,但不能混淆。咱們最終的目的是構建「線程安全程序」。但線程安 全的程序並不徹底由線程安全類構成,徹底由線程安全類構成的程序並不必定是線程安全的,HashTable相關的複合操做就是例子。
前面《<基礎-2> 構建線程安全應用程序》裏討論了什麼是線程安全性。在線程安全性的定義中,最核心的概念就是正確性。當多個線程訪問某個類時,這個類始終都能表現出正確的行爲,那麼就稱這個類是線程安全的。
1.2 原子性
就像數據庫裏的定義同樣,原子性就是一個操做(多是須要多步完成的複合操做)不能被打斷,一旦開始執行直到執行完其餘線程或多核都必須等待。好比」i++」表達式,就不是原子的,彙編後會發現由三條指令(讀取,修改,寫入)完成,每一條指令完成後均可能被中斷。說到原子性,通常會提到可見性,這二者其實沒有任何聯繫,但這兩個因素確是同時影響到多線程安全的特性。只具有原子性或可見性並不能保證線程安全(注意synchronized同時保證了原子性和可見性,只保證原子性可能結果並無同步到主存,其餘線程不可見)。可見性跟jvm的內存結構有 關係,前面《<基礎-2> 構建線程安全應用程序》裏給出了jvm內存結構圖,各個線程或多核對同一個變量有備份(在線程的工做內存中或核的寄存器中,爲了節省IO通訊等),致使跟 jvm主存中的變量值不一致。這樣作的目的是爲了提升性能。固然在多線程中就可能形成問題,就要用同步來解決了。
還能夠參考:http://www.cnblogs.com/mengyan/archive/2012/08/22/2651575.html
1.2.1 競態條件(race condition)
在大多數實際的多線程應用中,兩個或兩個以上的線程須要共享對同一數據的存取。若是不加控制的任意存取確定會出現錯亂,最典型的例子就是銀行帳戶 轉帳。根據各線程訪問數據的次序,可能會產生錯誤的結果,這樣一個狀況一般稱爲race condition(中文有的翻譯爲競爭條件,這裏就用原文race condition, [b]A race condition is any case where the results can be different depending on the order that processes arrive or are scheduled or depending on the order that specific competing instructions are executed[/b])也就是說race condition一般跟複合操做有關係。
爲了不race condition,必須學習如何同步存取。
最多見的競態條件類型就是「先檢查後執行check-then-act」操做,即經過一個可能失效的觀測結果來決定下一步動做。使用「先檢查後執行」的一種常見狀況就是懶加載。好比:
java
這也是典型的單例類,單例模式有好多實現方式這裏不討論。併發判斷instance == null時可能另外一個線程已經建立一個實例了,但其餘線程沒有發現而致使不是單實例。後面咱們還會討論怎樣是安全的延遲加載單實例。
「讀取-修改-寫入」是另外一種典型的競態條件,好比i++。
1.2.2 複合操做
要避免race condition就必須在某個線程修改該變量時,經過某種方式阻止其餘線程使用這個變量,從而確保其餘線程只能在修改操做完成以前或以後讀取和修改狀態,而不是在修改狀態的過程當中。這種「先檢查後執行」或「讀取-修改-寫入」等操做都統稱爲複合操做:包含了一組必須以原子方式執行的操做。
這裏使用java.util.concurrent(JUC)提供的原子變量AtomicLong來實現線程安全:
程序員
這裏使用了count.incrementAndGet(),這是個原子操做,查看api能夠看到該方法的定義:
數據庫
無限循環,直到能夠線程安全的增加。有時候咱們使用其餘線程安全類但又沒提供這種機制的時候,本身也能夠這麼作。這裏面又有關鍵的兩個方法get和compareAndSet,get簡單的返回當前值,但這個當前值是volatile類型的,能獲取當前最新的值,compareAndSet就是根據預期值和新值來增加,若是增加成功返回true,不然返回false。
前面看到了「同步」方式包括volatile類型的變量,synchronized,顯示鎖(Explicit Lock)以及原子變量,解決可見性這4種方式均可以,但解決原子性只能使用後面3種,volatile只能解決可見性。後三種又如何選擇呢?(使用java.util.concurrent裏的原子類 > synchronized > 鎖),後面會詳細介紹,在此以前,咱們先討論清楚synchronized,lock是什麼怎麼用。
1.3 加鎖機制
若是一個類裏用到多個狀態(已經屢次說明對象的「狀態」是指存儲在狀態變量(如實例或靜態域)中的數據,必定要理解這個概念)變量,即便每一個狀態 變量分別都是原子的,放到一塊兒使用也不能保證總體的原子性。要保持狀態一致性,就須要在單個原子操做中(注意是一個同一個原子操做)更新全部有依賴關聯關 系的狀態變量。
從java se5.0開始,有兩種機制防止代碼塊受併發干擾(併發干擾主要是由於對共享數據的影響不是原子操做)。Java語言提供一個synchronized關鍵字達到這一目的,而且java se 5.0引入了ReentrantLock類。
1.3.1 顯式鎖 explicit lock
顯式鎖通常使用ReentrantLock。ReentrantLock實現了Lock接口,並提供了與synchronized相同的互斥性和內存可見性。用ReentrantLock保護代碼塊的基本結構以下:
編程
這一結構控制更加精細。確保任什麼時候刻只有一個線程進入臨界區。一旦一個線程封鎖了鎖對象,其餘任何線程都沒法經過lock語句,當其餘線程調用lock時,他們被阻塞,直到第一個線程釋放鎖對象。這一切的前提是多線程獲取的是同一個鎖對象纔會造成阻塞,不然互不影響。
1.3.2 鎖的可重入性
鎖的可重入就是指線程能夠重複得到已經持有的鎖。當某個線程請求一個由其餘線程持有的鎖時,發出請求的線程就會阻塞。然而因爲內置的鎖是可重入的,所以若是某個線程視圖得到一個已經由它本身持有的鎖,那麼這個請求就會成功。「重入」意味着獲取鎖的操做的粒度是「線程」,而不是調用。
鎖是可重入的,由於線程能夠重複得到已經持有的鎖。鎖保持一個持有計數(hold count)來跟蹤對lock方法的嵌套調用。線程在每一次調用lock都要調用unlock來釋放鎖。因爲這一特性,被一個鎖保護的代碼能夠調用另外一個 使用相同鎖的方法。可重入就是說,一個線程得到共享資源的鎖以後,能夠重複訪問須要該鎖的資源而不受鎖的限制。
重入進一步提高了加鎖行爲的封裝性,簡化了面向對象併發代碼的開發。
1.3.3 條件對象 Condition, await/sinalAll
一般,線程進入臨界區,卻發如今某一條件知足以後它才執行。要使用一個條件對象來管理那些已經得到了一個鎖卻不能作有用工做的線程。條件對象常常被稱爲條件變量(conditional variable)。前面說到的複合操做一般是在條件變量處發生,如先判斷再操做。
如今模擬銀行轉帳功能,咱們避免選擇沒有足夠資金的帳戶做爲轉出帳戶。注意不能使用下面這樣的代碼:
api
當前線程徹底有可能在(1)成功的完成測試以後且在調用transfer方法以前被中斷,(2)在線程再次運行前,帳戶餘額可能已經低於提款金額。必須保證沒有其餘線程在本檢查餘額與轉帳活動之間修改餘額。經過使用鎖來完成這一點:
緩存
如今,當帳戶中沒有足夠的餘額時,會等待(await)直到另外一個線程向帳戶中注入了資金。
一個鎖對象能夠有一個或多個相關的條件對象。能夠用newCondition()方法得到一個條件對象,習慣上給每個條件對象命名爲能夠反映它所表達的條件的名字。
例如:
安全
若是線程調用transfer時發現餘額不足,它調用sufficientFunds.await();當前線程如今被阻塞了,並放棄了鎖(這點很重要,調用Thread.sleep()方法休眠時不會放棄鎖)。
等待得到鎖的線程和調用await方法的線程存在本質上的不一樣。一旦一個線程調用await方法,它進入該條件的等待集。當鎖可用時,該線程不能 立刻解除阻塞,相反,它處於阻塞狀態,直到另外一個線程調用同一條件上的signalAll方法爲止。好比當另外一個線程轉帳時,它應該調用 sufficientFunds.signalAll();
這一調用從新激活由於這一條件而等待的全部線程。當這些線程從等待集中移出時,他們再次成爲可運行的,調度器再次激活他們。同時,他們將試圖從新 進入該對象。一旦鎖成爲可用的,他們中的某個將從await調用返回,得到該鎖並從被阻塞的地方繼續執行。此時,線程應該再次測試條件是否知足,因此 await方法的調用一般在循環體中:
網絡
相當重要的是最終須要某個其餘線程調用signalAll方法。當一個線程調用await時,他沒有辦法從新激活本身,只能寄但願於其餘線程,如 果沒有其餘線程調用signal或signalAll或中斷該線程,那將致使死鎖。經驗上講,應該在對象的狀態變化了,有利於等待線程的方向改變時調用 signalAll。
注意:signallAll不會當即激活一個等待線程。它僅僅解除等待線程的阻塞,以便這些線程能夠在當前線程退出同步方法後,再次經過競爭實現對對象的訪問。
1.3.4 內置鎖 synchronized
前面介紹了ReentrantLock和Condition,這向程序設計人員提供了高度的封鎖控制。但大多數狀況下,不須要這樣的控制,而且可使用一種嵌入到java語言內部的機制。java1.0開始,java中的每個對象都有一個內部鎖。
Java提供了一種內置的鎖機制來支持原子性:同步代碼塊(synchronized block)。同步代碼塊包括兩部分:一是對被當作鎖的對象的引用,也就是放在synchronized(obj){…}裏的obj; 二是由這個鎖保護的代碼塊。
每一個java對象均可以用作一個實現同步的鎖,這些鎖被稱爲內置鎖(Intrinsic Lock)或監視器鎖(Monitor Lock)(以前本人一直想弄明白內置鎖和監視器鎖的區別,後來查了不少才知道,二者是同樣的,只是別名而已)。線程在進入同步代碼塊以前會自動得到鎖, 而且退出同步代碼塊時自動釋放鎖,而不管是經過正常的控制路徑退出仍是經過從代碼塊中拋出異常退出。得到內置鎖的惟一途徑就是進入由這個鎖保護的同步代碼 塊或方法。Java的內置鎖至關於一種互斥體,這意味着最多隻有一個線程能持有這種鎖。
若是一個方法用synchronized關鍵字聲明,那麼對象的內部鎖將保護整個方法。換句話說:
數據結構
內部對象鎖只有一個相關條件(ReentrantLock能夠有一個或多個Condition),調用對象的wait/notifyAll(這兩 個是從Object對象繼承過來的方法)等價於intrinsicCondition.await()和 intrinsicCondition.signalAll()。
能夠看到,使用synchronized關鍵字來編寫代碼簡潔的多。固然,必須瞭解每個對象有一個內部鎖,而且該鎖有一個內部條件。由內部鎖來管理那些視圖進入synchronized方法的線程,由內部條件來管理那些調用wait的線程。
內部鎖和內部條件存在一些侷限,包括:
1) 不能中斷一個正在試圖得到鎖的線程。
2) 試圖得到鎖時不能設定超時。
3) 每一個鎖僅有單一的條件,多是不夠的。
那在實際中應該怎樣選擇,使用synchronized仍是Lock加Condition?下面是一些建議:
1)最好二者都不使用。在許多狀況下可使用java.util.concurrent(JUC)包中的某一種機制,它會爲你處理全部的鎖。JUC裏的機制後面會介紹。
2)若是synchronized關鍵字適合你的程序請儘可能使用,這樣能夠減小編寫的代碼數量,減小出錯的概率。synchronized有幾種使用方法,儘可能使用同步塊,其次是同步方法,總之是讓同步的範圍儘可能小。
3)若是特別須要Lock/Condition結構提供的獨有特性時,如中斷,超時,多個條件等,才使用Lock/Condition。
必定注意,
(1)notify/notifyAll/wait只能在同步方法或同步塊內部使用,且調用這幾個方法的對象要跟synchronized鎖住的對象是同一個對象,不然會拋出
也就是說synchronized(obj),那必定要是obj.wait()或obj.notifyAll(),若是鎖住的是類實例,那能夠直接調用wait()或notifyAll().
(2)wait要在循環裏調用,由於雖然再次得到了執行權仍要要再次檢查條件是否知足。
例子:
好比Servlet要實現因數分解的操做,使用synchronized同步整個因數分解的方法,這樣Servlet就是線程安全的。可是,這種 方法過於極端,客戶端沒法同時使用因數分解Servlet,服務的響應很是低,幾乎變成了單線程,synchronized的範圍太大了,若是分解須要很 長時間,那問題就很嚴重。又可是,至少這樣沒有線程安全問題,只是性能問題。後面會逐步講到更好的寫法,所以這仍然是不推薦的寫法。
關於synchronized用在變量,static變量,類上,能夠參考:
http://developer.51cto.com/art/201104/255305.htm
1.3.5 同步阻塞
有時程序員用一個對象的鎖來實現額外的原子操做。實際上稱爲客戶端鎖定(client-side locking),客戶端鎖定是脆弱的,不推薦使用。例如,Vector類,它的方法單獨都是同步的,如今假定Vector<Double> 裏存儲銀行餘額。若是轉帳方法transfer實現:
Vector類的get和set方法都是同步的,可是這對於咱們沒有什麼幫助,組合操做起來並非同步的。而後咱們能夠修改方法,使用鎖來同步:
這個方法能夠工做,可是它徹底依賴於一個事實,Vector類對本身的全部可修改方法都使用內部鎖。然而Vector類的文檔並無給出這樣的承 諾。可見,客戶端鎖定是很是脆弱的,不推薦使用,雖然實際中咱們這樣使用的不少。實際使用時,咱們應該先思考下有沒有其餘方式實現同步。
1.3.6 鎖測試和超時
線程在調用lock方法來得到另外一個線程所持有的鎖時,極可能發生阻塞,lock方法不能被中斷。若是一個線程在等待得到一個鎖時被中斷,中斷線 程在得到鎖以前一直處在阻塞狀態。若是出現死鎖,那麼lock方法就沒法終止。因此應該更加謹慎的申請鎖。tryLock方法試圖申請一個鎖,在成功得到 鎖後返回true,不然當即返回false,並且線程能夠當即離開去作其餘事:
tryLock不管得到仍是沒得到鎖都會當即返回。還能夠帶上超時參數,阻塞時間不超過設定。這也是前面提到的顯示鎖ReentrantLock的優點。
1.3.7 讀寫鎖
Java.util.concurrent.locks包定義兩個鎖類。ReentrantLock和ReentrantReadWriteLock。若是不少線程從一個數據結構讀取數據而不多線程修改其中數據的話,後者十分有用。
下面是使用讀寫鎖的必要步驟:
1.3.8 volatile域
有時,僅僅爲了讀寫一個或兩個實例域就使用同步顯得開銷過大了。但若是不採起任何措施,出錯的可能性很大:
1)多處理器的計算機能暫時在寄存器或本地內存緩衝區保存內存中的值。結果是,運行在不一樣處理器上的線程可能在同一個內存位置取到不一樣的值。
2)編譯器可能改變指令執行的順序以使吞吐量最大化。這種順序上的變化不會改變代碼語義,可是編譯器假定內存的值僅僅在代碼中有顯式的修改指令纔會改變。然而,內存的值能夠被另外一個線程改變。
關於jvm的內存模型前面《基礎-2 構建線程安全應用程序》介紹過。
Brian Goetz給出了「同步格言」:若是向一個變量寫入值,而這個變量接下來可能被另外一個線程讀取,或者從一個變量讀值,而這個變量多是以前另外一個線程寫入的,此時必須使用同步。
volatile關鍵字爲實例域的同步訪問提供了一種免鎖機制。若是聲明一個域爲volatile,那麼編譯 器和虛擬機就知道該域是可能被另外一個線程併發更新的。會不在緩存中保存該值,各個線程看到的值都是最新修改的,解決了可見性的問題。但並不能保證原子性。 如volatile Boolean done; 使用時這樣使用:done = !done; 這是不能確保改變域中的值的,由於done也可能被其餘線程修改了。關於volatile的使用場景在《基礎-2 構建線程安全應用程序》介紹過了。此時,可使用AtomicBoolean解決。這個類的get和set方法是原子的,該實現使用有效的機器指令實現, 在不使用鎖的狀況下確保原子性,且十分高效。
總之,在如下3個條件之一下,域的併發訪問是安全的:
1)域是final的,而且在構造器調用完成以後被訪問。顯然,不能再被修改的域確定線程安全,就像String。
2)對域的訪問由公有的鎖保護。
3)域是volatile的。但這個有使用場景限制。
後面說到對象的發佈時還會詳細介紹安全發佈對象的經常使用方法。
1.4 用鎖來保護狀態
因爲鎖能使其保護的代碼路徑以串行形式來訪問,所以能夠經過鎖來構造一些協議以實現對共享狀態的獨佔訪問,只要始終遵循這些協議,就能確保狀態的 一致性。訪問共享狀態的複合操做,如「讀取-修改-寫入」或「先檢查後執行」都必須是原子操做以免產生靜態條件。然而,僅僅將複合操做封裝到一個同步代 碼塊中是不夠的,若是用同步來協調對某個變量的訪問,那麼在訪問這個變量的全部位置上都須要使用同步,並且,都要使用同一個鎖。不管是寫入仍是讀取都要 鎖。
一種常見的加鎖約定是,將全部的可變狀態都封裝在對象內部,並經過對象的內置鎖對全部訪問可變狀態的代碼路徑進行同步使得在該對象上不會發生併發訪問。在許多線程安全類中都使用了這種模式,好比Vector, Hashtable等。注意本身寫相似的類時,不要隨便的給可變共享狀態加上get方法,這是程序員常常犯的錯誤,讓外部直接得到這個內部狀態很明顯存在安全隱患,內部封裝鎖的再好都沒用。並不是全部數據都須要鎖的保護,前面說過,只有被多個線程同時訪問的可變狀態才須要鎖來保護,也就是兩個條件:共享+可變的狀態纔會可能發生併發問題。
若是同步能夠避免競態條件問題,那麼爲何不在每一個方法聲明時都使用關鍵字synchronized?事實上,濫用synchronized可能致使過多的同步,致使性能問題。另外還可能致使活躍性問題(也就是死鎖,餓死等問題,後面還會說到)。
1.5 性能
修改下1.3.1節不推薦的程序。
修改後,作到了簡單性和併發性的平衡。使用鎖時必定要在保證併發安全的同時鎖住的代碼儘可能小。並且使用鎖時,應該清楚代碼塊中實現的功能,以及執行該代碼是否須要很長的時間(好比網絡IO),若是執行某個可能阻塞的操做或持有鎖的時間過長,必定不要獨佔鎖。