java 記憶體模型-03-快取和重排序

longmanma發表於2021-09-09

快取

為了提升效能,JVM 做了 2 件事情。

快取+重排序

為什麼會出現執行緒可見性問題

要想解釋為什麼會出現執行緒可見性問題,需要從計算機處理器結構談起。

我們都知道計算機運算任務需要CPU和記憶體相互配合共同完成,其中CPU負責邏輯計算,記憶體負責資料儲存。

CPU要與記憶體進行互動,如讀取運算資料、儲存運算結果等。

由於記憶體和CPU的計算速度有幾個數量級的差距,為了提高CPU的利用率,現代處理器結構都加入了一層讀寫速度儘可能接近CPU運算速度的快取記憶體來作為記憶體與CPU之間的緩衝:
將運算需要使用的資料複製到快取中,讓CPU運算可以快速進行,計算結束後再將計算結果從快取同步到主記憶體中,這樣處理器就無須等待緩慢的記憶體讀寫了。

快取記憶體的引入解決了CPU和記憶體之間速度的矛盾,但是在多CPU系統中也帶來了新的問題:快取一致性

在多CPU系統中,每個CPU都有自己的快取記憶體,所有的CPU又共享同一個主記憶體。
如果多個CPU的運算任務都涉及到主記憶體中同一個變數時,那同步回主記憶體時以哪個CPU的快取資料為準呢?這就需要各個CPU在資料讀寫時都遵循同一個協議進行操作。

處理器=》快取記憶體=》快取一致性協議=》主記憶體
處理器=》快取記憶體=》快取一致性協議=》主記憶體

參考上圖,假設有兩個執行緒A、B分別在兩個不同的CPU上執行,它們共享同一個變數X。

如果執行緒A對X進行修改後,並沒有將 X 更新後的結果同步到主記憶體,則變數X的修改對B執行緒是不可見的。

所以CPU與記憶體之間的快取記憶體就是導致執行緒可見性問題的一個原因。

另一個原因就是重排序

重排序

目的

現在的CPU一般採用流水線來執行指令。

一個指令的執行被分成:取指、譯碼、訪存、執行、寫回、等若干個階段。然後,多條指令可以同時存在於流水線中,同時被執行。

指令流水線並不是序列的,並不會因為一個耗時很長的指令在“執行”階段呆很長時間,而導致後續的指令都卡在“執行”之前的階段上。

重排序的目的是為了效能

  • Example

理想情況下:

過程A:cpu0—寫入1—> bank0;
過程B:cpu0—寫入2—> bank1;

如果bank0狀態為busy, 則A過程需要等待

如果進行重排序,則直接可以先執行B過程。

類別

在執行程式時為了提高效能,編譯器和處理器常常會對指令做重排序。重排序分三種型別:

  • 編譯器最佳化的重排序

編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  • 指令級並行的重排序

現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。
如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

  • 記憶體系統的重排序

由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

執行過程

從java原始碼到最終實際執行的指令序列,會分別經歷下面三種重排序:

{原始碼 -> 編譯器最佳化重排序(1) -> 指令級並行重排序(2) -> 記憶體系統重排序(3) -> 最終執行的指令順序}

上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序都可能會導致多執行緒程式出現記憶體可見性問題。

對於編譯器,JMM的編譯器重排序規則會禁止特定型別的編譯器重排序(不是所有的編譯器重排序都要禁止)。

對於處理器重排序,JMM的處理器重排序規則會要求java編譯器在生成指令序列時,插入特定型別的記憶體屏障(memory barriers,intel稱之為memory fence)指令,
透過記憶體屏障指令來禁止特定型別的處理器重排序(不是所有的處理器重排序都要禁止)。

JMM 屬於語言級的記憶體模型,它確保在不同的編譯器和不同的處理器平臺之上,透過禁止特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的記憶體可見性保證。

不進行重排序的場景

資料依賴性

如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性

| 名稱       |    示例             | 說明 |
| 寫後讀   |   a = 1; b = a;   | 寫一個變數後再讀這個位置 |
| 寫後寫   |   a = 1; a = 2;   | 寫一個變數後再寫這個變數 |
| 讀後寫   |   a = b; b = 1;   | 讀一個變數後再寫這個變數 |

上面三種情況,只要重排序兩個操作的執行順序,程式的執行結果將會被改變。

所以有資料依賴性的語句不能進行重排序。

as-if-serial

  • 概念

as-if-serial語義就是: 不管怎麼重排序(編譯器和處理器為了提高並行度), 單執行緒程式的執行結果不能被改變。所以編譯器和cpu進行指令重排序時候回遵守as-if-serial語義。

  • 栗子

public int add() {  int x = 1;    //1
  int y = 1;    //2
  int ans = x + y;  //3
  return ans
}

上面三條指令, 指令1和指令2沒有資料依賴關係, 指令3依賴指令1和指令2。

根據上面我們講的重排序不會改變我們的資料依賴關係, 依據這個結論, 我們可以確信指令3是不會重排序於指令1和指令2的前面。

我們看一下上面上條指令編譯成位元組碼檔案之後

public int add();
    Code:       0: iconst_1     // 將int型數值1入運算元棧
       1: istore_1     // 將運算元棧頂數值寫到區域性變數表的第2個變數(因為非靜態方法會傳入this, this就是第一個變數)
       2: iconst_1     // 將int型數值1入運算元棧
       3: istore_2     // 將將運算元棧頂數值寫到區域性變數表的第3個變數
       4: iload_1      // 將第2個變數的值入運算元棧
       5: iload_2      // 將第三個變數的值入運算元棧
       6: iadd         // 運算元棧頂元素和棧頂下一個元素做int型add操作, 並將結果壓入棧
       7: istore_3     // 將棧頂的數值存入第四個變數
       8: iload_3      // 將第四個變數入棧
       9: ireturn      // 返回



作者:葉止水
連結:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2730/viewspace-2818144/,如需轉載,請註明出處,否則將追究法律責任。

相關文章