Java內存模型以前奏

介紹

Java支持多線程執行,在語言層面使用Thread類表示。用戶建立線程的惟一方式就是建立一個該類的對象,每一個線程都與這樣一個對象相關聯。在對應的Thread對象上調用start()方法將啓動線程。java

Java容許編譯器和處理器進行優化,這會使未正確同步的程序表現出出人意料的結果。數組

Thread 1 Thread 2
1: r2 = A 3: r1 = B
2: B = 1 4: A = 2

考慮上圖的例子,假設初始值A = B = 0,而且A和B是線程共享的,r1和r2是局部變量。可能會出現r1 == 1, r2 == 2這樣的結果。從直覺上,要麼指令1先執行,此時r2不該該看到指令4的結果;要麼指令3先執行,此時r1不該該看到指令2的結果。出現上述結果,那麼應該有這樣的執行順序:4 -> 1 -> 2 -> 3 -> 4,這樣指令4既是第一條執行指令,也是最後一條執行指令,這自相矛盾。緩存

因爲Java容許編譯器和處理器進行優化,那麼若是指令4發生在指令3以前,即發生了重排序,一切就合情合理了。從單線程的角度來看,只要重排序不影響線程的執行結果,Java就容許這樣的操做。多線程

as-if-serial語義

as-if-serial字面含義爲與串行似的,其語義爲編譯器、運行時和硬件應該協同工做,以建立"as-if-serial"語義的假象,這意味着在單線程程序中,程序不該該可以觀察重排序的效果。然而,在不正確同步的多線程程序中,從新排序可能會發揮做用,一個線程可以觀察到其餘線程的影響,而且可能可以檢測到變量訪問對其餘線程以不一樣於執行或程序中指定的順序變得可見。架構

物理平臺的內存模型

在當前物理計算機中,多處理器體系架構已成爲常態。在處理器運行的過程當中,數據的獲取和存儲必不可少,然而因爲存儲設備的讀取速度和處理器的運算速度相差較多,致使處理器不能充分的發揮本身的性能,因此當前計算機都會在處理器和內存之間增長高速緩存。每一個處理器都會擁本身的緩存,按期與主內存進行協調。工具

增長緩存雖然有效的提升的處理器的效率,同時也爲多處理器架構引入了新的問題。每一個處理器的緩存都只與主內存發生數據交換,而不能與其餘緩存直接進行通訊。若是多個處理器同時處理相同的內存,那麼可能致使每一個緩存會出現不一樣的數據,這就是緩衝一致性。爲了解決一致性問題,不一樣平臺經過不一樣的一致性協議來保證數據正確的同步回主內存。物理平臺的交互關係以下圖。性能

物理平臺的交互關係

除了緩存的問題以外,當前處理器爲了充分利用本身的性能,會對輸入代碼進行亂序執行。處理器會保證最終的執行結果與順序執行的結果一致,但不對執行順序保證。例如針對代碼:優化

a = 1;
b = 2;
c = a;
複製代碼

處理器爲了優化性能,可能會按照如下順序執行:this

a = 1;
c = a;
b = 2;
複製代碼

在原順序中,處理器須要讀取a變量兩次,這在性能上會形成很大的影響(想一想處理器從主內存中讀取兩次a的時間消耗)。若是處理器在執行中調整爲重排序後的順序,假設此時處理器執行完a = 1後,能夠將a的值緩存,這樣就減小了性能消耗。從最終的結果上來看,結果保持了一致性,可是執行順序與原有代碼並不相同。對於單線程來講,這樣的順序並不會引起問題,然而在多線程中,若是某個線程的處理依6賴其餘線程的執行,那麼就會出現嚴重的問題。spa

重排序

重排序即訪問程序變量(對象實例字段、類靜態字段和數組元素)的次序可能與程序指定的次序不一樣。編譯器能夠自由地以優化的名義對指令進行排序。在某些狀況下,處理器可能會無序地執行指令。數據能夠在寄存器、處理器緩存和主存之間以不一樣於程序指定的順序移動。

在上面已經描述了重排序可能致使的執行問題。例如,若是一個線程寫字段a,而後寫字段b,而b的值不依賴於a的值,那麼編譯器能夠自由地對這些操做從新排序,而緩存能夠在a以前將b刷新到主存。有許多從新排序的潛在來源,例如編譯器、JIT編譯器和緩存。

在此介紹下在Java體系中涉及的重排序類型。從java源碼到實際執行的過程當中,會經歷一下三種重排序:

重排序類型

  • 編譯器重排序:在不影響單線程執行過程的前提下,編譯器從新安排執行順序
  • 指令級重排序:現代處理器採用指令並行執行,數據之間若是不存在數據依賴,那麼處理器會經過指令重排提升性能
  • 內存級重排序:因爲處理器緩存和讀/寫緩衝區的存在,會致使指令執行與看上去的順序不一致。

其中編譯器重排序,是由java編譯器在編譯過程當中進行的指令重排,屬於語言級別。指令級和內存級重排序由硬件系統進行,不一樣的處理器會產生不一樣的處理結果。下面介紹上述重排序實例,給你們有個直觀的理解。

編譯器重排序

