記憶模型的動機

請考慮以下示例:

public class Example {
    public int a, b, c, d;
    
    public void doIt() {
       a = b + 1;
       c = d + 1;
    }
}

如果使用此類是單執行緒應用程式,那麼可觀察的行為將完全如你所期望的那樣。例如:

public class SingleThreaded {
    public static void main(String[] args) {
        Example eg = new Example();
        System.out.println(eg.a + ", " + eg.c);
        eg.doIt();
        System.out.println(eg.a + ", " + eg.c);
    }
}

將輸出:

0, 0
1, 1

執行緒而言main() 方法和 doIt() 方法中的語句將按照它們在原始碼中寫入的順序執行。這是 Java 語言規範(JLS)的明確要求。

現在考慮在多執行緒應用程式中使用的相同類。

public class MultiThreaded {
    public static void main(String[] args) {
        final Example eg = new Example();
        new Thread(new Runnable() {
            public void run() {
                while (true) {
                    eg.doIt();
                }
            }
        }).start();
        while (true) {
            System.out.println(eg.a + ", " + eg.c);
        }
    }
}

這會列印什麼?

事實上,根據 JLS,無法預測這將列印:

  • 你可能會看到幾行 0, 0 開始。
  • 然後你可能會看到像 N, NN, N + 1 這樣的行。
  • 你可能會看到像 N + 1, N 這樣的行。
  • 從理論上講,你甚至可以看到 0, 0 線永遠持續 1

1 - 實際上,println 語句的存在可能導致一些偶然的同步和記憶體快取重新整理。這可能隱藏了一些會導致上述行為的影響。

那我們怎麼解釋這些呢?

重新安排作業

意外結果的一種可能解釋是,JIT 編譯器已經改變了 doIt() 方法中賦值的順序。JLS 要求語句似乎從當前執行緒**的角度按順序執行。在這種情況下,doIt() 方法的程式碼中沒有任何內容可以觀察到這兩個語句的(假設的)重新排序的效果。這意味著允許 JIT 編譯器執行此操作。

為什麼會這樣做?

在典型的現代硬體上,使用指令流水線執行機器指令,該指令流水線允許指令序列處於不同的階段。指令執行的某些階段比其他階段要長,並且記憶體操作往往需要更長的時間。智慧編譯器可以通過對指令進行排序來最大化重疊量來優化流水線的指令吞吐量。這可能導致無序執行部分語句。JLS 允許這樣做,只要不影響當前執行緒的計算結果。

記憶體快取的影響

第二種可能的解釋是記憶體快取的效果。在傳統的計算機體系結構中,每個處理器都有一小組暫存器和更大的記憶體。訪問暫存器比訪問主儲存器要快得多。在現代架構中,存在比暫存器慢的記憶體快取,但比主記憶體更快。

編譯器將通過嘗試將變數的副本儲存在暫存器或記憶體快取記憶體中來利用此功能。如果一個變數並不需要重新整理到主記憶體,或者沒有需要從記憶體讀取的,也有不這樣做顯著的效能優勢。如果 JLS 不要求記憶體操作對另一個執行緒可見,則 Java JIT 編譯器可能不會新增強制主記憶體讀寫的讀屏障寫屏障指令。再一次,這樣做的效能優勢是顯著的。

適當的同步

到目前為止,我們已經看到 JLS 允許 JIT 編譯器生成程式碼,通過重新排序或避免記憶體操作來使單執行緒程式碼更快。但是當其他執行緒可以觀察主記憶體中(共享)變數的狀態時會發生什麼?

答案是,其他執行緒可能會根據 Java 語句的程式碼順序觀察看似不可能的變數狀態。解決方案是使用適當的同步。三種主要方法是:

  • 使用原始互斥體和 synchronized 構造。
  • 使用 volatile 變數。
  • 使用更高階別的併發支援; 例如 java.util.concurrent 包中的類。

但即便如此,重要的是要了解需要同步的位置,以及你可以依賴的效果。這就是 Java Memory Model 的用武之地。

記憶模型

Java 記憶體模型是 JLS 的一部分,它指定了一個執行緒可以保證看到另一個執行緒對記憶體寫入的影響的條件。記憶體模型具有相當程度的正式嚴謹性,並且(因此)需要詳細和仔細閱讀才能理解。但基本原則是某些構造在一個執行緒寫入變數和另一個執行緒後續讀取同一變數之間建立先發生關係。如果存在之前發生關係,則 JIT 編譯器必須生成程式碼,以確保讀操作看到寫入寫入的值。

有了這個,就可以推斷出 Java 程式中的記憶體一致性,並決定這對於所有執行平臺是否可預測和一致。