除了充分利用計算機處理器的能力外,一個服務端同時對多個客戶端提供服務則是另一個更具體的并發應用場景。衡量一個服務性能的好壞高低,每秒事務處理數(Transactions Per Second, TPS)是最重要的指標之一,它代表著一秒內服務端平均能相應的請求總數,而TPS值與程序的并發能力又有非常密切的關系。對于計算量相同的任務,程序線程并發協調得越有條不紊,效率自然就越高;反之,線程之間頻繁阻塞甚至死鎖,將會大大降低程序的并發能力。
服務端是Java語言最擅長的領域之一,這個領域的應用占了Java應用中最大的一塊份額,不過如何寫好并發應用程序確是服務端程序開發的難點之一,處理好并發方面的問題通常需要更多的編碼經驗來支持。幸好Java語言和虛擬機提供了許多工具,把并發變成的門檻降低了不少。而且各種中間件服務器、各類框架都努力地替程序員處理盡可能多的多線程并發細節,使得程序員在編碼時能更專注于業務邏輯,而不花費大量的時間去關服務會被多少人調用、如何協調硬件資源。無論語言、中間件和框架如何先進,開發人員都不能期望它們能獨立完成所有并發處理的事情,了解并發的內幕也是稱為一個高級程序員不可缺少的課程。
硬件的效率與一致性
“讓計算機并發執行若干個運算任務”和“更充分的利用計算機處理器的效能”之間的因果關系,看起來順理成章,實際上它們之間的關系并沒有想象中的那么簡單,其中一個重要的復雜性來源是絕大多數的運算任務都不可能只靠處理器計算能完成,處理器至少要與內存交互,如讀取運算數據、存儲運算結果等,這個I/O操作是很難消除的。由于計算器的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的告訴緩存(Cache)來作為內存與處理器之間的緩沖:將運算需要使用到的數據復制到緩存中,讓運算能快速進行,當運算結束之后再從緩存同步回內存之中,這樣處理器就無需等待緩慢的內存讀寫了。
基于高速緩存的存儲交互很好的解決了處理器的內存與內存的速度矛盾,但是也為計算機系統帶來了更高的復雜度,因為它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的告訴緩存,而它們又共享同一內存(Main Memory)。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自緩存數據不一致,如果真的發生這種情況,那同步回到主內存時以誰的緩存數據為準呢?為了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI、MOSI、Synapse、Firefly和Dragon Protocol等。多次提到的“內存模型”,可以理解為在特定的協議下,對特定的內存或告訴緩存進行讀寫訪問的抽象過程。不同結構的物理機器可以擁有不一樣的內存模型,而JVM也有自己的內存模型,并且這里介紹的內存訪問操作與硬件的緩存訪問操作具有很高的可比性。
除了增加高速緩存之外,為了使得處理器內部的運算單位能盡量被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之后將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但并不保證程序中各個語句計算的先后順序與輸入代碼中的順序一致,因此,如果存在一個計算任務依賴另外一個計算任務的中間結果,那么其順序性并不能靠代碼的先后順序來保證。與處理器的亂序執行優化類似,JVM的即時編譯器也有類似的指令重排序(Instruction Reorder)優化。
Java內存模型
JVM規范中試圖定義一種Java內存模型(Java Memory Model,JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都達到一致的內部才能訪問效果。在此之前,主流程序語言(C/C++)直接使用物理硬件和操作系統的內存模型,因此,會由于不同平臺上內存模型的差異,有可能導致在一套平臺上并發完全正常,而在另外一套平臺上并發訪問卻經常出錯,因此在某些場景就必須針對不同的平臺來編寫程序。
定義Java內存模型并非一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓Java的并發內存訪問操作不會產生歧義;但是,也必須定義得足夠寬松,使得虛擬機的實現有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存和指令集中某些特有指令)來獲取更好的執行速度。經過長時間的驗證和修補,在JDK1.5發布之后,Java內存模型已經成熟和完善起來了。
主內存與工作內存
Java內存模型的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。此外的變量(Variables)與Java編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但不包括局部變量與方法參數,因為后者是線程私有的,不會被共享,自然就不會存在競爭問題。為了獲得較好的執行效能,Java內存模型并沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器進行調整代碼執行順序這類優化措施。
Java內存模型規定了所有的變量都存儲在主內存中(此外的內存與介紹物理硬件時的主內存名字一樣,兩者也可以互相類比,但此處僅僅是虛擬機內存的一部分)。每條線程還有自己的工作內存(Working Memory,可與前面的高速緩存類比),線程的工作內存中保存了被線程使用到的變量的主內存副本拷貝。這里的副本拷貝并不是把內存復制出來,這個對象的引用、對象中某個線程訪問到的字段是有可能存在拷貝的,但不會有虛擬機實現成把整個對象拷貝一次。。線程對變量的所有操作(讀取、賦值等)都必須在工作內存中完成,而不是直接讀寫主內存中的變量(根據JVM規范的規定,volatile變量依然有工作內存的拷貝,但是由于它特殊的操作順序性規定,所以看起來如同直接在主內存中讀寫訪問一般,因此這里的描述對于volatile也并不例外)。不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的關系如下所示
內存間相互操作
關于主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存之類的實現細節,Java內存模型定義了以下8種操作來完成,虛擬機實現時必須保證下面提及的每一種操作的原子性、不可再分的(對于double、long類型的變量來說,load、store、read和write操作在某些平臺上允許有例外)。
- lock(鎖定):作用于主內存變量,它把一個變量標識為一條線程獨占的狀態。
- unlock(解鎖):作用于主內存的變量,它把一個處于鎖定狀態的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
- read(讀取):作用域主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便隨后的load動作使用。
- load(載入):作用于工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
- use(使用):作用于工作內存的變量,它把工作內存中的一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時將會執行這個操作。
- assign(賦值):作用于工作內存變量,它把一個從執行引擎接收到的賦值給工作內存的變量,每當虛擬機遇到一個變量賦值的字節碼時執行這個操作。
- store(存儲):作用于工作內存的變量,它把工作內存中一個變量的值傳遞到內存中,以便隨后的write操作使用。
- write(寫入):作用于主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。
如果要把一個變量從主內存復制到工作內存,那就要順序地執行read和load操作,如果要把變量從工作內存同步回主內存,就要順序的執行store和write操作。注意,Java內存模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。也就是說,read與load之間、store與write之間是可以插入其他指令的,如對主內存中的變量a、b進行訪問時,一種可能出現順序是read a、read b、load b、load a。初次之外,Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則:
- 不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或者從工作內存發起回寫了但主內存不接受的情況出現。
- 不允許一個線程丟棄它的最近的assign操作,即變量在工作內存中改變了之后必須把該變化同步回主內存。
- 不允許一個線程無原因的(沒有發生過任何assign操作)把數據從線程工作內存同步回主內存中。
- 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說,就是對一個變量實施use、store操作之前,必須先執行assign和load操作。
- 一個變量在用一個時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重復執行多次,多次執行lock之后,只有執行相同次數的unlock操作,變量才會解鎖。
- 如果對一個變量執行lock操作,那將會情況工作內存中詞變量的值,在執行引擎使用這個變量之前,需要重新執行load或assign操作初始化變量的值。
- 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。
- 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)。
這8種內存訪問操作以及上述規則限定,再加上稍后介紹的volatile的一些特殊規定就已經完全確定了Java程序中哪些內存訪問操作在并發下是安全的。
對于volatile型變量的特殊規則
關鍵字volatile可以說是JVM提供的最輕量級的同步機制,但是它并不容易完全被正確、完整的理解,以至于許多程序員動習慣不去使用它,遇到需要處理多線程數據競爭的時候一律使用synchronized來進行同步。了解volatile變量的語義對了解多線程操作的其他特性很有意義。
Java內存模型對volatile專門定義了一些特殊的訪問規則,當一個變量定義為volatile之后,它將具備兩個特性:第一,是保證此變量對所有線程的可見性,這里的“可見性”是指當一條線程修改了這個變量的值,新值對于其他線程來說是可以立即得知的。而普通變量不能做到這一點,普通變量的值在線程間傳遞均需要通過主內存來完成,例如,線程A修改了一個普通變量的值,然后向主內存進行回寫,另外一條線程B在線程A回寫完成之后再從主內存進行讀取操作,新變量值才會對線程B可見。
關于volatile變量的可見性,經常會被開發人員誤解,認為以下描述成立:“volatile變量對所有線程是立即可見的,對volatile變量所有的寫操作都能立即反應到其他線程中,換句話說,volatile變量在各個線程中是一致的,所以基于volatile變量的運算在并發下是安全的。”這個結論。 volatile變量在各個線程的工作內存中不存在一致性問題(在各個線程的工作內存中,volatile變量也可以存在不一致的情況,但由于每次使用之前都要先刷新,執行引擎看不到不一致的情況,因此變量也可以認為不存在一致性問題),但是Java里面的運算并非原子操作,導致volatile變量的運算在并發下一樣是不安全的,我們可以通過一段簡單的演示來說明:
public class Main {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(race);
}
}
這段代碼發起了20個線程,每個線程對race變量進行10000次自增操作,如果這段代碼能夠正確并發的話,最后輸出的結果應該是200000。但是在運行完這段代碼之后并沒有得到這個預期的結果,而且會發現每次運行程序,輸出的結果都不一樣,都是小于200000的數字。問題出在了自增運算“race++”上,我們用javap反編譯這段代碼后會得到如下代碼清單:
public static void increase();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
8: return
LineNumberTable:
line 11: 0
line 12: 8
發現只有一行代碼的increase方法在Class文件中是由4條字節碼指令構成的,從字節碼層面上很容易就分析出并發失敗的原因:當getstatic指令把race的值取到操作數棧的棧頂時,volatile關鍵字保證了race的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他線程可能已經把race的值增大了,而在操作棧頂的值就變成了過期數據,所以putstatic指令執行后就可能把較小的race同步回主內存之中。
由于volatile變量只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized或者java.util.concurrent中的原子類)來保證原子性。
- 運算結果并不依賴變量的當前值,或者能確保只有單一的線程修改變量的值
- 變量不需要與其他的狀態共同參與不變約束
如下的代碼清單中的場景就很適合使用volatile變量來控制并發,當shutdown方法被調用的時候,能確保所有線程中執行的doWork方法都立即停下來。
volatile boolean shutdownRequseted;
public void shutdown() {
shutdownRequseted = true;
}
public void doWork(){
whilr (!shutdownRequseted){
//do something
}
}
使用volatile變量的第二語義是禁止指令重排序優化,普通的變量僅僅會保證在該方法執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。因為在一個線程的方法執行過程中無法感知到這點,這也就是Java內存模型中描述的所謂的“線程內表現為串行的語義”(Within-Thead As-If-Serial Semantics)。
指令重排序是并發編程中最容易讓開發人員產生疑惑的地方,比如,一段標準的DCL單例代碼,可以觀察到加入了volatile和未加入volatile關鍵字時所產生匯編代碼的差別
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
}
}
編譯后,這段代碼對instance變量賦值部分如下:
0x01a3de0f:mov $0x3375cdb0,%esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14:mov %eax,0x150(%esi) ;...89865001 0000
0x01a3de1a:shr $0x9,%esi ;...clee09
0x01a3de1d:movb $0x0,0x1104800 (%esi) ;...c6860048 100100
0x01a3de24:lock addl $0x0,(%esp) ;...f0830424 00
;*putstatic intstance
;-
Singleton::getInstance@24
通過對比就會發現,關鍵變化在于有volatile修飾的變量,賦值后多執行了“lock addl 0x0,(%esp)”(把ESP寄存器的值加0)顯然是一個空操作,關鍵在于lock的前綴,它的作用是使得本CPU的Cache寫入了內存,該寫入動作也會引起別的CPU或者別的內核無效化(Invalidate)其Cache,這種操作相當于對Cache中的變量做了一次前面介紹Java內存模式中所說的“store和write”操作。所以通過這樣的空操作,可讓volatile變量的修改對其他CPU立即可見。
那為什么說它禁止指令重排序呢?從硬件架構上講,指令重排序是指CPU采用了允許講多條指令不按程序規定的順序分開發送給各相應電路單元處理。但并不是說指令任意重排,CPU需要能正確處理指令依賴情況以保障程序能得出正確的執行結果。在本內CPU中,重排序看起來依然是有序的。因此“lock addl $0x0,(%esp)”指令把修改同步到內存時,意味著所有之前的操作都已經執行完成,這樣便形成了“指令重排序無法通過內存屏障”的效果。
解決了volatile的語義問題,再來看看在眾多保障并發安全的工具中選用volatile的意義:它能讓我們的代碼比使用其他同步工具更快嗎?在某些情況下,volatile的同步機制的性能確實要優于鎖,但是由于虛擬機對鎖實行的許多消除和優化,使得我們很難量化的認為volatile就會比synchronized塊多少。如果讓volatile自己與自己比較的話,可以確定一個原則,volatile變量的讀操作的性能消耗與普通變量幾乎沒有什么差別,但是寫操作則會慢一些,因為它需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。不過即便如此,大多數場景下volatile的總開銷仍然要比鎖要低,我們在volatile與鎖之中選擇的唯一依據僅僅是volatile的語義更能滿足使用場景的需求。
最后再看看Java內存模型中對volatile變量定義的特殊規則。假定T表示一個線程,V和W分別表示兩個volatile型的變量,那么在進行read、load、use、assign、store和write操作時需要滿足如下規則:
- 只有當線程T對變量V執行的前一個動作是load的時候,線程T才能對變量V執行use動作;并且,只有當線程T對變量V執行的后一個動作是use的時候,線程T才能對變量V執行load動作。線程T對變量V的use動作可以認為是和線程T對變量V的load、read動作相關聯,必須連續一起出現(這條規則要求在工作線程中,每次使用V前都必須從主內存刷新最新的值,用于保證能看見其他線程對變量V所做的修改后的值)。
- 只有當線程T對變量V執行的前一個動作是assign的時候,線程T才能對變量V執行store動作;并且,只有當線程T對變量V執行的后一個動作是store的時候,線程T才能對變量V執行assign動作。線程T對變量V的assign動作認為是和線程T對變量V的store、write動作相關聯,必須一起出現(這條規則要求在工作線程中,每次修改V后都必須立刻同步回主內存中,用于確保其他線程可以看到自己對變量V所做的修改)。
- 假定動作A是線程T對變量V實施的use或assign動作,假定動作F是和動作A相關聯的load或store動作,假定動作P是和動作F相應的對變量V的read或者write動作;類似的,假定動作B是線程T對變量W實施的use或者assign動作,假定動作G是和動作B相關聯的load或者store動作,假定動作Q是和動作G相應的對變量W的read或write動作。如果A先于B,那么P先于Q(這條規則要求volatile修飾的變量不會被指令重排序優化,保證代碼是執行順序與程序的順序相同)。
對于long和double型變量的特殊規則
Java內存模型要求lock、unlock、read、load、assign、use、store、write這8個操作都具有原子性,但是對于64位的數據類型(long和double),在模型中特別定義了一條相對寬松的規定:允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機實現選擇可以不保證64位數據類型的load、store、read和write這4個操作的原子性,這點就是所謂的long和double的非原子性協定(Nonatomic Treatment of double and long Variables)。
如果有多個線程共享一個并未聲明為volatile的long或double類型的變量,并且同時對它們讀取和修改操作,那么某些線程可能會讀取到一個既非原值,也不是其他線程修改值的代表了“半個變量”的數值。
不過這種讀取到“半個變量”的情況非常罕見(在目前商用Java虛擬機中不會出現),因為Java內存模型雖然允許虛擬機不把long和double變量的讀寫實現成原子操作,但允許虛擬機選擇把這些操作實現為具有原子性的操作,而且還“強烈建議”虛擬機這樣實現。在實際開發中,目前各種平臺下的商用虛擬機幾乎都選擇把64位數據的讀寫操作作為原子操作來對待,因此我們在編寫代碼時一般不需要把用到的long和double變量專門聲明為volatile。
原子性、可見性和有序性
- 原子性(Atomicity):由Java內存模型來直接保證的原子性操作包括read、load、assign、use、store和write,我們大致可以認為基本數據類型的訪問讀寫是具備原子性的(例外就是long和double的非原子性協定,讀者只要知道這件事就可以了,無須太過在意這些幾乎不會發生的例外情況)。如果應用場景需要一個更大范圍的原子性保證,Java內存模型還提供了lock和unlock操作來滿足這種需求,盡管虛擬機未把lock和unlock操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個字節碼指令反映到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。
- 可見性(Visibility):可見性是指當一個線程修改了共享變量的值,其他線程就能夠立刻得知這個修改。Java內存模型是通過在變量修改后將新值同步回主內存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現可見性的,無論是普通變量還是volatile變量都是如此,普通變量與volatile變量的區別是,volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因此,可以說volatile保證了多線程操作時變量的可見性,而普通變量則不能保證這一點。除了volatile之外,Java還有兩個關鍵字能實現可見性,即synchronized和final。同步塊的可見性是由“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)”這條規則獲得的,而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦初始化完成,并且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到“初始化一半”的對象),那在其他線程中就能看到final字段的值。
- 有序性(Ordering):Java內存模型的有序性在volatile也討論過,Java程序中天然的有序性可以總結為一句話:如果在本線程中觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指“線程內表現為串行的語義”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象。Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含禁止指令重排序的語義,而synchronized則是由“一個變量在同一個時刻只允許一條線程對其進行lock操作”這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能串行地進入。
我們可以看到,synchronized關鍵字在需要這3種特性的時候就可以作為其中一種解決方案?看起來很“萬能”。的確,大部分的并發控制操作都能使用synchronized來完成,synchronized的萬能也造成了我們的濫用,越“萬能”的并發控制,通常會伴隨著越大的性能影響。
先行發生原則
如果Java內存模型中所有的有序性都僅僅依靠volatile和synchronized來完成,那么有一些操作將會變得很繁瑣,但是我們在編寫Java并發代碼的時候并沒有感覺到這一點,這是因為Java語言中有一個“先行發生”(happens-before)的原則。這個原則非常重要,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,我們可以通過幾條規則一攬子的解決并發環境下兩個操作之間是否可能存在沖突的所有問題。
先行發生時Java內存模型中定義的兩項操作之間的偏序關系,如果說操作A先行發生于操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。舉個例子:
//以下操作在線程A中執行
i = 1;
//以下操作在線程B中執行
j = i;
//以下操作在線程C中執行
i = 2;
假設線程A中的操作“i=1”先行發生于線程B的操作“j=1”,那么可以確定在線程B的操作執行后,變量j的值一定等于1,得出這個結論的依據有兩個:一是根據先行發生原則,“i=1”的結果可以被觀察到;二是線程C還沒“登場”,線程A操作結束之后沒有其他線程會修改變量i的值。現在再來考慮線程C,我們依然保持線程A和線程B之間的先行發生關系,而線程C出現在線程A和線程B的操作之間,但是線程C與線程B沒有先行發生關系,那j的值會是多少呢?答案是不確定。1和2都有可能,因為線程C對變量i的影響可能會被線程B觀察到,也可能不會,這個時候線程B就存在讀取到過期數據的風險,不具備多線程安全性。
下面是Java內存模型下一些“天然的”先行發生關系,這些先行發生關系無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關系不在此列,并且無法從下列規則中推導出來的話,它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序。
- 程序次序規則:在一個線程中,按照程序代碼順序,書寫在前面的操作先行發生于書寫在后面的操作。準確的說,應該是控制流順序而不是程序代碼順序,因為要考慮分支、循環等結構。
- 管程鎖定規則:一個unlock操作先行發生于后面對同一個鎖的lock操作。這里必須強調的是同一個鎖,而“后面”是指時間上的先后順序。
- volatile變量規則:對一個volatile變量的寫操作先行發生于后面對這個變量的讀操作,這里的“后面”同樣是指時間上的先后順序。
- 線程啟動規則:Thread對象的start()方法先行發生于此線程的每一個動作。
- 線程中斷規則:線程中的所有操作都先行發生于對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
- 對象終結規則:一個對象的初始化完成(構造函數執行結束)先行發生于它的finalize()方法的開始。
- 傳遞性:如果操作A先行發生于操作B,操作B先行發生于操作C,那就可以得出操作A先行發生于操作C的結論。
Java語言無須任何同步手段保障就能成立的先行發生規則就只有上面這些了。
private int value = 0;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
代碼中展示的不過是getter/setter方法,假設存在線程A和線程B,線程A先調用了“setValue(1)”,然后B線程調用了同一個對象的“getValue()”,那么線程B收到的返回值是什么?
我們依次分析一下發生原則中的各項規則,由于兩個方法分別由線程A和線程B調用,不在一個線程中,所以程序次序規則在這里不適用;由于沒有同步塊,自然就不會發生lock和unlock的操作,所以管程鎖定規則也不是適用;由于value變量沒有被volatile關鍵字修飾,所以volatile變量規則不適用;后面的線程啟動、終止、中斷規則和對象終結規則也和這里完全沒關系。因為沒有一個適用的先行發生規則,所以最后一天傳遞性也無從談起,因此我們可以判斷盡管線程A在操作時間上先于線程B,但是無法確定線程B中“getValue”方法的返回結果,換句話說,這里面的操作不是線程安全的。
那怎么修復這個問題呢?我們至少有兩種比較簡單的方案可以選擇,要么把getter/setter方法都定義為synchronized方法,這樣就可以套用管理鎖定規則;要么把value定義volatile變量,由于setter方法對于value的修改不依賴value的原值,滿足volatile關鍵字使用場景,這樣就可以套用volatile變量規則來實現先行發生關系。
通過上面的例子,我們可以得出結論:一個操作“時間上的先發生”不代表這個操作會是“先行發生”,那如果一個操作“先行發生”是否就能推導出這個操作必定是“時間上的先發生”呢?也不成立,一個典型的例子就是多次提到的“指令重排序”
//以下操作在同一個線程中執行
int i= 1;
int j = 2;
這兩條語句都是在同一個線程之中,根據程序次序規則,“int i=1”的操作先行發生于“int j=2”,但是“int j=2”的代碼完全可能先被處理器執行,這并不影響先行發生原則的正確性,因為我們在這條線程之中沒有辦法感知到這點。
綜合上面兩個例子我們可以得出結論:時間先后順序與先行發生原則之間基本沒有太大關系,所以我們衡量并發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為準。
Java與線程
并發不一定要依賴多線程(如PHP中很常見的多線程并發),但是在Java里面談論并發,大多數都與線程脫不開關系。
線程的實現
線程是比進程更輕量級的調度執行單位,線程的引入,可以把一個線程的資源分配和執行調度分開,各個線程可以共享進程資源(內存地址、文件IO等),又可以獨立調度(線程是CPU調度的基本單位)。
主流的操作系統都提供了線程實現,Java語言則提供了在不同硬件和操作系統平臺下對線程操作的統一處理,每個已經執行start()且未結束的java.lang.Thread類的實現就代表了一個線程。我們注意到Thread類與的大部分JavaAPI有顯著差別,它的所有關鍵方法都是聲明為native的。在JavaAPI中,一個native方法往往意味著這個方法沒有使用或無法使用平臺無關的手段來實現(當然也可能是為了執行效率而使用native方法,不過,通常最高效的手段也就是平臺相關的手段)。
實現線程主要有3種方式:使用內核線程實現、使用用戶線程實現和使用用戶線程+輕量級進程混合實現。
使用內核實現
內核線程(Kernel-Level Thread,KLT)就是直接由操作系統內核(Kernel,下面統稱內核)支持的線程,這種線程由內核來完成線程切換,內核通過操縱調度器(Scheduler)對線程進行調度,并負責將線程的任務映射到各個處理器上。每個內核線程可以視為內核的一個分身,這樣操作系統就有能力同時處理多件事情,支持多線程的內核就叫做多線程內核(Multi-Threads Kernel)。
程序一般不會直接去使用內核線程,而是去使用內核線程的一種高級接口——輕量級進程(Light Weight Process,LWP),輕量級進程就是我們通常意義上所講的線程,由于每個輕量級進程都由一個內核線程支持,因此只有先支持內核線程,才能有輕量級進程。這種輕量級進程與內核線程之間1:1的關系稱為一對一的線程模型,如下圖所示:
由于內核線程的支持,每個輕量級進程都成為了一個獨立的調度單元,即使有一個輕量級進程在系統調用中阻塞了,也不會影響整個進程繼續工作,但是輕量級進程有它的局限性:
- 首先,由于是基于內核線程實現的,所以各種線程操作,如創建、析構和同步,都需要進行系統調用。而系統調用的待嫁相對較高,需要在用戶態和內核態中來回切換。
- 其次,每個輕量級進程都需要有一個內核線程的支持,因此輕量級進程要消耗一定的內核資源(如內核線程的棧空間),因此一個系統支持輕量級進程的數量是有限的。
使用用戶線程實現
從廣義上講,一個線程只要不是內核線程,就可以認為是用戶線程,因此從這個定義上來講,輕量級進程也屬于用戶線程,但輕量級進程的實現始終是建立在內核之上的,許多操作都要進行系統代用,效率會受到限制。
而狹義的用戶線程指的是完全建立在用戶空間的線程庫上,系統內核不能感知線程存在的實現。用戶線程的創建、同步、銷毀和調度完全在用戶狀態中完成,不需要內個的幫助。如果程序實現得當,這種線程不需要切換到內核態,因此操作可以是非常快速且低消耗的,也可以支持規模更大的線程數量,部分高性能數據庫中的多線程就是由用戶線程實現的。這種線程與用戶線程之間1:N的關系稱為一對多的線程模型,如圖所示
使用用戶線程的優勢在于不需要系統內核支援,劣勢也在于沒有系統內核的支援,所有的線程操作都需要用戶程序自己處理。線程的創建、切換和調度都是需要考慮的問題,而且由于操作系統只把處理器資源分配到進程,那注入“阻塞如何處理”、“多處理器系統中如何將線程映射到其他處理器上”這類問題解決起來就會異常困難,甚至不可能完成。因而使用用戶線程實現的程序一般都比較復雜,除了以前在不支持多線程的操作系統中的多線程和少數特殊需求的程序外,現在使用用戶線程的程序越來越少,Java、Ruby等語言都曾經使用過用戶線程,但最終又都放棄了。
使用用戶線程+輕量級進程混合實現
線程除了依賴內核線程實現和完全由用戶程序自己實現之外,還有一種將內核線程與用戶線程一起使用的實現方式。在這種混合實現下,既存在用戶線程,也存在輕量級進程。用戶線程還是完全建立在用戶空間中,因此用戶線程的創建、切換、析構等操作依然廉價,并且可以支持大規模的用戶線程并發。而操作系統提供支持的輕量級進程則作為用戶線程和內核線程之間的橋梁,這樣可以使用內核提供的線程調度功能及處理器映射,并且用戶線程的系統調度要通過輕量級進程來完成,大大降低了整個進程被阻塞的風險。在這種混合模式中,用戶線程與輕量級進程的數量比是不一定的,即為N:M的關系。許多UNIX系列的操作系統都提供了N:M的線程模型實現。
Java線程的實現
Java線程在JDK1.2之前,是基于稱為“綠色線程”的用戶線程實現的,而在JDK1.2中,線程模型替換成基于操作系統原生線程模型來實現。因此在目前的JDK版本中,操作系統支持怎樣的線程模型,在很大程度上決定了Java虛擬機的線程是怎樣的映射,這點不同平臺沒有辦法達成一致,虛擬機規范中也并未限定Java線程需要使用哪些線程模型來實現。線程模型只對線程的并發規模和操作成本產生影響,對Java程序的編碼和運行過程來說,這些差異都是透明的。
對于Sun JDK來說,它的Windows版和Linux版都是使用一對一的模型實現的,一條Java線程就映射到一條輕量級進程中,因為Windows和Linux系統提供的線程模型就是一對一的。
但是在Solaris平臺,由于操作系統的線程特性可以同時支持一對一和多對多的線程模型,因此在Solaris版的JDK也對應提供了兩個平臺專有的的虛擬機參數:-XX:UseLWPSynchronization(默認值)和-XX:UseBoundThreads來明確指定虛擬機適用哪種線程模型。
Java線程調度
線程調度是指系統為線程分配處理器使用權的過程,主要調度方式有兩種,分別是協同式線程調度(Cooperative )和搶占式線程調度(Preemptive Threads-Scheduling)。
如果使用協同式調度的多線程系統,線程的執行時間由線程本身來控制,線程把自己的工作執行完了之后,要主動通知系統切換到另外一個線程上。協同式多線程的最大好處是實現簡單,而且由于線程要把自己的事情干完后才會進行線程切換,切換操作對線程自己是可知的,所以沒有什么線程同步的問題。Lua語言中的“協同例程”就是這類實現。它的壞處也很明顯:線程執行時間不可控制,甚至如果一個線程編寫的問題,一直不告知系統進行線程切換,那么程序就會一直阻塞在那里。很久以前的Windows3.x系統就是使用協同式來實現多線程多任務的,相當不穩定,一個進程堅持不讓出CPU執行時間就可能會導致整個系統崩潰。
如果使用搶占式調度的多線程系統,那么每個線程將由系統來分配執行時間,線程的切換不由線程本身來決定(在Java中,Thread.yield()可以讓出執行時間,但是要獲取執行時間的話,線程本身是沒有什么辦法的)。在這種實現線程調度的方式下,線程的執行時間是系統可控的,也不會有一個線程導致整個進程阻塞的問題,Java使用的線程調度方式就是搶占式調度。與前面所說的Windows3.x的例子相對,在Windows9x/NT內核中就是使用搶占式來實現多進程的,當一個進程除了問題,我們還可以使用任務管理器把這個進程殺掉,而不至于導致系統崩潰。
雖然Java線程調度是系統自動完成的,但是我們還是可以“建議”系統給默寫線程多分配一點執行時間,另外的一些線程則可以少分配一點,這項操作可以通過設置線程優先級來完成。Java語言一共設置了10個級別的線程優先級(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),這兩個線程同時處于Ready狀態時,優先級越高的線程越容易被系統選擇執行。
不過優先級并不是太靠譜,原因是Java線程是通過映射到系統的原生線程上來實現的,所以線程調度最終還是取決于操作系統,雖然現在很多操作系統都提供線程優先級的概念,但是并不能和Java線程的優先級一一對應,如Solaris有2^32種概念,但win只有7種。并且一些平臺中不同的優先級實際上也會變得相同,優先級會被系統自行改變,例如,在win中存在一個稱謂“優先級推進去”的功能,它的大致作用就是當系統發現一個線程執行得特別勤奮的話,可能會越過優先級去為它分配執行時間。
狀態轉換
Java中定義了5種線程狀態,在任意一個時間點,一個線程只能有且僅有一種狀態,如下:
- 新建(New):創建后尚未啟動的線程
- 運行(Runable):Runable包括了操作系統線程狀態中的Running和Ready,也就是處于此狀態的線程有可能正在執行,也有可能賑災等待CPU為它分配執行時間。
- 無限等待(Waiting):處于這種狀態的線程不會被分配CPU執行時間,它們要等待被其他線程顯式的喚醒。以下方法會讓線程陷入無限等待狀態:
- 沒有設置Timeout參數的Object.wait()方法
- 沒有設置Timeout參數的Thread.join()方法
- LockSupport.park()方法
- 限期等待(Timed Waiting):處于這種狀態的線程也不會被分配CPU執行時間,不過無須等待被其他線程顯示的喚醒,在一定時間后它們會由系統自動喚醒。以下方法會讓線程進入限期等待狀態:
- Thread.sleep()方法
- 設置了Timeout參數的Object.wait()方法
- 設置了Timeout參數的Thread.join()方法
- LockSupport.parkNanos()方法
- LockSupport.parkUnit()方法
- 阻塞(Blocked):線程被阻塞了,“阻塞狀態”和“等待狀態”的區別在于:“阻塞狀態”在等待著獲取到一個排他鎖,這個時間將在另外一個線程放棄這個鎖的時候發生;而“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程序等待進入同步區域的時候,線程將進入這種狀態。
- 結束(Terminated):已終止線程的線程狀態,線程已經結束執行。