Java內存模型(JMM)
Java內存模型(JMM)定義了程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。
在Java中,所有實例域、靜態域和數組元素都存在堆內存中,堆內存在線程之間共享,這些變量就是共享變量。
局部變量(Local Variables),方法定義參數(Formal Method Parameters)和異常處理參數(Exception Handler Parameters)不會在線程之間共享,它們不存在內存可見性問題。
JMM抽象結構
圖參考自《Java並發編程的藝術》3-1
上圖是抽象結構,一個包含共享變量的主內存(Main Memory),出於提高效率,每個線程的本地內存中都擁有共享變量的副本。Java內存模型(簡稱JMM)定義了線程和主內存之間的抽象關系,抽象意味着並不具體存在,還涵蓋了其他具體的部分,如緩存、寫緩存區、寄存器等。
此時線程A、B之間是如何進行通信的呢?
- A把本地內存中的更新的共享變量刷新到主內存中。
- B再從主內存中讀取更新后的共享變量。
明確一點,JMM通過控制主內存與每個線程的本地內存之間的交互,確保內存的可見性。
重排序
編譯器和處理器為了優化程序性能會對指令序列進行重新排序,重排序可能會導致多線程出現內存可見性問題。
源碼->最終指令序列
下圖為《Java並發編程的藝術》3-3
編譯器重排序
- 編譯器優化的重排序:編譯器不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
JMM對於編譯器重排序規則會禁止特定類型的編譯器重排序。
處理器重排序
- 指令級並行的重排序:現代處理器采用指令級並行技術(Instruction-Level-Parallelism,ILP)將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應及其指令的執行順序。
- 內存系統的重排序:處理器使用緩存和讀/寫緩沖區,使得加載和存儲的操作看起來在亂序執行。
對於處理器重排序,JMM的處理器重排序會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障指令,以禁止特定類型的處理器重排序。
數據依賴性
如果兩個操作訪問同一變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。
編譯器和處理器會遵守數據依賴性,不會改變存在數據依賴關系的兩個操作的執行順序。(針對單個處理器中執行的指令序列和單個線程中執行的操作)
考慮抽象內存模型,現代處理器處理線程之間數據的傳遞的過程:將數據寫入寫緩沖區,以批處理的方式刷新寫緩沖區,合並寫緩沖區對同一內存地址的多次寫,減少內存總線的占用。但每個寫緩沖區只對它所在的處理器可見,處理器對內存的讀/寫操作可能就會改變。
as-if-serial
不管怎么重排序,(單線程)程序的執行結果不能被改變,同樣,不會對具有數據依賴性的操作進行重排序,相應的,如果不存在數據依賴,就會重排序。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
- C與A訪問同一變量pi、C與B訪問同一變量r,且存在寫操作,具有依賴關系,它們之間不會進行重排序。
- A與B之間不存在依賴關系,編譯器和處理器可以重排序,可以變成B->A->C。
很明顯,as-if-serial
語義很好地保護了上述單線程,讓我們以為程序就是按照A->B->C的順序執行的。
happens-before
從JDK5開始,Java使用新的JSR-133內存模型,使用happens-before
的概念闡述操作之間的內存可見性。
有個簡單的例子理解所謂的可見性和happens-before“先行發生”的規則。
i = 1; //在線程A中執行
j = i; //在線程B中執行
我們對線程B中這個j的值進行分析:
假如A happens-before B,那么A操作中i=1的結果對B可見,此時j=1,是確切的。但如果他們之間不存在happens-before的關系,那么j的值是不一定為1的。
在JMM中,如果一個操作執行的結果需要對另一個操作可見,兩個操作可以在不同的線程中執行,那么這兩個操作之間必須要存在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 Interruption Rule):對線程
interrupt()
方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生。 - 對象終結規則(Finalizer Rule):一個對象的初始化完成先行發生於它的
finalize()
方法的開始。 - 傳遞性(Transitivity):A在B之前發生,B在C之前發生,那么A在C之前發生。
happens-before關系的定義
- 如果A happens-before B,A的執行結果對B可見,且A的操作的執行順序排在B之前,即時間上先發生不代表是happens-before。
- A happens-before B,A不一定在時間上先發生。如果兩者重排序之后,結果和happens-before的執行結果一致,就ok。
舉個例子:
private int value = 0;
public void setValue(int value){
this.value = value;
}
public int getValue(){
return value;
}
假設此時有兩個線程,A線程首先調用setValue(5)
,然后B線程調用了同一個對象的getValue
,考慮B返回的value值:
根據happens-before
的多條規則一一排查:
- 存在於多個線程,不滿足程序次序的規則。
- 沒有方法使用鎖,不滿足監視器鎖規則。
- 變量沒有用volatile關鍵字修飾,不滿足volatile規則。
- 后面很明顯,都不滿足。
綜上所述,最然在時間線上A操作在B操作之前發生,但是它們不滿足happens-before
規則,是無法確定線程B獲得的結果是啥,因此,上面的操作不是線程安全的。
如何去修改呢?我們要想辦法,讓兩個操作滿足happens-before
規則。比如:
- 利用監視器鎖規則,用
synchronized
關鍵字給setValue()
和getValue()
兩個方法上一把鎖。 - 利用volatile變量規則,用
volatile
關鍵字給value修飾,這樣寫操作在讀之前,就不會修改value值了。
重排序對多線程的影響
考慮重排序對多線程的影響:
如果存在兩個線程,A先執行writer()方法,B再執行reader()方法。
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (flag) { // 3
int i = a * a; // 4
……
}
}
}
在沒有學習重排序相關內容前,我會毫不猶豫地覺得,運行到操作4的時候,已經讀取了修改之后的a=1,i也相應的為1。但是,由於重排序的存在,結果也許會出人意料。
操作1和2,操作3和4都不存在數據依賴,編譯器和處理器可以對他們重排序,將會導致多線程的原先語義出現偏差。
順序一致性
數據競爭與順序的一致性
上面示例就存在典型的數據競爭:
- 在一個線程中寫一個變量。
- 在另一個線程中讀這個變量。
- 寫和讀沒有進行同步。
我們應該保證多線程程序的正確同步,保證程序沒有數據競爭。
順序一致性內存模型
- 一個線程中的所有操作必須按照程序的順序來執行。
- 所有線程都只能看到一個單一的操作執行順序。
- 每個操作都必須原子執行且立刻對所有線程可見。
這些機制實際上可以把所有線程的所有內存讀寫操作串行化。
順序一致性內存模型和JMM對於正確同步的程序,結果是相同的。但對未同步程序,在程序順序執行順序上會有不同。
JMM處理同步程序
對於正確同步的程序(例如給方法加上synchronized關鍵字修飾),JMM在不改變程序執行結果的前提下,會在在臨界區之內對代碼進行重排序,未編譯器和處理器的優化提供便利。
JMM處理非同步程序
對於未同步或未正確同步的多線程程序,JMM提供最小安全性。
一、什么是最小安全性?
JMM保證線程讀取到的值要么是之前某個線程寫入的值,要么是默認值(0,false,Null)。
二、如何實現最小安全性?
JMM在堆上分配對象時,首先會對內存空間進行清零,然后才在上面分配對象。因此,在已清零的內存空間分配對象時,域的默認初始化已經完成(0,false,Null)
三、JMM處理非同步程序的特性?
- 不保證單線程內的操作會按程序的順序執行。
- 不保證所有線程看到一致的操作執行順序。
- 不保證64位的long型和double型的變量的寫操作具有原子性。(與處理器總線的工作機制密切相關)
- 對於32位處理器,如果強行要求它對64位數據的寫操作具有原子性,會有很大的開銷。
- 如果兩個寫操作被分配到不同的總線事務中,此時64位寫操作就不具有原子性。
總結
JMM遵循的基本原則:
對於單線程程序和正確同步的多線程程序,只要不改變程序的執行結果,編譯器和處理器無論怎么優化都OK,優化提高效率,何樂而不為。
as-if-serial與happens-before的異同
異:as-if-serial 保證單線程內程序的結果不被改變,happens-before 保證正確同步的多線程程序的執行結果不被改變。
同:兩者都是為了在不改變程序執行結果的前提下,盡可能的提高程序執行的並行度。
參考資料:
《Java並發編程的藝術》方騰飛
《深入理解Java虛擬機》周志明