并發編程簡介
上古時期的計算機沒有操作系統,它們從頭到尾只運行一個程序。這個程序獨占計算機上所有的資源。只有當一個程序運行完之后才繼續運行其他的程序。這對于當時昂貴的計算機資源來說是一種很大的浪費。隨著操作系統的出現,使得計算機每次能夠運行多個程序。并且不同的程序都在獨立的進程當中運行:操作系統為各個獨立執行的進程分配各種資源,包括內存,文件句柄等等。如果需要的話,在不同的進程之間可以通過一些粗粒度的通信機制來交換數據。之所以在計算機中加入操作系統來實現多個程序的同時執行,主要是基于以下原因:
- 資源利用率:在某些情況下,程序必須等待某個外部操作完成之后才能繼續執行,比如說I/O操作。然而在等待I/O操作完成的過程中,程序無法執行其他操作,此時該程序會把CPU讓出來。此時如果在等待I/O操作完成的同時運行其他程序,那將提高計算機資源的利用率。
- 公平性:不同的用戶和程序對于計算機上的資源有著同等的使用權。一種高效的運行方式是通過粗粒度的時間分片(Time Slicing)使這些用戶和程序能共享計算機資源,而不是由一個程序從頭到尾運行,然后再啟動下一個程序。
- 便利性:一般來說在計算多個任務時,應該編寫多個程序,每個程序執行一個任務并在必要的時候互相通信,這比只編寫一個程序來計算所有任務更容易實現。
在早期的分時系統中,每個進程都相當于一臺馮諾依曼計算機,它擁有存儲指令和數據的內存空間,根據機器語言的語義以串行方式執行指令,并通過一組I/O指令與外部設備通信。對于每條被執行的指令來說,它們都有相應的“下一條指令”,程序中的控制流是按照指令集的規則來確定的。當前,幾乎所有的主流編程語言都遵循這種串行編程模型,并且在這些語言的規范中也都清晰地定義了在某個動作完成之后需要執行的“下一個動作”;串行編程模型的優勢在于其直觀性和簡單性,因為它模范了人類的工作方式:每次只做一件事情,做完之后再做下一件。就拿泡茶為例:我們在泡茶的時候,通常是將茶葉從柜子里拿出來放到杯子里,然后去燒水,等水開了之后再將開水倒入杯中。我們也可以先把水放到壺子里面去燒,然后再等待水開的過程中放好茶葉,甚至可以去干其他的事情,直到水燒開了再來泡茶。因為在這個過程中存在一定的異步性。因此,如果我們想變成一個做事很有效率的人,就必須在串行性和異步性之間找到合理的平衡,對于程序來說同樣如此。
線程安全性問題
這些促使進程出線的因素(資源利用率、公平性以及便利性等)同樣和促使著線程的出現。線程允許在同一個進程中同時存在多個程序控制流。線程會共享進程范圍內的資源。線程還提供了一種直觀的分解模式來充分利用多處理器系統中的硬件并行性,而在同一個程序中的多個線程也可以被同時調度到多個CPU上運行。線程也被稱為輕量級進程。在大多數現代操作系統中,都是以線程為基本的調度單位,而不是進程。如果沒有明確的同步機制,那么線程將彼此獨立執行。由于同一個進程中的所有線程都將共享進程的內存地址空間,因此這些線程都能訪問相同的變量并在同一個堆上分配對象,這就需要實現一種比在進程間共享數據粒度更細的數據共享機制。如果沒有明確的同步機制來協同對共享數據的訪問,那么當一個線程正在使用某個變量時,另一個線程可能同時訪問這個變量,這將導致不可預測的結果。下面將用一個非線程安全的數值序列生成器進行舉例:
public class UnsafeSequence{ private int value; public int getNext(){ return value++; } } 程序1-1
上面這段代碼的問題在于,如果執行時機不對,那么兩個線程在調用getNext時會得到相同的值。雖然看上去遞增運算是單個操作,但事實上它包含三個獨立的操作:讀取value,將value加1,并將計算結果寫入value.由于運行時可能將多個線程之間的操作交替執行,因此這兩個線程可能同時執行讀操作,從而使得它們得到相同的值,并都將這個值加1.結果就是,在不同線程的調用中返回了相同的數值。
上面是一種常見的并發安全問題,稱為競態條件(Race Condition)。在多線程的環境下,getNext是否會返回唯一的值,要取決于運行時對線程中操作的交替執行方式。其實,我們可以將getNext修改為一個同步的方法,就能避免出現上面的問題:
public class SafeSequence{ private int value; public synchronized int getNext(){ return value++; } } 程序1-2
那么,我們說了那么多,線程安全性到底是什么?
還有,為什么在方法上加上一個synchronized就能防止程序1-1中錯誤的交替執行情況呢?
下面,我將對上面這兩個問題進行解答
首先我們來說說線程安全性到底是什么。在給線程安全性下一個定義之前,我們先來弄明白什么叫做對象的狀態。從非正式意義上來說,對象的狀態是指存儲在狀態變量(例如實例或者靜態域)中的數據。對象的狀態可能包括其他依賴對象的域。例如,某個HashMap的狀態不僅存儲在HashMap對象本身,還存儲在許多Map.Entry對象中。在對象的狀態中包含了人和可能影響其外部可見行為的數據。
“共享”意味著變量可以由多個線程同時訪問,而“可變”則意味著變量的值在其生命周期內可以發生變化。一個對象是否需要是線程安全的,取決于它是否被多個線程訪問。這指的是在程序中訪問對象的方式,而不是對象要實現的功能。要使得對象是線程安全的,需要采用同步機制來協同對對象可變狀態的訪問,如果無法實現協同,那么可能會導致數據破壞以及其他不該出現的結果。
關于線程安全性
其實要對線程安全性給出一個明確的定義是非常復雜的。比如說我們在網絡上搜索時,能搜到許多關于線程安全的定義:
可以在多個線程中調用,并且在線程之間不會出現錯誤的交互;
可以同時被多個線程調用,而調用者無須執行額外的動作。
其實上面這兩句話聽起來就像“如果某個類可以在多個線程中安全地使用,那么它就是一個線程安全的類”。
在線程安全性的定義中,最核心的概念就是正確性。如果對線程安全性的定義是模糊的,那么就是因為缺乏對正確性的清晰定義。
正確性的含義是,某個類的行為與其規范完全一致。相應的,我們可以給線程安全性下一個定義:當多個線程訪問某個類時,這個類始終都能表現出正確的行為,那么就稱這個類是線程安全的。
當多個線程訪問某個類時,不管運行時環境采用何種調度方式或者這些線程將如何交替執行,并且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那么就稱這個類是線程安全的。
Synchronized協助實現線程安全
Java提供了一種內置的鎖機制來支持原子性:同步代碼塊(Synchronized Block)。同步代碼塊包括兩個部分:一個作為鎖的對象引用,一個作為有這個鎖保護的代碼塊。每個Java對象都可以用做一個實現同步的鎖,這些鎖被稱為內置鎖(Intrinsic Lock)或監視器鎖(Monitor Lock)。線程在進入同步代碼塊之前會自動獲得鎖,并且在退出同步代碼塊時自動釋放鎖,而無論是通過正常的控制路徑退出,還是通過從代碼塊中拋出異常退出,獲得內置鎖的唯一途徑就是進入由這個鎖保護的同步代碼塊或方法。
其實,Java的內置鎖相當于一種互斥鎖。即在同一時刻,只有一個線程能夠持有這種鎖。當線程A嘗試獲取一個由線程B持有的鎖時,線程A必須等待或阻塞,直到線程B釋放這個鎖。如果B永遠不釋放鎖,那么A也將永遠等待下去。
其實這也就解釋了為什么在程序1-1的方法上加一個Synchronized就防止錯誤的交替執行情況了:
在程序1-1中,getNext將會出現的問題是可能有多個線程在同一時間調用該方法,有可能獲得的value值會相同。然而如果在方法上加上Synchronized,情況就截然相反了:當一個方法被Synchronized修飾,那就說明在同一時刻只能由一個線程訪問該方法。因此,由這個鎖保護的同步代碼塊,在這里即方法體會以原子方式執行,多個線程在執行該代碼塊時也不會互相干擾。
此時,我們已經知道了同步代碼塊和同步方法可以確保以原子的方式執行操作,但是有一種常見的誤解就是認為關鍵字Synchronized只能用于實現原子性或者確定“臨界區(Critical Section)”。同步還有另一個重要的方面:內存可見性(Memory Visibility)。我們不僅希望防止某個線程正在使用對象狀態而另一個線程在同時修改該狀態,而且希望確保當一個線程修改了對象狀態之后,其他線程能夠看到發生的狀態變化。
可見性
其實可見性是一種復雜的屬性,因為常常可見性的錯誤總是會違背我們的直覺。在單線程的環境中,如果向某個值先寫入值,然后在沒有其他寫入操作的情況下讀取這個變量,那么我們總能獲得相同的值。但是當讀操作和寫操作在不同的線程中執行的時候,情況卻并非如此:因為我們無法確保執行讀操作的線程能夠在適當的時間看到其他線程寫入的值。所以為了確保多個線程之間對內存的寫入操作的可見性,我們就必須使用同步機制。
下面將給出一個錯誤的共享變量的程序作為例子:
public class NoVisibility{ private static boolean ready; private static int number; private static class ReaderThread extends Thread{ public void run(){ while(!ready){ Thread.yield(); System.out.println(number); } } public static void main(String[] args){ new ReaderThread().start(); number = 42; ready = true; } } } 程序1-3
在程序1-3中,主線程和讀線程都將訪問共享變量ready和number。理想的情況是:主線程啟動讀線程,然后主線程將number設為42,并且將ready設為true。讀線程一直循環知道發現ready變為了true,然后再輸出number的值。似乎看起來是這樣的。可是由于缺少同步機制,讀線程可能看不到主線程對這個兩個共享變量的值進行了更改,所以情況可能變得非常糟糕。可能由于讀線程沒有發現ready已經被設為了true,程序可能一直循環下去。或者是讀線程發現了ready已經被設為了true,但是輸出的number的值卻為0(這種現象被稱為重排序)。所以當有多個線程訪問共享變量時,我們就必須使用合適的同步機制來避免得出錯誤的結論:
在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多線程程序中,要想對內存操作的執行順序進行判斷,幾乎無法得出正確的結論。因此,只要有數據在多個線程之間共享,就必須使用正確的同步。
失效數據
下面再來看看一個程序:
public class MutableInteger{ private int value; public int get() { return value;} public void set(int value){ this.value = value;} } 程序1-4
在缺乏足夠同步的情況下,我們還可能遇到另一種錯誤的結果:失效數據。就如程序1-4所示,如果某個線程調用了set方法,那么另一個正在調用get方法的線程可能會看到更新之后的value值,也可能看不到。所以我們要添加適當的同步機制,使得程序1-4變成一個線程安全的類:
public class SynchronizedInteger{ private int value; public int get() { return value;} public void set(int value){ this.value = value;} } 程序1-5
其實很簡單,只要在set和get方法上加上關鍵字Synchronized即可。僅僅對set方法設置同步是不夠的,因為調用get的線程仍然會看見失效值。
非原子的64位操作
當線程在沒有同步的情況下讀取變量時,可能會得到一個失效值,但至少這個值是由之前某個線程設置的值,而不是一個隨機的值。這種安全性保證也被稱為最低安全性(out-of-thin-air safety)。
最低安全性適用于絕大多數變量,但是有一個例外:非volatile類型的64位數值變量(double 和 long)。Java內存模型要求,變量的讀取操作和寫入操作都必須是原子操作,但對于非volatile類型的long和double變量,JVM允許將64位的讀操作和寫操作分解為兩個32位的操作。當讀取一個非volatile類型的long變量時,如果對該變量的讀操作和寫操作不在同一個線程內進行,那么很可能會讀取到某個值的高32位和另一個值得低32位。因此即使不考慮失效數據的問題,如果需要在多線程的環境下使用共享并且是可變的long和double等類型的變量也是不安全的,必須用關鍵字volatile來聲明它們,或者是用鎖保護起來。
既然說到了volatile這個關鍵字,下面我們就來了解一下volatile變量
volatile變量
Java語言還提供了一種比Synchronized鎖稍弱的同步機制,即volatile變量,可以用來確保將變量的更新操作通知到其他的線程。當把變量聲明為volatile之后,編譯器與運行時(Runtime)都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序。volatile變量也不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。
其實,volatile變量對可見性的影響比le變量本身更為重要。當線程A首先寫入一個volatile變量并且線程B隨后讀取這個volatile變量時,在寫入volatile變量之前對A可見的所有變量的值在B都去了volatile變量后,對B也是可見的。因此從內存可見性的角度來看,寫入volatile相當于退出同步代碼塊,讀取volatile變量相當于進入同步代碼塊。但是并不建議過度依賴volatile變量提供的可見性。如果在代碼中依賴volatile變量來控制狀態的可見性,通常比使用鎖的代碼更為脆弱,也難以理解。
雖然volatile變量很方便,但是也存在一些局限性。volatile變量通常用做某個操作完成、發生中斷或者狀態的標志。盡管volatile變量也可以標識其他的狀態信息,但是在使用的時候要非常小心。例如,volatile的語義不足以確保遞增操作(count++)的原子性,除非能夠確保只有一個線程對變量進行寫操作。(原子變量提供了“讀-改-寫”的原子操作,并且常常用做一種“更好的volatile變量”)
加鎖機制既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性。
僅當滿足一下所有條件時,才應該使用volatile變量:
- 對變量的寫入操作不依賴變量的當前值,或者能夠確保只有單個線程更新變量的值。
- 該變量不會與其他狀態變量一起納入不變性條件中。
- 在訪問變量時不需要加鎖。
發布與逸出
- 發布:使對象能夠在當前作用域之外的代碼中使用。比如說,將一個指向該對象的引用保存到其他類可以訪問的地方,或者是在某一個非私有的方法中返回該引用,或者是將引用傳遞到其他類的方法中。
- 逸出:當某個不應該被發布的對象被發布時就稱為逸出。
當我們在編寫程序的時候,要特別注意不要使內部的可變狀態逸出,或者是將我們程序中一些private修飾的變量逸出了。看看下面這個例子:
class UnsafeStates{ private String[] states = new String[] {"AK","AL",......}; public String[] getStates(){ return states; } } 代碼1-6
按照代碼1-6所示,我們在不經意之間就將數組states逸出了它本來的作用域。如果我們在實際工作當中編寫了這樣的代碼,就有可能會造成嚴重的后果。除了上面這種顯式地逸出,還會出現下面這種隱式地使this引用逸出:
public class ThisEscape{ public ThisEscape (EventSource source){ source.registerListtener( new EventListener(){ public void onEvent(Event e){ doSomething(e); } } ); } } 程序1-7
當ThisEscape發布EventListener時,其實也隱含地發布了ThisEscape實例本身,因為在這個內部類的實例中包含了對ThisEscape實例的隱含引用。
安全的對象構造過程
在構造的過程中使this引用逸出的一個常見錯誤是在構造函數中啟動一個線程。當對象在構造函數中啟動一個線程的時候,無論是顯式地創建(通過將它傳給構造函數)還是隱式創建(由于Thread或Runnable是該對象的一個內部類),this引用都會被新創建的線程共享。在對象尚未完全被構造之前,新的線程就可以看見它,這是非常錯誤的做法。其實我們可以使用工廠方法來防止this引用在構造的過程中逸出:將構造函數聲明為private類型的,即使用一個私有的構造函數以及一個公共的工廠方法,從而避免不正確的構造過程。
線程封閉(Thread Confinement)
什么是線程封閉呢?
當我們訪問可變的數據時,通常需要使用同步。一種避免使用同步的方式就是不共享數據。如果僅在單線程內訪問數據,那我們就不需要同步。這種技術被稱為線程封閉。通俗一點說,就是把對象等全部封裝在一個線程里面,只有這個線程才能看到它里面有什么東西,這就叫線程封閉。
線程封閉的三種方式(挖個坑,以后再填)
- Ad-hoc線程封閉
- 棧封閉
- ThreadLocal類
不變性
滿足同步需求的另一種方法就是使用不可變對象(Immutable Object)。如果某個對象在被創建完之后,它的狀態不再發生改變或者是不能發生改變,那我們就稱這個對象為不可變對象。線程安全性是不可變對象的固有屬性之一,它們的不變性條件是由構造函數創建的,只要它們的狀態不改變,那么這些不變性條件就能得以維持。
不可變對象一定是線程安全的
我們在學習Java基礎知識的時候都知道final這個關鍵字,知道經final修飾的變量或者函數是不可變的。然而,我們需要注意的是,雖然在Java語言規范以及Java內存模型中都沒有給出不可變性的正式定義,但不可變性并不等于將對象中所有的域都聲明為final類型,即使對象中所有的域都是final的,這個對象也仍然可變,因為在final對象中可以保存對可變對象的引用。
當滿足以下條件時,對象才是不可變的:
- 對象創建之后其狀態就不能發生改變
- 對象所有的域都是final類型
- 對象是正確創建的
安全發布的模式
- 在靜態初始化函數中初始化一個對象引用;
- 將對象的引用保存到volatile類型的域或者AtomicReference對象中;
- 將對象的引用保存到某個正確構造對象的final類型域中;
- 將對象的引用保存到一個由鎖保護的域中.