《Java虛擬機規范》中曾試圖定義一種“Java內存模型”(Java Memory Model,JMM)來屏蔽各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平台下都能達到一致的內存訪問效果。在此之前,主流程序語言(如C和C++等)直接使用物理硬件和操作系統的內存模型。因此,由於不同平台上內存模型的差異,有可能導致程序在一套平台上並發完全正常,而在另外一套平台上並發訪問卻經常出錯,所以在某些場景下必須針對不同的平台來編寫程序。
定義Java內存模型並非一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓Java的並發內存訪問操作不會產生歧義;但是也必須定義得足夠寬松,使得虛擬機的實現能有足夠的自由空間去利用硬件的各種特性(寄存器、高速緩存和指令集中某些特有的指令)來獲取更好的執行速度。經過長時間的驗證和修補,直至JDK 5(實現了JSR-133[3])發布后,Java內存模型才終於成熟、完善起來了。
1、主內存與工作內存
Java內存模型的主要目的是定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。此處的變量(Variables)與Java編程中所說的變量有所區別,它包括了實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量與方法參數,因為后者是線程私有的,不會被共享,自然就不會存在競爭問題。為了獲得更好的執行效能,Java內存模型並沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器是否要進行調整代碼執行順序這類優化措施。
Java內存模型規定了所有的變量都存儲在主內存(Main Memory)中(此處的主內存與介紹物理硬件時提到的主內存名字一樣,兩者也可以類比,但物理上它僅是虛擬機內存的一部分)。每條線程還有自己的工作內存(Working Memory,可與前面講的處理器高速緩存類比),線程的工作內存中保存了被該線程使用的變量的主內存副本,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的數據。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的交互關系如圖所示。
線程、主內存、工作內存三者的交互關系
這里所講的主內存、工作內存與Java內存區域中的Java堆、棧、方法區等並不是同一個層次的對內存的划分,這兩者基本上是沒有任何關系的。如果兩者一定要勉強對應起來,那么從變量、主內存、工作內存的定義來看,主內存主要對應於Java堆中的對象實例數據部分,而工作內存則對應於虛擬機棧中的部分區域。從更基礎的層次上說,主內存直接對應於物理硬件的內存,而為了獲取更好的運行速度,虛擬機(或者是硬件、操作系統本身的優化措施)可能會讓工作內存優先存儲於寄存器和高速緩存中,因為程序運行時主要訪問的是工作內存。
2、內存間交互操作
關於主內存與工作內存之間具體的交互協議,即一個變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存這一類的實現細節,Java內存模型中定義了以下8種操作來完成。Java虛擬機實現時必須保證下面提及的每一種操作都是原子的、不可再分的(對於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程序中哪些內存訪問操作在並發下才是安全的。這種定義相當嚴謹,但也是極為煩瑣,實踐起來更是無比麻煩。
可能部分讀者閱讀到這里已經對多線程開發產生恐懼感了,后來Java設計團隊大概也意識到了這個問題,將Java內存模型的操作簡化為read、write、lock和unlock四種,但這只是語言描述上的等價化簡,Java內存模型的基礎設計並未改變,即使是這四操作種,對於普通用戶來說閱讀使用起來仍然並不方便。不過讀者對此無須過分擔憂,除了進行虛擬機開發的團隊外,大概沒有其他開發人員會以這種方式來思考並發問題,我們只需要理解Java內存模型的定義即可。后面介紹這種定義的一個等效判斷原則——Happens-Before原則,用來確定一個操作在並發環境下是否安全的。
3、對於volatile型變量的特殊規則
關鍵字volatile可以說是Java虛擬機提供的最輕量級的同步機制,但是它並不容易被正確、完整地理解,以至於許多程序員都習慣去避免使用它,遇到需要處理多線程數據競爭問題的時候一律使用synchronized來進行同步。了解volatile變量的語義對后面理解多線程操作的其他特性很有意義。
Java內存模型為volatile專門定義了一些特殊的訪問規則,在介紹這些比較拗口的規則定義之前,先用一些不那么正式,但通俗易懂的語言來介紹一下這個關鍵字的作用。
當一個變量被定義成volatile之后,它將具備兩項特性:第一項是保證此變量對所有線程的可見性,這里的“可見性”是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量並不能做到這一點,普通變量的值在線程間傳遞時均需要通過主內存來完成。比如,線程A修改一個普通變量的值,然后向主內存進行回寫,另外一條線程B在線程A回寫完成了之后再對主內存進行讀取操作,新變量值才會對線程B可見。
關於volatile變量的可見性,經常會被開發人員誤解,他們會誤以為下面的描述是正確的:“volatile變量對所有線程是立即可見的,對volatile變量所有的寫操作都能立刻反映到其他線程之中。換句話說,volatile變量在各個線程中是一致的,所以基於volatile變量的運算在並發下是線程安全的”。這句話的論據部分並沒有錯,但是由其論據並不能得出“基於volatile變量的運算在並發下是線程安全的”這樣的結論。volatile變量在各個線程的工作內存中是不存在一致性問題的(從物理存儲的角度看,各個線程的工作內存中volatile變量也可以存在不一致的情況,但由於每次使用之前都要先刷新,執行引擎看不到不一致的情況,因此可以認為不存在一致性問題),但是Java里面的運算操作符並非原子操作,這導致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修飾的變量不會被指令重排序優化,從而保證代碼的執行順序與程序的順序相同。
4、針對long和double型變量的特殊規則
Java內存模型要求lock、unlock、read、load、assign、use、store、write這八種操作都具有原子性,但是對於64位的數據類型(long和double),在模型中特別定義了一條寬松的規定:允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作划分為兩次32位的操作來進行,即允許虛擬機實現自行選擇是否要保證64位數據類型的load、store、read和write這四個操作的原子性,這就是所謂的“long和double的非原子性協定”(Non-Atomic Treatment of double and long Variables)。如果有多個線程共享一個並未聲明為volatile的long或double類型的變量,並且同時對它們進行讀取和修改操作,那么某些線程可能會讀取到一個既不是原值,也不是其他線程修改值的代表了“半個變量”的數值。不過這種讀取到“半個變量”的情況是非常罕見的。
5、原子性、可見性與有序性
介紹完Java內存模型的相關操作和規則后,我們再整體回顧一下這個模型的特征。Java內存模型是
圍繞着在並發過程中如何處理原子性、可見性和有序性這三個特征來建立的,我們逐個來看一下哪些
操作實現了這三個特性。
5.1. 原子性(Atomicity)
由Java內存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write這六個,我們大致可以認為,基本數據類型的訪問、讀寫都是具備原子性的(例外就是long和double的非原子性協定,讀者只要知道這件事情就可以了,無須太過在意這些幾乎不會發生的例外情況)。
如果應用場景需要一個更大范圍的原子性保證(經常會遇到),Java內存模型還提供了lock和unlock操作來滿足這種需求,盡管虛擬機未把lock和unlock操作直接開放給用戶使用,但是卻提供了更
高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作。這兩個字節碼指令反映到Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。
5.2. 可見性(Visibility)
可見性就是指當一個線程修改了共享變量的值時,其他線程能夠立即得知這個修改。上文在講解volatile變量的時候我們已詳細討論過這一點。Java內存模型是通過在變量修改后將新值同步回主內
存,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現可見性的,無論是普通變量還是volatile變量都是如此。普通變量與volatile變量的區別是,volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因此我們可以說volatile保證了多線程操作時變量的可見性,而普通變量則不能保證這一點。
除了volatile之外,Java還有兩個關鍵字能實現可見性,它們是synchronized和final。同步塊的可見性是由“對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操
作)”這條規則獲得的。而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦被初始化完成,並且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到“初始化了一半”的對象),那么在其他線程中就能看見final字段的值。
5.3. 有序性(Ordering)
Java內存模型的有序性在前面講解volatile時也比較詳細地討論過了,Java程序中天然的有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指“線程內似表現為串行的語義”(Within-Thread As-If-SerialSemantics),后半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象。
Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變量在同一個時刻只允許一條線程對其進行lock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入。
介紹完並發中三種重要的特性,讀者是否發現synchronized關鍵字在需要這三種特性的時候都可以作為其中一種的解決方案?看起來很“萬能”吧?的確,絕大部分並發控制操作都能使用synchronized來完成。synchronized的“萬能”也間接造就了它被程序員濫用的局面,越“萬能”的並發控制,通常會伴隨着越大的性能影響,
6、Happens-Before 規則
Happens-Before 並不是說前面一個操作發生在后續操作的前面,它真正要表達的是:前面一個操作的結果對后續操作是可見的。就像有心靈感應的兩個人,雖然遠隔千里,一個人心之所想,另一個人都看得到。Happens-Before 規則就是要保證線程之間的這種“心靈感應”。所以比較正式的說法是:Happens-Before 約束了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器優化后一定遵守 Happens-Before 規則。
下面是Java內存模型下一些“天然的”先行發生關系,這些先行發生關系無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關系不在此列,並且無法從下列規則推導出
來,則它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序。
-
程序次序規則(Program Order Rule):在一個線程內,按照控制流順序,書寫在前面的操作先行發生於書寫在后面的操作。注意,這里說的是控制流順序而不是程序代碼順序,因為要考慮分支、循環等結構。
-
管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於后面對同一個鎖的lock操作。這里必須強調的是“同一個鎖”,而“后面”是指時間上的先后。
-
volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於后面對這個變量的讀操作,這里的“后面”同樣是指時間上的先后。
-
線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。
-
線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止執行。
-
線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread::interrupted()方法檢測到是否有中斷發生。
-
對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
-
傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
時間先后順序與先行發生原則之間基本沒有因果關系,所以我們衡量並發安全問題的時候不要受時間順序的干擾,一切必須以Happens-Before原則為准。
相關文章
synchronized(this) 與synchronized(class) 之間的區別