并發(fā)編程簡介
上古時期的計算機沒有操作系統(tǒng),它們從頭到尾只運行一個程序。這個程序獨占計算機上所有的資源。只有當一個程序運行完之后才繼續(xù)運行其他的程序。這對于當時昂貴的計算機資源來說是一種很大的浪費。隨著操作系統(tǒng)的出現(xiàn),使得計算機每次能夠運行多個程序。并且不同的程序都在獨立的進程當中運行:操作系統(tǒng)為各個獨立執(zhí)行的進程分配各種資源,包括內(nèi)存,文件句柄等等。如果需要的話,在不同的進程之間可以通過一些粗粒度的通信機制來交換數(shù)據(jù)。之所以在計算機中加入操作系統(tǒng)來實現(xiàn)多個程序的同時執(zhí)行,主要是基于以下原因:
- 資源利用率:在某些情況下,程序必須等待某個外部操作完成之后才能繼續(xù)執(zhí)行,比如說I/O操作。然而在等待I/O操作完成的過程中,程序無法執(zhí)行其他操作,此時該程序會把CPU讓出來。此時如果在等待I/O操作完成的同時運行其他程序,那將提高計算機資源的利用率。
- 公平性:不同的用戶和程序對于計算機上的資源有著同等的使用權。一種高效的運行方式是通過粗粒度的時間分片(Time Slicing)使這些用戶和程序能共享計算機資源,而不是由一個程序從頭到尾運行,然后再啟動下一個程序。
- 便利性:一般來說在計算多個任務時,應該編寫多個程序,每個程序執(zhí)行一個任務并在必要的時候互相通信,這比只編寫一個程序來計算所有任務更容易實現(xiàn)。
在早期的分時系統(tǒng)中,每個進程都相當于一臺馮諾依曼計算機,它擁有存儲指令和數(shù)據(jù)的內(nèi)存空間,根據(jù)機器語言的語義以串行方式執(zhí)行指令,并通過一組I/O指令與外部設備通信。對于每條被執(zhí)行的指令來說,它們都有相應的“下一條指令”,程序中的控制流是按照指令集的規(guī)則來確定的。當前,幾乎所有的主流編程語言都遵循這種串行編程模型,并且在這些語言的規(guī)范中也都清晰地定義了在某個動作完成之后需要執(zhí)行的“下一個動作”;串行編程模型的優(yōu)勢在于其直觀性和簡單性,因為它模范了人類的工作方式:每次只做一件事情,做完之后再做下一件。就拿泡茶為例:我們在泡茶的時候,通常是將茶葉從柜子里拿出來放到杯子里,然后去燒水,等水開了之后再將開水倒入杯中。我們也可以先把水放到壺子里面去燒,然后再等待水開的過程中放好茶葉,甚至可以去干其他的事情,直到水燒開了再來泡茶。因為在這個過程中存在一定的異步性。因此,如果我們想變成一個做事很有效率的人,就必須在串行性和異步性之間找到合理的平衡,對于程序來說同樣如此。
線程安全性問題
這些促使進程出線的因素(資源利用率、公平性以及便利性等)同樣和促使著線程的出現(xiàn)。線程允許在同一個進程中同時存在多個程序控制流。線程會共享進程范圍內(nèi)的資源。線程還提供了一種直觀的分解模式來充分利用多處理器系統(tǒng)中的硬件并行性,而在同一個程序中的多個線程也可以被同時調(diào)度到多個CPU上運行。線程也被稱為輕量級進程。在大多數(shù)現(xiàn)代操作系統(tǒng)中,都是以線程為基本的調(diào)度單位,而不是進程。如果沒有明確的同步機制,那么線程將彼此獨立執(zhí)行。由于同一個進程中的所有線程都將共享進程的內(nèi)存地址空間,因此這些線程都能訪問相同的變量并在同一個堆上分配對象,這就需要實現(xiàn)一種比在進程間共享數(shù)據(jù)粒度更細的數(shù)據(jù)共享機制。如果沒有明確的同步機制來協(xié)同對共享數(shù)據(jù)的訪問,那么當一個線程正在使用某個變量時,另一個線程可能同時訪問這個變量,這將導致不可預測的結果。下面將用一個非線程安全的數(shù)值序列生成器進行舉例:
public class UnsafeSequence{ private int value; public int getNext(){ return value++; } } 程序1-1
上面這段代碼的問題在于,如果執(zhí)行時機不對,那么兩個線程在調(diào)用getNext時會得到相同的值。雖然看上去遞增運算是單個操作,但事實上它包含三個獨立的操作:讀取value,將value加1,并將計算結果寫入value.由于運行時可能將多個線程之間的操作交替執(zhí)行,因此這兩個線程可能同時執(zhí)行讀操作,從而使得它們得到相同的值,并都將這個值加1.結果就是,在不同線程的調(diào)用中返回了相同的數(shù)值。

