Java記憶體模型之前奏

會飛的小熊發表於2018-12-20

介紹

Java支援多執行緒執行,在語言層面使用Thread類表示。使用者建立執行緒的唯一方式就是建立一個該類的物件,每個執行緒都與這樣一個物件相關聯。在對應的Thread物件上呼叫start()方法將啟動執行緒。

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;
複製程式碼

處理器為了優化效能,可能會按照以下順序執行:

a = 1;
c = a;
b = 2;
複製程式碼

在原順序中,處理器需要讀取a變數兩次,這在效能上會造成很大的影響(想想處理器從主記憶體中讀取兩次a的時間消耗)。如果處理器在執行中調整為重排序後的順序,假設此時處理器執行完a = 1後,可以將a的值快取,這樣就減少了效能消耗。從最終的結果上來看,結果保持了一致性,但是執行順序與原有程式碼並不相同。對於單執行緒來說,這樣的順序並不會引發問題,然而在多執行緒中,如果某個執行緒的處理依6賴其他執行緒的執行,那麼就會出現嚴重的問題。

重排序

重排序即訪問程式變數(物件例項欄位、類靜態欄位和陣列元素)的次序可能與程式指定的次序不同。編譯器可以自由地以優化的名義對指令進行排序。在某些情況下,處理器可能會無序地執行指令。資料可以在暫存器、處理器快取和主存之間以不同於程式指定的順序移動。

在上面已經描述了重排序可能導致的執行問題。例如,如果一個執行緒寫欄位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多執行緒執行的複雜情況,為下面的章節提供必要的前提知識。

相關文章