本文中部分內(nèi)容引用至《深入理解Java虛擬機:JVM高級特性與最佳實踐(第2版)》第12章,如果有興趣可自行深入閱讀,文末放有書籍PDF版本連接。
物理機遇到的并發(fā)問題其實與虛擬機中的情況有很多相似之處,所以物理機對并發(fā)的處理方案對于虛擬機的實現(xiàn)也有比較大的參考意義
????“充分的利用計算機效能”和“讓計算機并發(fā)運行多個任務(wù)”之間的關(guān)系,看上去是緊密相連的,但是實際上并沒有想象中的那么簡單,這其中有一個重要的原因在于計算機的大多數(shù)任務(wù)不能僅僅只依靠處理器去完成,處理器至少需要和內(nèi)存打交道,比如讀取數(shù)據(jù)、保存結(jié)果等,而這一過程中的I/O是無法避免的。由于計算機的存儲設(shè)備與處理器的運算速度有幾個數(shù)量級的差距,所以現(xiàn)代計算機系統(tǒng)都不得不加入一層讀寫速度盡可能接近處理器運算速度的高速緩存(Cache)來作為內(nèi)存與處理器之間的緩沖:將運算需要使用到的數(shù)據(jù)復(fù)制到緩存中,讓運算能快速進行,當(dāng)運算結(jié)束后再從緩存同步回內(nèi)存之中,這樣處理器就無須等待緩慢的內(nèi)存讀寫了。
????但是這樣這種情況也帶來了另一個問題,“緩存一致性(Cache Coherence)”。
(重要概念)
多處理器中,每一個處理器都擁有自己的高速緩存,同時又共享同一主內(nèi)存,如下圖所示
當(dāng)多個處理器對同一塊內(nèi)存區(qū)域進行運算的時候,將可能導(dǎo)致各自的緩存數(shù)據(jù)不一致,如果發(fā)生這種情況,那同步回到主內(nèi)存時以誰的緩存數(shù)據(jù)為準(zhǔn)呢?為了解決一致性的問題,需要各個處理器訪問緩存時都遵循一些協(xié)議,在讀寫時要根據(jù)協(xié)議來進行操作,這類協(xié) 議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。在本中將會多次提到的“內(nèi)存模型”一詞,可以理解為在特定的操作協(xié)議下,對特定的內(nèi)存或高速緩存進行讀寫訪問的過程抽象。不同架構(gòu)的物理機器可以擁有不一樣的內(nèi)存模型,而Java虛擬機也有自己的內(nèi)存模型,并且這里介紹的內(nèi)存訪問操作與硬件的緩存訪問操作具有很高的可比性。
除了增加高速緩存之外,為了使得處理器內(nèi)部的運算單元能盡量被 充分利用,處理器可能會對輸入代碼進行亂序執(zhí)行(Out-Of-Order Execution)優(yōu)化,處理器會在計算之后將亂序執(zhí)行的結(jié)果重組,保證該 結(jié)果與順序執(zhí)行的結(jié)果是一致的,但并不保證程序中各個語句計算的先 后順序與輸入代碼中的順序一致,因此,如果存在一個計算任務(wù)依賴另 外一個計算任務(wù)的中間結(jié)果,那么其順序性并不能靠代碼的先后順序來 保證。與處理器的亂序執(zhí)行優(yōu)化類似,Java虛擬機的即時編譯器中也有 類似的指令重排序(Instruction Reorder)優(yōu)化。
Java虛擬機規(guī)范中試圖定義一種Java內(nèi)存模型(Java Memory Model,JMM)來屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,以實現(xiàn)讓 Java程序在各種平臺下都能達到一致的內(nèi)存訪問效果。在此之前,主流程序語言(如C/C 等)直接使用物理硬件和操作系統(tǒng)的內(nèi)存模型,因此,會由于不同平臺上內(nèi)存模型的差異,有可能導(dǎo)致程序在一套平臺上 并發(fā)完全正常,而在另外一套平臺上并發(fā)訪問卻經(jīng)常出錯,因此在某些場景就必須針對不同的平臺來編寫程序。
定義Java內(nèi)存模型并非一件容易的事情,這個模型必須定義得足夠嚴(yán)謹(jǐn),才能讓Java的并發(fā)內(nèi)存訪問操作不會產(chǎn)生歧義;但是,也必須定義得足夠?qū)捤桑沟锰摂M機的實現(xiàn)有足夠的自由空間去利用硬件的各種 特性(寄存器、高速緩存和指令集中某些特有的指令)來獲取更好的執(zhí) 行速度。經(jīng)過長時間的驗證和修補,在JDK 1.5(實現(xiàn)了JSR-133)發(fā)布后,Java內(nèi)存模型已經(jīng)成熟和完善起來了。
Java內(nèi)存模型主要是定義變量的訪問規(guī)則,通俗的來講就是Java虛擬機對變量的存、取操作的底層細節(jié)。這里所說的變量指的是靜態(tài)變量、常量、構(gòu)成數(shù)組的元素、實例字段,不包含局部變量和方法參數(shù),兩者在Java虛擬機中屬于線程私有,不會存在線程安全問題。
Java虛擬機為了保持程序執(zhí)行的高效率,在定義內(nèi)存模型的同時并沒有限制執(zhí)行引擎使用處理器特定的寄存器或緩存來和主內(nèi)存交互,也沒有限制即時編譯器對代碼執(zhí)行順序進行調(diào)整。
Java內(nèi)存模型規(guī)定了所有的變量都存儲在主內(nèi)存(Main Memory) 中(此處的主內(nèi)存與介紹物理硬件時的主內(nèi)存名字一樣,兩者也可以互相類比,但此處僅是虛擬機內(nèi)存的一部分)。每條線程還有自己的工作內(nèi)存(Working Memory,可與前面講的處理器高速緩存類比),線程的工作內(nèi)存中保存了被該線程使用到的變量的主內(nèi)存副本拷貝,線程 對變量的所有操作(讀取、賦值等)都必須在工作內(nèi)存中進行,而不能直接讀寫主內(nèi)存中的變量。不同的線程之間也無法直接訪問對方工作 內(nèi)存中的變量,線程間變量值的傳遞均需要通過主內(nèi)存來完成,線程、 主內(nèi)存、工作內(nèi)存三者的交互關(guān)系如下圖所示。
注意: Java內(nèi)存模型與內(nèi)存結(jié)構(gòu)不能混為一談,內(nèi)存結(jié)構(gòu)是指Java堆、棧、方法區(qū)等
Java內(nèi)存模型定義了以下8種操作,虛擬機實現(xiàn)時需要保證以下操作均是原子的,不可再分的(double和long類型在32位和64位平臺上有一定區(qū)別需要特別留意)
lock(鎖定):作用于主內(nèi)存的變量,它把一個變量標(biāo)識為一條線程獨占的狀態(tài)。
unlock(解鎖):作用于主內(nèi)存的變量,它把一個處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定。
read(讀?。鹤饔糜谥鲀?nèi)存的變量,它把一個變量的值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動作使用。
load(載入):作用于工作內(nèi)存的變量,它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中。
use(使用):作用于工作內(nèi)存的變量,它把工作內(nèi)存中一個變量的值傳遞給執(zhí)行引擎,每當(dāng)虛擬機遇到一個需要使用到變量的值的字節(jié) 碼指令時將會執(zhí)行這個操作。
assign(賦值):作用于工作內(nèi)存的變量,它把一個從執(zhí)行引擎接收到的值賦給工作內(nèi)存的變量,每當(dāng)虛擬機遇到一個給變量賦值的字節(jié) 碼指令時執(zhí)行這個操作。
store(存儲):作用于工作內(nèi)存的變量,它把工作內(nèi)存中一個變量的值傳送到主內(nèi)存中,以便隨后的write操作使用。
write(寫入):作用于主內(nèi)存的變量,它把store操作從工作內(nèi)存中得到的變量的值放入主內(nèi)存的變量中。
如果要把一個變量從主內(nèi)存復(fù)制到工作內(nèi)存,那就要順序地執(zhí)行 read和load操作,如果要把變量從工作內(nèi)存同步回主內(nèi)存,就要順序地 執(zhí)行store和write操作。注意,Java內(nèi)存模型只要求上述兩個操作必須按 順序執(zhí)行,而沒有保證是連續(xù)執(zhí)行。也就是說,read與load之間、store 與write之間是可插入其他指令的,如對主內(nèi)存中的變量a、b進行訪問 時,一種可能出現(xiàn)順序是read a、read b、load b、load a。除此之外, Java內(nèi)存模型還規(guī)定了在執(zhí)行上述8種基本操作時必須滿足如下規(guī)則:
不允許read和load、store和write操作之一單獨出現(xiàn),即不允許一個變量從主內(nèi)存讀取了但工作內(nèi)存不接受,或者從工作內(nèi)存發(fā)起回寫了但主內(nèi)存不接受的情況出現(xiàn)。
不允許一個線程丟棄它的最近的assign操作,即變量在工作內(nèi)存中改變了之后必須把該變化同步回主內(nèi)存。
不允許一個線程無原因地(沒有發(fā)生過任何assign操作)把數(shù)據(jù)從線程的工作內(nèi)存同步回主內(nèi)存中。
一個新的變量只能在主內(nèi)存中“誕生”,不允許在工作內(nèi)存中直接使用一個未被初始化(load或assign)的變量,換句話說,就是對一個變 量實施use、store操作之前,必須先執(zhí)行過了assign和load操作。
一個變量在同一個時刻只允許一條線程對其進行l(wèi)ock操作,但lock操作可以被同一條線程重復(fù)執(zhí)行多次,多次執(zhí)行l(wèi)ock后,只有執(zhí)行相同 次數(shù)的unlock操作,變量才會被解鎖。
如果對一個變量執(zhí)行l(wèi)ock操作,那將會清空工作內(nèi)存中此變量的值,在執(zhí)行引擎使用這個變量前,需要重新執(zhí)行l(wèi)oad或assign操作初始 化變量的值。
如果一個變量事先沒有被lock操作鎖定,那就不允許對它執(zhí)行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。
對一個變量執(zhí)行unlock操作之前,必須先把此變量同步回主內(nèi)存中(執(zhí)行store、write操作)。
Java虛擬機為volatile類型定義了一些特有的規(guī)則,當(dāng)一個變量定義為volatile之后,它將具備兩種特性,第一是保證此變量對所有線程的可見性,這里的“可見性”是指當(dāng)一條線程修改了這個變量的值,新值對于其他線程來說是可以立即得知的。而普通變量不能做到這一點,普通變量的值在線程間傳遞均需要通過主內(nèi)存來完成,例如,線程A修改一個普通變量的值,然后向主內(nèi)存進行回寫,另外一條 線程B在線程A回寫完成了之后再從主內(nèi)存進行讀取操作,新變量值才會對線程B可見。
同時volatile對于很多人而言存在很多誤解,其中有很多人認(rèn)為volatile修飾的變量對內(nèi)存是立即可見的所以自然而然的認(rèn)為是線程安全的
,然而這個觀點是存在一定問題的。volatile修飾的變量對所有線程可見,但是變量本身的修改操作并不是原子性操作,這就導(dǎo)致volatile變量在多線程情況下并不一定是線程安全的,請看一下例子
import java.util.concurrent.CountDownLatch;public class VolatileTest { private static volatile int total; private static final int THREAD_COUNT = 10; private static void increase() { total ; } public static void main(String[] args) throws InterruptedException { total = 0; CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT); Thread[] threads = new Thread[THREAD_COUNT]; for (int i = 0; i < THREAD_COUNT; i ) { threads[i] = new Thread(new Runnable() { @Override public void run() { for (int a = 0; a < 100; a ) { increase(); } countDownLatch.countDown(); } }); threads[i].start(); } countDownLatch.await(); System.out.println(total); }}
這段代碼啟動了10個線程,每個線程執(zhí)行100次累加,那么預(yù)期的輸出應(yīng)該為1000,但是當(dāng)你實際執(zhí)行之后會發(fā)現(xiàn)每次輸出的結(jié)果大多數(shù)是小于1000,且每次輸出的結(jié)果都不一樣。造成這個問題的原因出現(xiàn)在total ,利用javap可以查看關(guān)鍵部分字節(jié)碼指令,如下:
public static void increase(); descriptor: ()V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=0, args_size=0 0: getstatic #2 // Field total:I 3: iconst_1 4: iadd 5: putstatic #2 // Field total:I 8: return LineNumberTable: line 10: 0 line 11: 8
volatile保證了getstatic操作獲取的值是正確的,但是在執(zhí)行iconst_1(棧指令,入棧),iadd過程中,其他線程有可能已經(jīng)計算完畢并且將數(shù)據(jù)更新到主內(nèi)存中,這樣就導(dǎo)致當(dāng)前線程所擁有的的數(shù)據(jù)變成了過期數(shù)據(jù)
,而后putstatic操作則會將過期數(shù)據(jù)更新至主內(nèi)存。當(dāng)所有線程執(zhí)行完畢后,total的值便會小于預(yù)期數(shù)。
由于volatile變量只能保證可見性,所以在實際運用中還需要遵循以下規(guī)則:
運算結(jié)果并不依賴變量的當(dāng)前值,或者能夠確保只有單一的線程修改變量的值。
變量不需要與其他的狀態(tài)變量共同參與不變約束。
不過在下面這種情況中,volatile變量就非常適合
public class VolatileDemo { private static volatile boolean stop; public static void main(String[] args) { while (!stop) { // do something } } public static void stop(){ stop=true; }}
由于volatile變量對所有線程保持可見性,當(dāng)變量被賦值為true時,就會停止循環(huán),而在賦值的過程中沒有出現(xiàn)非原子性操作,所以這種方法是可靠并且安全的。
Java內(nèi)存模型要求lock、unlock、read、load、assign、use、store、 write這8個操作都具有原子性,但是對于64位的數(shù)據(jù)類型(long和 double),在模型中特別定義了一條相對寬松的規(guī)定:允許虛擬機將沒有被volatile修飾的64位數(shù)據(jù)的讀寫操作劃分為兩次32位的操作來進行, 即允許虛擬機實現(xiàn)選擇可以不保證64位數(shù)據(jù)類型的load、store、read和 write這4個操作的原子性,這點就是所謂的long和double的非原子性協(xié)定 (Nonatomic Treatment ofdouble and long Variables)。
如果有多個線程共享一個并未聲明為volatile的long或double類型的 變量,并且同時對它們進行讀取和修改操作,那么某些線程可能會讀取 到一個既非原值,也不是其他線程修改值的代表了“半個變量”的數(shù)值。
不過這種讀取到“半個變量”的情況非常罕見(在目前商用Java虛擬機中不會出現(xiàn)),因為Java內(nèi)存模型雖然允許虛擬機不把long和double 變量的讀寫實現(xiàn)成原子操作,但允許虛擬機選擇把這些操作實現(xiàn)為具有原子性的操作,而且還“強烈建議”虛擬機這樣實現(xiàn)。在實際開發(fā)中,目前各種平臺下的商用虛擬機幾乎都選擇把64位數(shù)據(jù)的讀寫操作作為原子操作來對待,因此我們在編寫代碼時一般不需要把用到的long和double 變量專門聲明為volatile。
Java內(nèi)存模型圍繞著并發(fā)過程中如何處理原子性、可見性和順序性這三個特征來設(shè)計的。
由Java內(nèi)存模型來直接保證的原子性變量操作包括read、load、assign、use、store和write,我們大致可以認(rèn)為基本數(shù)據(jù)類型的訪問讀寫是具備原子性的(例外就是long和double的非原子性協(xié)定,讀者只要知道這件事情就可以了,無須太過在意這些幾乎不會發(fā)生的例外情況)。
可見性是指當(dāng)一個線程修改了共享變量的 值,其他線程能夠立即得知這個修改。上文在講解volatile變量的時候我們已詳細討論過這一點。Java內(nèi)存模型是通過在變量修改后將新值同步回主內(nèi)存,在變量讀取前從主內(nèi)存刷新變量值這種依賴主內(nèi)存作為傳遞媒介的方式來實現(xiàn)可見性的,無論是普通變量還是volatile變量都是如此,普通變量與volatile變量的區(qū)別是,volatile的特殊規(guī)則保證了新值能立即同步到主內(nèi)存,以及每次使用前立即從主內(nèi)存刷新。因此,可以說 volatile保證了多線程操作時變量的可見性,而普通變量則不能保證這一 點。
Java內(nèi)存模型的有序性在前面講解volatile時 也詳細地討論過了,Java程序中天然的有序性可以總結(jié)為一句話:如果 在本線程內(nèi)觀察,所有的操作都是有序的;如果在一個線程中觀察另一 個線程,所有的操作都是無序的。前半句是指“線程內(nèi)表現(xiàn)為串行的語 義”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排 序”現(xiàn)象和“工作內(nèi)存與主內(nèi)存同步延遲”現(xiàn)象。
如果Java內(nèi)存模型中所有的有序性都僅僅靠volatile和synchronized來完成,那么有一些操作將會變得很煩瑣,但是我們在編寫Java并發(fā)代碼 的時候并沒有感覺到這一點,這是因為Java語言中有一個“先行發(fā) 生”(happens-before)的原則。這個原則非常重要,它是判斷數(shù)據(jù)是否 存在競爭、線程是否安全的主要依據(jù),依靠這個原則,我們可以通過幾條規(guī)則一攬子地解決并發(fā)環(huán)境下兩個操作之間是否可能存在沖突的所有問題。
先行發(fā)生是Java內(nèi)存模 型中定義的兩項操作之間的偏序關(guān)系,如果說操作A先行發(fā)生于操作 B,其實就是說在發(fā)生操作B之前,操作A產(chǎn)生的影響能被操作B觀察到,“影響”包括修改了內(nèi)存中共享變量的值、發(fā)送了消息、調(diào)用了方法等??赏ㄟ^以下偽代碼理解:
//以下操作在線程A中執(zhí)行a=1;//以下操作在線程B中執(zhí)行b=a;//以下操作在線程C中執(zhí)行a=2;
假設(shè)線程A中的操作"a=1"先行發(fā)生于線程B的操作"b=a",那么可以 確定在線程B的操作執(zhí)行后,變量b的值一定等于1,得出這個結(jié)論的依 據(jù)有兩個:一是根據(jù)先行發(fā)生原則,"a=1"的結(jié)果可以被觀察到;二是線 程C還沒“登場”,線程A操作結(jié)束之后沒有其他線程會修改變量a的值。 現(xiàn)在再來考慮線程C,我們依然保持線程A和線程B之間的先行發(fā)生關(guān)系,而線程C出現(xiàn)在線程A和線程B的操作之間,但是線程C與線程B沒有先行發(fā)生關(guān)系,那b的值會是多少呢?答案是不確定!1和2都有可能,因為線程C對變量a的影響可能會被線程B觀察到,也可能不會,這時候線程B就存在讀取到過期數(shù)據(jù)的風(fēng)險,不具備線程安全性。
以下是Java內(nèi)存模型與生俱來的先行發(fā)生關(guān)系
:
程序次序規(guī)則(Program Order Rule):在一個線程內(nèi),按照程序代碼順序,書寫在前面的操作先行發(fā)生于書寫在后面的操作。準(zhǔn)確地說, 應(yīng)該是控制流順序而不是程序代碼順序,因為要考慮分支、循環(huán)等結(jié)構(gòu)。
管程鎖定規(guī)則(Monitor Lock Rule):一個unlock操作先行發(fā)生于后面對同一個鎖的lock操作。這里必須強調(diào)的是同一個鎖,而“后面”是 指時間上的先后順序。
volatile變量規(guī)則(Volatile Variable Rule):對一個volatile變量的寫操作先行發(fā)生于后面對這個變量的讀操作,這里的“后面”同樣是指時間 上的先后順序。
線程啟動規(guī)則(Thread Start Rule):Thread對象的start()方法先行 發(fā)生于此線程的每一個動作。
線程終止規(guī)則(Thread Termination Rule):線程中的所有操作都先 行發(fā)生于對此線程的終止檢測,我們可以通過Thread.join()方法結(jié)束、 Thread.isAlive()的返回值等手段檢測到線程已經(jīng)終止執(zhí)行。
線程中斷規(guī)則(Thread Interruption Rule):對線程interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生,可以通過 Thread.interrupted()方法檢測到是否有中斷發(fā)生。
對象終結(jié)規(guī)則(Finalizer Rule):一個對象的初始化完成(構(gòu)造函 數(shù)執(zhí)行結(jié)束)先行發(fā)生于它的finalize()方法的開始。
傳遞性(Transitivity):如果操作A先行發(fā)生于操作B,操作B先行發(fā)生于操作C,那就可以得出操作A先行發(fā)生于操作C的結(jié)論。