Java内存模型、volatile、原子性、可见性、有序性、happens-before原则
目錄
1.硬件的效率與一致性:
緩存一致性(Cache Coherence)
2.Java內存模型
2.1主內存與工作內存
2.2內存間的交互
2.3 volatile型變量的特殊規則
2.3.1 保證此變量對所有線程的可見性;
2.3.2 禁止指令重排序優化
2.3.4 在volatile與鎖之中選擇的唯一依據
2.3.4 JMM中對volatile變量定義的特殊規則
2.4對于double和long型變量的特殊規則
2.5 原子性、可見性和有序性
2.5.1原子性atomicity
2.5.2 可見性visibility
2.5.3 有序性 ordering
2.6 先行發生原則happens-before
JMM的天然的先行發生關系
并發處理的廣泛應用是使得Amdahl定律代替摩爾定律成為計算機性能發展原動力的根本原因。
多任務處理:讓計算機同時去做幾件事情。
- 很重要的原因:計算機的運算速度與它的存儲和通信子系統虛度差距太大,大量的時間都花費在磁盤I/O、網絡通信或數據庫訪問上。
每秒事務處理數(transacrions per seconds,TPS):衡量一個服務性能的高低好壞。
- 它代表著一秒內服務端平均能響應的請求總數
- 與并發能力有很大關系:
- 計算量相同的任務,程序線程并發協調得有條不紊,效率自然會高;
- 線程之間頻繁發生阻塞甚至死鎖,將會大大降低程序的并發能力。
1. 硬件的效率與一致性:
物理計算機中的并發:
- 執行任務:處理器至少要完成與內存交互,如讀取數據、存儲運算結果等。
- 這個I/O操作很難消除。
- 計算機的存儲設備與處理器的運算速度由幾個數量及的差距,所以現代計算機系統都加入了一層讀寫速度盡可能接近處理器運算速度的高速緩存(cache)來作為內存與處理器之間的緩沖;
- 將運算需要使用到的數據復制到緩存中,讓運算能快速進行;
- 當運算結束后,再從緩存同步到內存中,
- 這樣處理器就無需等待緩慢的內存讀寫了。
基于高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但也引入了一個新問題:
緩存一致性(Cache Coherence)
百度百科:又譯為緩存連貫性、緩存同調,是指保留在高速緩存中的共享資源,保持數據一致性的機制。
在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(Main Memory),如圖所示:
當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致。
這就需要各個處理器訪問緩存時都遵循一些協議:MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。
內存模型:在特定操作協議下,對特定的內存或者告訴緩存進行讀寫訪問的過程抽象。
- 不同架構可以擁有不一樣的內存模型
- JVM也有自己的內存模型。
除了增加高速緩存以外,為了使得處理器內部的運算單元盡量被充分利用,處理器可能會對輸入代碼進行亂序執行(out-of-order execution)優化,處理器會在計算之后將亂序的執行結果重組,保證該結果與順序執行的結果是一致的,但并不保證程序各個語句計算的先后順序與輸入代碼中的順序一致。
所以——如果存在一個計算任務依賴另外一個計算任務的中間結果,那么其順序性并不能靠代碼的先后順序保證。
JVM的即時編譯器中也有和亂序執行優化類似的指令重排序(instruction reorder)優化。
2. Java內存模型
JMM,來屏蔽各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各個平臺下都能達到一致的內存訪問效果。
2.1 主內存與工作內存
JMM的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。
- 變量(variables):包括實例字段、靜態字段和構成數組對象的元素,但不包括局部變量與方法參數。
- 因為后面的是線程私有的,不會被共享。
為了獲得較高的執行效能,JMM并沒有限制執行引擎適用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器進行調整代碼執行順序這類優化措施。
JMM規定,所有的變量都存儲在主內存(Main memory)(可類比,物理硬件是的主內存,虛擬機內存中的一部分)。
每條線程有自己的工作內存(working memory)(可類比。處理器高速緩存)。
- 線程的工作內存保存了被該線程使用到的變量的主內存副本拷貝。
- 線程對變量的所有操作(讀取、賦值等)必須在工作內存中進行,而不能把自己接讀寫主內存中的變量。
- 不同線程之間也無法直接訪問對方的工作內存中的變量。
- 線程間變量值的傳遞均需要通過主內存來完成。
線程、主內存、工作內存三者之間的交互關系如圖:
這里所提到的主內存、工作內存與Java內存區域中的Java堆、棧、方法區等并不是同一個層次的內存劃分?;旧蠜]有聯系。
2.2 內存間的交互
JMM中定義了一下8種操作來完成主內存與工作內存之間具體的交互協議(即一個變量如何從主內存拷貝到工作內存,如何從工作內存同步會主內存之類的實現細節),虛擬機必須保證下面提及的每一種操作都是原子的、不可再分的(對于double和long類型的變量來說,load、store、read和write操作在某些平臺商允許有例外):
除此之外,JMM還規定了在執行者8種基本操作必須滿足如下規則:
2.3 volatile型變量的特殊規則
關鍵字volatile是JVM提供的最輕量級的同步機制。
當一個變量定義為volatile之后,它將具備兩種特性:
2.3.1 保證此變量對所有線程的可見性
- 可見性:指當一條線程修改了這個變量的值,新值對于其他線程來說是可以立即得知的
- 而普通變量的值在線程間傳遞需要通過主內存來完成。
- volatile變量對所有線程是立即可見的,對volatile變量的所有的寫操作都能立即反映到其它線程之中,即volatile變量在各個線程中是一致的。
- 但是并不能得出“基于volatile變量的運算在并發下是安全的”。
- volatile變量在各個線程的工作內存中不存在一致性問題(在各個線程的工作內存中,volatile變量也可以存在不一致的情況,但由于每次使用之前都需要刷新,執行引擎可以看到不一致的情況,因此可以認為不存在一致性問題)。
- 但在Java里面的運算并非原子操作,導致volatile變量的運算在并發下一樣是不安全的。如下代碼演示了可說明原因:
運行結果:
這段代碼發起了20個線程,每個線程對race變量進行1000次自增操作。
如果這段代碼能夠正確并發的話,最后輸出的結果應該是200000.
但運行后并不會得到期望的結果,總小于200000.
問題:自增運算race++
使用Javap反編譯這段代碼后,得到:
public static void increase();Code:Stack=2,Locals=0,Args_size=00: getstatic #13;//Field race:I3: iconst_14: iadd5:putstatic #13;//Field race:I8: return LineNumberTable:line 14:0line 15:8只有一行代碼的increase()方法在Class文件中是由4字節碼指令構成的(return 不是有race++產生的,這條指令可以不計算),從字節碼層面上很容易就分析出并發失敗的原因了:
- 當getstatic指令把race的值取到操作棧頂時,volatile關鍵字保證了race的值此時是正確的;
- 但是在執行iconst_1、iadd這些指令的時候,其它線程可能已經把race的值加大了,而在操作棧頂的值就變成了過起的數據,所以putstatic指令執行后就可能把較小的race值同步回主內存中。
即使編譯出來只有一條字節碼指令,也并不意味著執行這條指令就是一個原子操作。
- 一條字節碼指令在解釋執行時,解釋器將要運行許多行代碼才能實現它的語義,如果是編譯執行,一條字節碼指令也可能轉化為若干條本地機器指令,此處使用-XX:+PrintAssembly參數輸出反匯編來分析會更嚴謹些。
由于volatile變量只能保證可見性,在不符合以下兩條規則的運算場景中,仍需要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性:
某個變量定義為volatile的應用場景:
- 運算結果并不依賴變量的當前值,或者能夠確保自由單一的線程修改變量的值;
- 變量不需要與其他的狀態變量共同參與不變約束。
很適合使用volatile變量來控制并發的場景:
volatile boolean shutdownRequested; public void shutdown() {shutdownRequested=true; } public void dowork() {while(!shutdownRequested) {//do stuff} }當shutdown()方法被調用時,能保證所有線程中執行的dowork()方法都立即停下來。
2.3.2 禁止指令重排序優化
普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。——線程內表現為串行的語義(within-thread as-if-serial semantics)。
如下例子展示了為何指令重排序會干擾并發的執行:
Map configOptions; char[] configText; //此變量必須定義為volatile volatile boolean initialized=false;//假設以下代碼在線程A中執行 //模擬讀取配置信息,當讀取完成后將initialized設置為true以通知其它線程配置可用 configOptions=new HashMap(); configTest=readConfigFile(fileName); processConfigOptions(configText,configOptions); initialized=true;//假設以下代碼在線程B中執行 //等待initialized為true,代表線程A已經把配置信息初始化完成; while(!initialized) {sleep(); } //使用線程A中初始化好的配置信息 doSomethingWithConfig();如果initialized變量沒有用volatile修飾,就可能會由于指令重排序的優化,導致位于線程A的最后一句代碼initialized=true被提前執行(這里雖然使用Java作為偽代碼,但所指的重排序優化是機器及的優化操作,提前執行是指這句話對應的匯編代碼被提前執行),這樣,在線程B中使用配置信息的代碼就可能出現錯誤,而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.getInstance();} }編譯后,這段代碼對instance變量復制部分如下所示:
關鍵變化在于由volatile修飾前面mov%eax,0x150(%esi)這句便是賦值操作)多執行了一個“lock addl $ 0x0, (%esp)"操作,這個操作相當于一個內存屏障(memory barrier或memory fence,指重排序是不能把后面的指令重排序到內存屏障之前的位置),只有一個CPU訪問內存時,并不需要內存屏障;
但如果有兩個或更多CPU訪問同一塊內存,且其中有一個在觀測另一個,就需要內存屏障來保證一致性了。
addl $ 0x0, (%esp)這句指令,把ESP寄存器的值加0,顯然是一個空操作。采用這個空操作而不是空操作指令nop是因為IA32手冊規定lock前綴不允許配合nop指令使用。
關鍵在于Lock前綴。它的作用是使得本CPU的Cache寫入了內存,該寫入動作也會引起別的CPU或者別的內核無效化(Invalidae)其cache,這種操作相當于對cache中的變量做了一次前面介紹的JMM中所說的“store和write”操作。所以通過這樣一個空操作,可以讓前面的volatile變量的修改對其他CPU立即可見。
volatile禁止重排序:
從硬件架構上講,指令重排序是指CPU采用了允許將多條指令不按程序規定的順序分開發送給各個響應的電路單元處理。但并不是說,指令任意重排,CPU需要能正確處理指令以來情況以保障程序能得到正確的執行結果。
所以在本CPU內,重排序看起來依然是有序的。因此lock?addl $ 0x0, (%esp)指令把修改同步到內存中,意味著之前所欲的操作都已經執行完成,這樣便形成了“指令重排序無法越過內存屏障”的效果。
volatile變量讀操作的性能消耗與普通變量幾乎沒有什么差別,但是寫操作可能會慢點,因為他需要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。
2.3.4 在volatile與鎖之中選擇的唯一依據
僅僅是volatile的語義能否滿足使用場景的需求。
2.3.4 JMM中對volatile變量定義的特殊規則
① 在工作內存中每次使用變量前都需要從主內存中刷新最新值;
② 每次修改變量的值之后都必須立刻同步到主內存中;
③ 要求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是對變量W實施的use或assign動作,假定動作G是和動作B相關聯的load或store動作,假定動作Q是和動作G相對應的對W的read或write動作。如果A先于B,那么P先于Q(這條規則要求volatile修飾的變量不會被指令重排序優化,保證代碼的執行順序與程序的順序相同)。
2.4 對于double和long型變量的特殊規則
JMM要求lock、unlock、read、load、assign、use、store、write這8個操作都具有原子性。
對于64位的數據類型(long和double)在模型中特別定義了一條相對較寬松的規定:
允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分為兩次32位的操作來進行。即允許虛擬機實現選擇可以不保證64位數據類型的load、read、store和write這4個操作的原子性?!猯ong和double的非原子性協定(Nonatomic treament of double and long variables)。
如果有多個線程共享一個并未聲明為volatile的long或double類型的變量,并且同時對他們進行讀取和修改操作,那么某些線程可能會讀取到一個既非原值,也不是其它線程修改值的代表了“半個變量”的數值(很罕見)。
- 因為,JMM雖然允許虛擬機不把long和double變量的讀寫實現為原子操作,但允許虛擬機選擇把這些操作時限為具有原子性的操作,而且還“強烈建議”虛擬機這樣實現。
- 因此,在編寫代碼的時候一般不需要把用到的long和double變量專門聲明為volatile。
2.5 原子性、可見性和有序性
2.5.1 原子性atomicity
由JMM來直接保證的原子性變量操作包括:
- read
- load
- assign
- use
- store
- write
可以大致認為基本數據類型的訪問和讀寫是具備原子性的(例外就是long和double的非原子性協定)。
如果應用場景中需要更大范圍的原子性保證(經常會遇到),JMM還提供了lock和unlock操作來滿足這種需求。
盡管虛擬機未把lock和unclock操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令monitoerenter和monitorexit來隱式地使用這兩個操作,這兩個字節碼指令反應在Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。
2.5.2 可見性visibility
指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。
JMM是通過在變量修改后將新值同步回主內存中,在變量讀取前從主內存刷新變量值這種依賴主內存作為傳遞媒介的方式來實現可見性的。
無論是普通變量還是volatile變量都是如此。
普通變量和volatile的區別是:
- volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。
- 因此,volatile保證了多線程操作是變量的可見性
- 而普通變量則不能保證這一點。
Java還有synchronized和final關鍵字可以實現可見性。
- 同步塊的可見性是由“對一個變量執行unlock之前,必須先把此變量同步回主內存中(執行store、write操作)”這條規則獲得的。
- final關鍵字的可見性:被final修飾的字段在構造其中一旦初始化完成,并且構造器沒有把this的引用傳遞出去(this引用逃逸是一件很危險的事,其它線程可能通過這個引用訪問到“初始化了一半”的對象),那在其它線程中就能看見final字段的值。如下代碼:
2.5.3 有序性 ordering
Java程序的天然有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。
- 前半句:線程內表現為串行語義——within-thread as-if-serial semantics
- 后半句:指令重排序現象和工作內存與主內存同步延遲現象
Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性
- volatile關鍵字:本身就包含了禁止指令重排序的語義
- synchronized:是由“一個變量在同一時刻只允許一條線成對其進行lock操作”這條規則獲得的。這條規則決定了持有同一個鎖的兩個同步塊只能串行的進行。
synchronized關鍵字在這三種特性都可以作為一種解決方案。
2.6 先行發生原則happens-before
它是判斷數據是否存在競爭、線程是否安全的重要依據。
它是JMM中定義的兩項操作之間的偏序關系
- 如果說操作A先行發生于操作B,其實就是在說發生在操作B之前,操作A產生的影響能被操作B觀察到。
- 影響:包括修改了內存中的值、發送了消息、調用了方法等。
- 舉例:
分析:
假設線程A的操作“i=1”先行發生于線程B的操作“j=i”,那么可以確定在線程B的操作執行之后,變量j的值一定等于1。得出這個結論的依據有兩個:
- 根據先行發生原則,“i=1”的結果可以被觀察到;
- 線程C還沒登場,A操作結束后,沒有其他線程會修改變量i的值。
再考慮線程C,依然保持A和B之間的先行發生關系,而C出現在A和B之間,但是C和B沒有先行發生關系,那j的值會使多少?
答案是不確定。1和2都有可能。
- C對i的影響可能會被B觀察到,也可能不會
- 這時候,B就存在讀取到過期數據的風險,不具備多線程安全性。
JMM的天然的先行發生關系
無需任何同步器的協助就已經存在,可在編碼中直接利用。
如果兩個操作之間的關系不在此列,并且無法從下列推導出來的話,它們就沒有順序性保障,虛擬機可以隨意地對它們進行重排序。
- 程序次序規則(program order rule):在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生于書寫在后面的操作。準確的說,應該是控制流順序而不是程序代碼的順序,因為要考慮分支、循環等結構。
- 管程鎖定規則(monitor lock rule):一個unlock操作先行發生于后面對同一個鎖的lock操作。這里必須強調的是同一個鎖,而“后面”是指時間上的先后順序。
- volatile變量規則(volatile variables rule):對一個volatile變量的寫操作先行發生于后面對這個變量的讀操作。這里的“后面”是指時間上的額先后順序。
- 線程啟動規則(thread start rule):Thread獨享的start()方法先行發生于此線程的每一個動作。
- 線程終止規則(Thread termination rule):線程中的所有操作都先行發生于對此線程的終止檢測??赏ㄟ^Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止。
- 線程中斷檢測(thread interruption rule):對縣城interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷時間的發生,可通過Thread.interrupted()方法檢測到是否有中斷發生。
- 對象終結規則(finalizer rule):一個對象的初始化完成(構造函數執行結束)先行發生于它的finalizer()方法的開始。
- 傳遞性(transitivity):如果操作A先行發生于操作BB,操作B先行發生于操作C,那就可以得出操作A先行發生于操作C的結論。
“時間上的先后順序”和“先行發生于”有何不同?
private int value=0;public void setValue(int value){this.value=value; }public int getValue(){return value; }假設存在線程A和B,A先(時間上的先后)調用了setValue(1),然后B調用了同一個對象的getValue(),那么B的返回值是什么?
一次分析下先行發生原則中的各項規則:
- A和B不在一個線程中,程序次序原則不適用;
- 沒有同步塊,不會發生lock和unclock操作,管程鎖定規則不適用;
- value變量沒有被volatile修飾,volatile變量規則不適用,
- 線程啟動/終止/終端規則和對象終結規則不適用
- 無適用的先行發生規則。
所以可以斷定,即使A在操作時間上先行發生于B,但是無法確定B中的gertValue()方法的返回結果。即這里面的操作不是線程安全的。
修復這個問題的方法:
- 把getValue()和setValue()都設置為synchronized方法,可套用管程鎖定規則。
- 把value定義為volatile,由于setValue()對value的修改不依賴原值,滿足volatile關鍵字使用場景,可套用volatile白能量規則來實現先行發生關系。
由上面的例子可以得出一個結論:
- 一個操作“時間上的先發生”不代表這個操作會是“先行發生”。
- 一個操作若是“先行發生”也不代表這個操作是“時間上先發生”。典型的例子就是“指令重排”
依據程序次序規則,“int i=1”的操作先行發生于“int j=2”。但是“int j=2”的代碼完全可能被處理器先執行,這并不影響先行發生原則的正確性。因為我們無法再這條線程中感知到這一點。
上面兩個例子,綜合起來可得到:
時間先后順序和先行發生原則之間基本沒有太大的關系。
所以我們衡量并發安全問題的時候不要受到時間順序的干擾,一切必須以現行發生原則為準。
3. JVM參數中的Server模式和Client模式
具體見《java并發編程實戰》P31
對于服務器應用程序,無論是開發階段還是測試階段,當啟動JVM時一定都要指定-server命令行選項。
Server模式的JVM比client模式的JVM進行更多的優化,例如將循環中未被修改的變量提升到循環外部,因此在開發模式(client模式的JVM)中能正確運行的代碼,可能會在部署環境(server模式的JVM)中運行失敗。如下代碼:
?
如果在代碼中忘記把asleep變量聲明為volatile變量,則Server模式的JVM會把asleep變量的判斷提升到循環體外部(這將導致一個無限循環),但Client模式的JVM不會這么做。
在解決開發環境中出現無限循環問題時,解決這個問題的開銷遠小于解決在應用環境中出現無限循環的開銷。
整理自《深入理解Java虛擬機——JVM高級特性與最佳實踐》
總結
以上是生活随笔為你收集整理的Java内存模型、volatile、原子性、可见性、有序性、happens-before原则的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java内存泄露和内存溢出、JVM命令行
- 下一篇: Hashtable源码分析