上面是一種常見的并發(fā)安全問題,稱為競態(tài)條件(Race Condition)。在多線程的環(huán)境下,getNext是否會返回唯一的值,要取決于運行時對線程中操作的交替執(zhí)行方式。其實,我們可以將getNext修改為一個同步的方法,就能避免出現(xiàn)上面的問題:
public class SafeSequence{ private int value; public synchronized int getNext(){ return value++; } } 程序1-2
那么,我們說了那么多,線程安全性到底是什么?
還有,為什么在方法上加上一個synchronized就能防止程序1-1中錯誤的交替執(zhí)行情況呢?
下面,我將對上面這兩個問題進行解答
首先我們來說說線程安全性到底是什么。在給線程安全性下一個定義之前,我們先來弄明白什么叫做對象的狀態(tài)。從非正式意義上來說,對象的狀態(tài)是指存儲在狀態(tài)變量(例如實例或者靜態(tài)域)中的數(shù)據(jù)。對象的狀態(tài)可能包括其他依賴對象的域。例如,某個HashMap的狀態(tài)不僅存儲在HashMap對象本身,還存儲在許多Map.Entry對象中。在對象的狀態(tài)中包含了人和可能影響其外部可見行為的數(shù)據(jù)。
“共享”意味著變量可以由多個線程同時訪問,而“可變”則意味著變量的值在其生命周期內(nèi)可以發(fā)生變化。一個對象是否需要是線程安全的,取決于它是否被多個線程訪問。這指的是在程序中訪問對象的方式,而不是對象要實現(xiàn)的功能。要使得對象是線程安全的,需要采用同步機制來協(xié)同對對象可變狀態(tài)的訪問,如果無法實現(xiàn)協(xié)同,那么可能會導致數(shù)據(jù)破壞以及其他不該出現(xiàn)的結果。
關于線程安全性
其實要對線程安全性給出一個明確的定義是非常復雜的。比如說我們在網(wǎng)絡上搜索時,能搜到許多關于線程安全的定義:
可以在多個線程中調(diào)用,并且在線程之間不會出現(xiàn)錯誤的交互;
可以同時被多個線程調(diào)用,而調(diào)用者無須執(zhí)行額外的動作。
其實上面這兩句話聽起來就像“如果某個類可以在多個線程中安全地使用,那么它就是一個線程安全的類”。
在線程安全性的定義中,最核心的概念就是正確性。如果對線程安全性的定義是模糊的,那么就是因為缺乏對正確性的清晰定義。
正確性的含義是,某個類的行為與其規(guī)范完全一致。相應的,我們可以給線程安全性下一個定義:當多個線程訪問某個類時,這個類始終都能表現(xiàn)出正確的行為,那么就稱這個類是線程安全的。
當多個線程訪問某個類時,不管運行時環(huán)境采用何種調(diào)度方式或者這些線程將如何交替執(zhí)行,并且在主調(diào)代碼中不需要任何額外的同步或協(xié)同,這個類都能表現(xiàn)出正確的行為,那么就稱這個類是線程安全的。
Synchronized協(xié)助實現(xiàn)線程安全
Java提供了一種內(nèi)置的鎖機制來支持原子性:同步代碼塊(Synchronized Block)。同步代碼塊包括兩個部分:一個作為鎖的對象引用,一個作為有這個鎖保護的代碼塊。每個Java對象都可以用做一個實現(xiàn)同步的鎖,這些鎖被稱為內(nèi)置鎖(Intrinsic Lock)或監(jiān)視器鎖(Monitor Lock)。線程在進入同步代碼塊之前會自動獲得鎖,并且在退出同步代碼塊時自動釋放鎖,而無論是通過正常的控制路徑退出,還是通過從代碼塊中拋出異常退出,獲得內(nèi)置鎖的唯一途徑就是進入由這個鎖保護的同步代碼塊或方法。
其實,Java的內(nèi)置鎖相當于一種互斥鎖。即在同一時刻,只有一個線程能夠持有這種鎖。當線程A嘗試獲取一個由線程B持有的鎖時,線程A必須等待或阻塞,直到線程B釋放這個鎖。如果B永遠不釋放鎖,那么A也將永遠等待下去。
其實這也就解釋了為什么在程序1-1的方法上加一個Synchronized就防止錯誤的交替執(zhí)行情況了:
在程序1-1中,getNext將會出現(xiàn)的問題是可能有多個線程在同一時間調(diào)用該方法,有可能獲得的value值會相同。然而如果在方法上加上Synchronized,情況就截然相反了:當一個方法被Synchronized修飾,那就說明在同一時刻只能由一個線程訪問該方法。因此,由這個鎖保護的同步代碼塊,在這里即方法體會以原子方式執(zhí)行,多個線程在執(zhí)行該代碼塊時也不會互相干擾。
此時,我們已經(jīng)知道了同步代碼塊和同步方法可以確保以原子的方式執(zhí)行操作,但是有一種常見的誤解就是認為關鍵字Synchronized只能用于實現(xiàn)原子性或者確定“臨界區(qū)(Critical Section)”。同步還有另一個重要的方面:內(nèi)存可見性(Memory Visibility)。我們不僅希望防止某個線程正在使用對象狀態(tài)而另一個線程在同時修改該狀態(tài),而且希望確保當一個線程修改了對象狀態(tài)之后,其他線程能夠看到發(fā)生的狀態(tài)變化。
可見性
其實可見性是一種復雜的屬性,因為常??梢娦缘腻e誤總是會違背我們的直覺。在單線程的環(huán)境中,如果向某個值先寫入值,然后在沒有其他寫入操作的情況下讀取這個變量,那么我們總能獲得相同的值。但是當讀操作和寫操作在不同的線程中執(zhí)行的時候,情況卻并非如此:因為我們無法確保執(zhí)行讀操作的線程能夠在適當?shù)臅r間看到其他線程寫入的值。所以為了確保多個線程之間對內(nèi)存的寫入操作的可見性,我們就必須使用同步機制。
下面將給出一個錯誤的共享變量的程序作為例子:
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。讀線程一直循環(huán)知道發(fā)現(xiàn)ready變?yōu)榱藅rue,然后再輸出number的值。似乎看起來是這樣的??墒怯捎谌鄙偻綑C制,讀線程可能看不到主線程對這個兩個共享變量的值進行了更改,所以情況可能變得非常糟糕??赡苡捎谧x線程沒有發(fā)現(xiàn)ready已經(jīng)被設為了true,程序可能一直循環(huán)下去?;蛘呤亲x線程發(fā)現(xiàn)了ready已經(jīng)被設為了true,但是輸出的number的值卻為0(這種現(xiàn)象被稱為重排序)。所以當有多個線程訪問共享變量時,我們就必須使用合適的同步機制來避免得出錯誤的結論:
在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執(zhí)行順序進行一些意想不到的調(diào)整。在缺乏足夠同步的多線程程序中,要想對內(nèi)存操作的執(zhí)行順序進行判斷,幾乎無法得出正確的結論。因此,只要有數(shù)據(jù)在多個線程之間共享,就必須使用正確的同步。
失效數(shù)據(jù)
下面再來看看一個程序:
public class MutableInteger{ private int value; public int get() { return value;} public void set(int value){ this.value = value;} } 程序1-4
在缺乏足夠同步的情況下,我們還可能遇到另一種錯誤的結果:失效數(shù)據(jù)。就如程序1-4所示,如果某個線程調(diào)用了set方法,那么另一個正在調(diào)用get方法的線程可能會看到更新之后的value值,也可能看不到。所以我們要添加適當?shù)耐綑C制,使得程序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方法設置同步是不夠的,因為調(diào)用get的線程仍然會看見失效值。
非原子的64位操作
當線程在沒有同步的情況下讀取變量時,可能會得到一個失效值,但至少這個值是由之前某個線程設置的值,而不是一個隨機的值。這種安全性保證也被稱為最低安全性(out-of-thin-air safety)。
最低安全性適用于絕大多數(shù)變量,但是有一個例外:非volatile類型的64位數(shù)值變量(double 和 long)。Java內(nèi)存模型要求,變量的讀取操作和寫入操作都必須是原子操作,但對于非volatile類型的long和double變量,JVM允許將64位的讀操作和寫操作分解為兩個32位的操作。當讀取一個非volatile類型的long變量時,如果對該變量的讀操作和寫操作不在同一個線程內(nèi)進行,那么很可能會讀取到某個值的高32位和另一個值得低32位。因此即使不考慮失效數(shù)據(jù)的問題,如果需要在多線程的環(huán)境下使用共享并且是可變的long和double等類型的變量也是不安全的,必須用關鍵字volatile來聲明它們,或者是用鎖保護起來。
既然說到了volatile這個關鍵字,下面我們就來了解一下volatile變量
volatile變量
Java語言還提供了一種比Synchronized鎖稍弱的同步機制,即volatile變量,可以用來確保將變量的更新操作通知到其他的線程。當把變量聲明為volatile之后,編譯器與運行時(Runtime)都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內(nèi)存操作一起重排序。volatile變量也不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。
其實,volatile變量對可見性的影響比le變量本身更為重要。當線程A首先寫入一個volatile變量并且線程B隨后讀取這個volatile變量時,在寫入volatile變量之前對A可見的所有變量的值在B都去了volatile變量后,對B也是可見的。因此從內(nèi)存可見性的角度來看,寫入volatile相當于退出同步代碼塊,讀取volatile變量相當于進入同步代碼塊。但是并不建議過度依賴volatile變量提供的可見性。如果在代碼中依賴volatile變量來控制狀態(tài)的可見性,通常比使用鎖的代碼更為脆弱,也難以理解。
雖然volatile變量很方便,但是也存在一些局限性。volatile變量通常用做某個操作完成、發(fā)生中斷或者狀態(tài)的標志。盡管volatile變量也可以標識其他的狀態(tài)信息,但是在使用的時候要非常小心。例如,volatile的語義不足以確保遞增操作(count++)的原子性,除非能夠確保只有一個線程對變量進行寫操作。(原子變量提供了“讀-改-寫”的原子操作,并且常常用做一種“更好的volatile變量”)
加鎖機制既可以確保可見性又可以確保原子性,而volatile變量只能確??梢娦?。
僅當滿足一下所有條件時,才應該使用volatile變量:
- 對變量的寫入操作不依賴變量的當前值,或者能夠確保只有單個線程更新變量的值。
- 該變量不會與其他狀態(tài)變量一起納入不變性條件中。
- 在訪問變量時不需要加鎖。
發(fā)布與逸出
- 發(fā)布:使對象能夠在當前作用域之外的代碼中使用。比如說,將一個指向該對象的引用保存到其他類可以訪問的地方,或者是在某一個非私有的方法中返回該引用,或者是將引用傳遞到其他類的方法中。
- 逸出:當某個不應該被發(fā)布的對象被發(fā)布時就稱為逸出。
當我們在編寫程序的時候,要特別注意不要使內(nèi)部的可變狀態(tài)逸出,或者是將我們程序中一些private修飾的變量逸出了。看看下面這個例子:
class UnsafeStates{ private String[] states = new String[] {"AK","AL",......}; public String[] getStates(){ return states; } } 代碼1-6
按照代碼1-6所示,我們在不經(jīng)意之間就將數(shù)組states逸出了它本來的作用域。如果我們在實際工作當中編寫了這樣的代碼,就有可能會造成嚴重的后果。除了上面這種顯式地逸出,還會出現(xiàn)下面這種隱式地使this引用逸出:
public class ThisEscape{ public ThisEscape (EventSource source){ source.registerListtener( new EventListener(){ public void onEvent(Event e){ doSomething(e); } } ); } } 程序1-7
當ThisEscape發(fā)布EventListener時,其實也隱含地發(fā)布了ThisEscape實例本身,因為在這個內(nèi)部類的實例中包含了對ThisEscape實例的隱含引用。
安全的對象構造過程
在構造的過程中使this引用逸出的一個常見錯誤是在構造函數(shù)中啟動一個線程。當對象在構造函數(shù)中啟動一個線程的時候,無論是顯式地創(chuàng)建(通過將它傳給構造函數(shù))還是隱式創(chuàng)建(由于Thread或Runnable是該對象的一個內(nèi)部類),this引用都會被新創(chuàng)建的線程共享。在對象尚未完全被構造之前,新的線程就可以看見它,這是非常錯誤的做法。其實我們可以使用工廠方法來防止this引用在構造的過程中逸出:將構造函數(shù)聲明為private類型的,即使用一個私有的構造函數(shù)以及一個公共的工廠方法,從而避免不正確的構造過程。
線程封閉(Thread Confinement)
什么是線程封閉呢?
當我們訪問可變的數(shù)據(jù)時,通常需要使用同步。一種避免使用同步的方式就是不共享數(shù)據(jù)。如果僅在單線程內(nèi)訪問數(shù)據(jù),那我們就不需要同步。這種技術被稱為線程封閉。通俗一點說,就是把對象等全部封裝在一個線程里面,只有這個線程才能看到它里面有什么東西,這就叫線程封閉。
線程封閉的三種方式(挖個坑,以后再填)
- Ad-hoc線程封閉
- 棧封閉
- ThreadLocal類
不變性
滿足同步需求的另一種方法就是使用不可變對象(Immutable Object)。如果某個對象在被創(chuàng)建完之后,它的狀態(tài)不再發(fā)生改變或者是不能發(fā)生改變,那我們就稱這個對象為不可變對象。線程安全性是不可變對象的固有屬性之一,它們的不變性條件是由構造函數(shù)創(chuàng)建的,只要它們的狀態(tài)不改變,那么這些不變性條件就能得以維持。
不可變對象一定是線程安全的
我們在學習Java基礎知識的時候都知道final這個關鍵字,知道經(jīng)final修飾的變量或者函數(shù)是不可變的。然而,我們需要注意的是,雖然在Java語言規(guī)范以及Java內(nèi)存模型中都沒有給出不可變性的正式定義,但不可變性并不等于將對象中所有的域都聲明為final類型,即使對象中所有的域都是final的,這個對象也仍然可變,因為在final對象中可以保存對可變對象的引用。
當滿足以下條件時,對象才是不可變的:
- 對象創(chuàng)建之后其狀態(tài)就不能發(fā)生改變
- 對象所有的域都是final類型
- 對象是正確創(chuàng)建的
安全發(fā)布的模式
- 在靜態(tài)初始化函數(shù)中初始化一個對象引用;
- 將對象的引用保存到volatile類型的域或者AtomicReference對象中;
- 將對象的引用保存到某個正確構造對象的final類型域中;
- 將對象的引用保存到一個由鎖保護的域中.