當前JDK自帶上午javac工具在編譯成字節碼的過程當中,不會對代碼進行編譯的優化。下面的示例使用 hsdis 反編譯工具,獲取C2類型的JIT編譯器生成的彙編指令,來展現JVM在運行中,由即時編譯器形成的重排序。該工具的使用,我會在其餘文章中進行簡單介紹。

源代碼以下所示:

public class Test {
    int sum = 0;
    boolean flag = false;

    private void add(int param) {
        sum += 1;
        flag = true;
        sum += param;
    }

    public static void main(String[] args) {
        Test test = new Test();
        test.add(100);
    }

}
複製代碼

參考生成編譯的指令(需安裝 hsdis

javac Test.java
java -Xcomp -XX:CompileCommand=dontinline,Test.add -XX:CompileOnly=Test.add -XX:CompileCommand=print,Test.add Test
複製代碼

C2編譯器編譯後,add 方法的彙編指令以下,此處只展現部分重要指令,該指令使用jdk11生成。

其中前四行由 hsdis 工具生成,第二行表示 Test 的實例對象 test 的地址保存在 rdx 寄存器中,第三行表示 param 參數值保存在 r8 寄存器中。

# {method} {0x000001b4f9910398} 'add' '(I)V' in 'Test'
# this:     rdx:rdx   = 'Test'
# parm0:    r8        = int
#           [sp+0x20]  (sp of caller)

sub    $0x18,%rsp
mov    %rbp,0x10(%rsp)
add    0xc(%rdx),%r8d   # 0xc(%rdx)表示test對象所在地址(rdx寄存器保存test對象的地址)移動0xc字節處的地址,此處爲字段num的地址。該指令表示num和param相加,結果保存在r8寄存器中
movb   $0x1,0x10(%rdx)  # 將0x1(即十六進制的1)保存到test對象所在地址(rdx寄存器保存test對象的地址)移動0x10字節處的地址處,即變量flag賦值爲true
inc    %r8d             # r8寄存器中的值自增長1,即sum += 1的部分操做
mov    %r8d,0xc(%rdx)   # 將r8寄存器的值寫回est對象所在地址(rdx寄存器保存test對象的地址)移動0xc字節處的地址處,即將num + 1 + param的值寫回內存
add    $0x10,%rsp
pop    %rbp
mov    0x108(%r15),%r10
test   %eax,(%r10)
retq
複製代碼

由上面的指令可知,add 方法在編譯後,先執行 num += param;, 後執行 num += 1; 。此處雖然有重排序,可是更重要的一點是,在執行完上述寫回內存的操做前,num 的值都保存在寄存器中。這就形成其餘線程在獲取 num 時,只能獲取到初始值0或者add方法執行結束的值101,中間過程的值根本沒有保存回內存。

指令級重排序

CPU的基本工做是執行存儲的指令序列,即程序。程序的執行過程其實是不斷地取出指令、分析指令、執行指令的過程。一條CPU指令在執行中能夠分爲5個階段:取指令、指令譯碼、執行指令、訪存取數和結果寫回。

在串行的指令執行方式下,一個指令週期只能執行一條指令。若是在對第一條指令譯碼的時候,就取第二條指令;第二條指令譯碼的時候,就取第三條指令。在完美的條件下,指令就能夠像流水線同樣進行執行,這就是指令流水線技術。

然而因爲數據之間存在相互依賴關係,因此上述的執行方式就存在必定的問題。好比以下的彙編指令:

指令1:ADD %r8d, %r10d  # 寄存器r8的值和寄存器r10的值相加,寫入r10
指令2:inc %r10d        # 寄存器r10的值自增1
指令3:mov $0x10,(%rdx) # 10 寫入寄存器rdx中指向的地址
複製代碼

因爲指令2的操做數依賴指令1的執行結果,那麼在指令1的執行完成前,指令2是不能夠獲取變量的值的。假如將指令3提早到指令2以前,那麼在指令2執行到取值的階段,指令1的結果已寫入寄存器 r10,那麼就能夠完美實現流水線的執行過程。指令重排序的實際執行結果以下:

指令1:ADD %r8d, %r10d  # 寄存器r8的值和寄存器r10的值相加,寫入r10
指令3:mov $0x10,(%rdx) # 10 寫入寄存器rdx中指向的地址
指令2:inc %r10d        # 寄存器r10的值自增1
複製代碼

能夠看到,因爲流水線技術,處理器可能亂序執行,形成重排序的結果。

內存級重排序

詳情見編譯器重排序,其中 numadd 方法中的中間操做值根本沒有保存到內存中,而是保存在寄存器中間。假如沒有發生編譯器重排序,在 num += param; 執行前,有其餘線程想取得 num += 1;num 的值,因爲寄存器的存在,這個值在其餘線程根本是不可見的。

總結

經過上述介紹可知,java在要求在單線程中保證as-if-serial,對多線程的執行並無增長特殊的要求。java本意是爲java虛擬機的實現者提供儘可能大的自由度,保證java在運行時能最大限度的利用現代處理器優化的功能。同時這也形成了java多線程在未正確同步的狀況下,執行亂序的結果。本章經過一部分實例,來演示java多線程執行的複雜狀況,爲下面的章節提供必要的前提知識。

相關文章
相關標籤/搜索