簡介
什么是JMM
內存模型可以理解為在特定的操作協議下,對特定的內存或者高速緩存進行讀寫訪問的過程抽象描述,不同架構下的物理機擁有不一樣的內存模型,Java虛擬機是一個實現了跨平台的虛擬系統,因此它也有自己的內存模型,即Java內存模型(Java Memory Model, JMM)。
因此它不是對物理內存的規范,而是在虛擬機基礎上進行的規范從而實現平台一致性,以達到Java程序能夠“一次編寫,到處運行”。
究竟什么是內存模型?
內存模型描述了程序中各個變量(實例域、靜態域和數組元素)之間的關系,以及在實際計算機系統中將變量存儲到內存和從內存中取出變量這樣的底層細節
Java Memory Model(Java內存模型), 圍繞着在並發過程中如何處理可見性、原子性、有序性這三個特性而建立的模型。
JSR-133規范
即JavaTM內存模型與線程規范,由JSR-133專家組開發。本規范是JSR-176(定義了JavaTM平台 Tiger(5.0)發布版的主要特性)的一部分。本規范的標准內容將合並到JavaTM語言規范、JavaTM虛擬機規范以及java.lang包的類說明中。
JSR-133中文版下載
該規范在Java語言規范里面指出了JMM是一個比較開拓性的嘗試,這種嘗試視圖定義一個一致的、跨平台的內存模型,但是它有一些比較細微而且很重要的缺點。它提供大范圍的流行硬件體系結構上的高性能JVM實現,現在的處理器在它們的內存模型上有着很大的不同,JMM應該能夠適合於實際的盡可能多的體系結構而不以性能為代價,這也是Java跨平台型設計的基礎。
其實Java語言里面比較容易混淆的關鍵字主要是synchronized和volatile,也因為這樣在開發過程中往往開發者會忽略掉這些規則,這也使得編寫同步代碼比較困難。
JSR133本身的目的是為了修復原本JMM的一些缺陷而提出的。
JMM結構規范
JMM規定了所有的變量都存儲在主內存(Main Memory)中。每個線程還有自己的工作內存(Working Memory),線程的工作內存中保存了該線程使用到的變量的主內存的副本拷貝,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量(volatile變量仍然有工作內存的拷貝,但是由於它特殊的操作順序性規定,所以看起來如同直接在主內存中讀寫訪問一般)。不同的線程之間也無法直接訪問對方工作內存中的變量,線程之間值的傳遞都需要通過主內存來完成。
在java中,所有實例域、靜態域和數組元素存儲在堆內存中,堆內存在線程之間共享(本文使用“共享變量”這個術語代指實例域,靜態域和數組元素)。局部變量(Local variables),方法定義參數(java語言規范稱之為formal method parameters)和異常處理器參數(exception handler parameters)不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。
主內存和本地內存結構
從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。本地內存是JMM的一個抽象概念,並不真實存在。本地內存它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化之后的一個數據存放位置
從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:
- 首先,線程A把本地內存A中更新過的共享變量刷新到主內存中去。
- 然后,線程B到主內存中去讀取線程A之前已更新過的共享變量。
下面通過示意圖來說明這兩個步驟:
如上圖所示,本地內存A和B有主內存中共享變量x的副本。假設初始時,這三個內存中的x值都為0。線程A在執行時,把更新后的x值(假設值為1)臨時存放在自己的本地內存A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改后的x值刷新到主內存中,此時主內存中的x值變為了1。隨后,線程B到主內存中去讀取線程A更新后的x值,此時線程B的本地內存的x值也變為了1。
從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為java程序員提供內存可見性保證。
JMM的三個特征
Java內存模型是圍繞着並發編程中原子性、可見性、有序性這三個特征來建立的,那我們依次看一下這三個特征:
原子性(Atomicity)
一個操作不能被打斷,要么全部執行完畢,要么不執行。在這點上有點類似於事務操作,要么全部執行成功,要么回退到執行該操作之前的狀態。
基本類型數據的訪問大都是原子操作,long 和double類型的變量是64位,但是在32位JVM中,32位的JVM會將64位數據的讀寫操作分為2次32位的讀寫操作來進行,這就導致了long、double類型的變量在32位虛擬機中是非原子操作,數據有可能會被破壞,也就意味着多個線程在並發訪問的時候是線程非安全的。
下面我們來演示這個32位JVM下,對64位long類型的數據的訪問的問題:
可見性:
一個線程對共享變量做了修改之后,其他的線程立即能夠看到(感知到)該變量的這種修改(變化)。
Java內存模型是通過將在工作內存中的變量修改后的值同步到主內存,在讀取變量前從主內存刷新最新值到工作內存中,這種依賴主內存的方式來實現可見性的。
無論是普通變量還是volatile變量都是如此,區別在於:volatile的特殊規則保證了volatile變量值修改后的新值立刻同步到主內存,每次使用volatile變量前立即從主內存中刷新,因此volatile保證了多線程之間的操作變量的可見性,而普通變量則不能保證這一點。
除了volatile關鍵字能實現可見性之外,還有synchronized,Lock,final也是可以的。
使用synchronized關鍵字,在同步方法/同步塊開始時(Monitor Enter),使用共享變量時會從主內存中刷新變量值到工作內存中(即從主內存中讀取最新值到線程私有的工作內存中),在同步方法/同步塊結束時(Monitor Exit),會將工作內存中的變量值同步到主內存中去(即將線程私有的工作內存中的值寫入到主內存進行同步)。
使用Lock接口的最常用的實現ReentrantLock(重入鎖)來實現可見性:當我們在方法的開始位置執行lock.lock()方法,這和synchronized開始位置(Monitor Enter)有相同的語義,即使用共享變量時會從主內存中刷新變量值到工作內存中(即從主內存中讀取最新值到線程私有的工作內存中),在方法的最后finally塊里執行lock.unlock()方法,和synchronized結束位置(Monitor Exit)有相同的語義,即會將工作內存中的變量值同步到主內存中去(即將線程私有的工作內存中的值寫入到主內存進行同步)。
final關鍵字的可見性是指:被final修飾的變量,在構造函數數一旦初始化完成,並且在構造函數中並沒有把“this”的引用傳遞出去(“this”引用逃逸是很危險的,其他的線程很可能通過該引用訪問到只“初始化一半”的對象),那么其他線程就可以看到final變量的值。
有序性:
對於一個線程的代碼而言,我們總是以為代碼的執行是從前往后的,依次執行的。這么說不能說完全不對,在單線程程序里,確實會這樣執行;但是在多線程並發時,程序的執行就有可能出現亂序。用一句話可以總結為:在本線程內觀察,操作都是有序的;如果在一個線程中觀察另外一個線程,所有的操作都是無序的。前半句是指“線程內表現為串行語義(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”現象和“工作內存和主內存同步延遲”現象。
一個最經典的例子就是銀行匯款問題,一個銀行賬戶存款100,這時一個人從該賬戶取10元,同時另一個人向該賬戶匯10元,那么余額應該還是100。那么此時可能發生這種情況,A線程負責取款,B線程負責匯款,A從主內存讀到100,B從主內存讀到100,A執行減10操作,並將數據刷新到主內存,這時主內存數據100-10=90,而B內存執行加10操作,並將數據刷新到主內存,最后主內存數據100+10=110,顯然這是一個嚴重的問題,我們要保證A線程和B線程有序執行,先取款后匯款或者先匯款后取款,此為有序性。
Java提供了兩個關鍵字volatile和synchronized來保證多線程之間操作的有序性,volatile關鍵字本身通過加入內存屏障來禁止指令的重排序,而synchronized關鍵字通過一個變量在同一時間只允許有一個線程對其進行加鎖的規則來實現,
在單線程程序中,不會發生“指令重排”和“工作內存和主內存同步延遲”現象,只在多線程程序中出現。
關鍵詞synchronized與volatile總結
synchronized的特點
一個線程執行互斥代碼過程如下:
1. 獲得同步鎖;
2. 清空工作內存;
3. 從主內存拷貝對象副本到工作內存;
4. 執行代碼(計算或者輸出等);
5. 刷新主內存數據;
6. 釋放同步鎖。
所以,synchronized既保證了多線程的並發有序性,又保證了多線程的內存可見性。
volatile是第二種Java多線程同步的手段,根據JLS的說法,一個變量可以被volatile修飾,在這種情況下內存模型確保所有線程可以看到一致的變量值
class Test { static volatile int i = 0, j = 0; static void one() { i++; j++; } static void two() { System.out.println("i=" + i + " j=" + j); } }
加上volatile可以將共享變量i和j的改變直接響應到主內存中,這樣保證了i和j的值可以保持一致,然而我們不能保證執行two方法的線程是在i和j執行到什么程度獲取到的,所以volatile可以保證內存可見性,不能保證並發有序性。
如果沒有volatile,則代碼執行過程如下:
1. 將變量i從主內存拷貝到工作內存;
2. 刷新主內存數據;
3. 改變i的值;
4. 將變量j從主內存拷貝到工作內存;
5. 刷新主內存數據;
6. 改變j的值;
重排序
在執行程序時為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分三種類型:
- 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序。現代處理器采用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
- 內存系統的重排序。由於處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。
從java源代碼到最終實際執行的指令序列,會分別經歷下面三種重排序:
上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序都可能會導致多線程程序出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是所有的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求java編譯器在生成指令序列時,插入特定類型的內存屏障(memory barriers,intel稱之為memory fence)指令,通過內存屏障指令來禁止特定類型的處理器重排序(不是所有的處理器重排序都要禁止)。
JMM屬於語言級的內存模型,它確保在不同的編譯器和不同的處理器平台之上,通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內存可見性保證。
#### 處理器重排序與內存屏障指令
現代的處理器(物理處理器即CPU)使用寫緩沖區來臨時保存向內存寫入的數據。寫緩沖區可以保證指令流水線持續運行,它可以避免由於處理器停頓下來等待向內存寫入數據而產生的延遲。同時,通過以批處理的方式刷新寫緩沖區,以及合並寫緩沖區中對同一內存地址的多次寫,可以減少對內存總線的占用。雖然寫緩沖區有這么多好處,但每個處理器上的寫緩沖區,僅僅對它所在的處理器可見。這個特性會對內存操作的執行順序產生重要的影響:處理器排序后對內存的讀/寫操作的執行順序,不一定與內存實際發生的讀/寫操作順序一致!為了具體說明,請看下面示例:
Processor A | Processor B |
---|---|
a = 1; //A1 | b = 2; //B1 |
x = b; //A2 | y = a; //B2 |
初始狀態:a = b = 0 | |
處理器允許執行后得到結果:x = y = 0 |
假設處理器A和處理器B按程序的順序並行執行內存訪問,最終卻可能得到x = y = 0的結果。具體的原因如下圖所示:
這里處理器A和處理器B可以同時把共享變量寫入自己的寫緩沖區(A1,B1),然后從內存中讀取另一個共享變量(A2,B2),最后才把自己寫緩存區中保存的臟數據刷新到內存中(A3,B3)。當以這種時序執行時,程序就可以得到x = y = 0的結果。
從內存操作實際發生的順序來看,直到處理器A執行A3來刷新自己的寫緩存區,寫操作A1才算真正執行了。雖然處理器A執行內存操作的順序為:A1->A2,但內存操作實際發生的順序卻是:A2->A1。此時,處理器A的內存操作順序被重排序了(處理器B的情況和處理器A一樣,這里就不贅述了)。
這里的關鍵是,由於寫緩沖區僅對自己的處理器可見,它會導致處理器執行內存操作的順序可能會與內存實際的操作執行順序不一致。由於現代的處理器都會使用寫緩沖區,因此現代的處理器都會允許對寫-讀操做重排序。
下面是常見處理器允許的重排序類型的列表:
Load-Load | Load-Store | Store-Store | Store-Load | 數據依賴 | |
sparc-TSO | N | N | N | Y | N |
x86 | N | N | N | Y | N |
ia64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
上表單元格中的“N”表示處理器不允許兩個操作重排序,“Y”表示允許重排序。
從上表我們可以看出:
- 常見的處理器都允許Store-Load重排序;
- 常見的處理器都不允許對存在數據依賴的操作做重排序。sparc-TSO和x86擁有相對較強的處理器內存模型,它們僅允許對寫-讀操作做重排序(因為它們都使用了寫緩沖區)。
※注1:sparc-TSO是指以TSO(Total Store Order)內存模型運行時,sparc處理器的特性。
※注2:上表中的x86包括x64及AMD64。
※注3:由於ARM處理器的內存模型與PowerPC處理器的內存模型非常類似,本文將忽略它。
※注4:數據依賴性后文會專門說明。
為了保證內存可見性,java編譯器在生成指令序列的適當位置會插入內存屏障指令來禁止特定類型的處理器重排序。JMM把內存屏障指令分為下列四類:
屏障類型 | 指令示例 | 說明 |
LoadLoad Barriers | Load1; LoadLoad; Load2 | 確保Load1數據的裝載,之前於Load2及所有后續裝載指令的裝載。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 確保Store1數據對其他處理器可見(刷新到內存),之前於Store2及所有后續存儲指令的存儲。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 確保Load1數據裝載,之前於Store2及所有后續的存儲指令刷新到內存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 確保Store1數據對其他處理器變得可見(指刷新到內存),之前於Load2及所有后續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有內存訪問指令(存儲和裝載指令)完成之后,才執行該屏障之后的內存訪問指令。 |
StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他三個屏障的效果。現代的多處理器大都支持該屏障(其他類型的屏障不一定被所有處理器支持)。執行該屏障開銷會很昂貴,因為當前處理器通常要把寫緩沖區中的數據全部刷新到內存中(buffer fully flush)。
數據依賴性
如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分下列三種類型:
名稱 | 代碼示例 | 說明 |
---|---|---|
寫后讀 | a = 1; b = a; | 寫一個變量之后,再讀這個位置。 |
寫后寫 | a = 1;a = 2; | 寫一個變量之后,再寫這個變量。 |
讀后寫 | a = b;b = 1; | 讀一個變量之后,再寫這個變量。 |
上面三種情況,只要重排序兩個操作的執行順序,程序的執行結果將會被改變。
前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。
注意,這里所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。
as-if-serial語義
as-if-serial語義的意思指:不管怎么重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守as-if-serial語義。
【例】
double pi = 3.14; //A double r = 1.0; //B double area = pi * r * r; //C
如上圖所示,A和C之間存在數據依賴關系,同時B和C之間也存在數據依賴關系。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的結果將會被改變)。但A和B之間沒有數據依賴關系,編譯器和處理器可以重排序A和B之間的執行順序。下圖是該程序的兩種執行順序:
as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單線程程序的程序員創建了一個幻覺:單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。
先行發生(happens-before)原則:
前面所述的內存交互操作必須要滿足一定的規則,而happens-before就是定義這些規則的一個等效判斷原則。happens-before是JMM定義的2個操作之間的偏序關系:如果操作A線性發生於操作B,則A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。如果兩個操作滿足happens-before原則,那么不需要進行同步操作,JVM能夠保證操作具有順序性,此時不能夠隨意的重排序。否則,無法保證順序性,就能進行指令的重排序。
Java內存模型中定義的兩項操作之間的次序關系,如果說操作A先行發生於操作B,操作A產生的影響能被操作B觀察到,“影響”包含了修改了內存中共享變量的值、發送了消息、調用了方法等。
下面是Java內存模型下一些”天然的“happens-before關系,這些happens-before關系無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關系不在此列,並且無法從下列規則推導出來的話,它們就沒有順序性保障,虛擬機可以對它們進行隨意地重排序。
a.程序次序規則(Pragram Order Rule):在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生於書寫在后面的操作。准確地說應該是控制流順序而不是程序代碼順序,因為要考慮分支、循環結構。
b.管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於后面對同一個鎖的lock操作。這里必須強調的是同一個鎖,而”后面“是指時間上的先后順序。
c.volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於后面對這個變量的讀取操作,這里的”后面“同樣指時間上的先后順序。
d.線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。
e.線程終於規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束,Thread.isAlive()的返回值等作段檢測到線程已經終止執行。
f.線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測是否有中斷發生。
g.對象終結規則(Finalizer Rule):一個對象初始化完成(構造方法執行完成)先行發生於它的finalize()方法的開始。
g.傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
一個操作”時間上的先發生“不代表這個操作會是”先行發生“,那如果一個操作”先行發生“是否就能推導出這個操作必定是”時間上的先發生 “呢?也是不成立的,一個典型的例子就是指令重排序。所以時間上的先后順序與happens-before原則之間基本沒有什么關系,所以衡量並發安全問題一切必須以happens-before 原則為准。
注意:不同操作時間先后順序與先行發生原則之間沒有關系,二者不能相互推斷,衡量並發安全問題不能受到時間順序的干擾,一切都要以happens-before原則為准
示例
private int value = 0; public void setValue(int value) { this.value = value; } public int getValue() { return this.value; }
對於上面的代碼,假設線程A在時間上先調用setValue(1),然后線程B調用getValue()方法,那么線程B收到的返回值一定是1嗎?
按照happens-before原則,兩個操作不在同一個線程、沒有通道鎖同步、線程的相關啟動、終止和中斷以及對象終結和傳遞性等規則都與此處沒有關系,因此這兩個操作是不符合happens-before原則的,這里的並發操作是不安全的,返回值並不一定是1。
對於該問題的修復,可以使用lock或者synchronized套用“管程鎖定規則”實現先行發生關系;或者將value定義為volatile變量(兩個方法的調用都不存在數據依賴性),套用“volatile變量規則”實現先行發生關系。如此一來,就能保證並發安全性。