天天看點

深入了解Java記憶體模型:保障多線程程式的正确執行

作者:Java熱點

計算機記憶體模型

高速緩存

計算機絕大多數運算任務,都不可能隻靠一個CPU就能完成,往往還需要和記憶體,硬碟進行互動。我們知道 CPU 的運作速度遠遠快于記憶體的速度,是以會出現 CPU 等待記憶體讀取資料的情況。

由于兩者的速度差距實在太大,為了加快運作速度,于是計算機的設計者在 CPU 中加了一個CPU 高速緩存。這個 CPU 高速緩存的速度介于 CPU 與記憶體之間,每次需要讀取資料的時候,先從記憶體讀取到CPU緩存中,CPU再從CPU緩存中讀取。這樣雖然還是存在速度差異,但至少不像之前差距那麼大了。

深入了解Java記憶體模型:保障多線程程式的正确執行

緩存一緻性

高速緩存引入了一個新的問題:緩存一緻性(Cache Coherence)。在多核CPU系統中,每個CPU都有自己的高速緩存,而它們又公用一塊主記憶體(Main Memory)。當多個CPU的運算任務都涉及同一塊主記憶體區域時,将可能導緻各自的緩存不一緻。如果真發生這種情況,那同步回到主記憶體時以誰的緩存資料為準呢?

一般來說,有兩種方式解決緩存一緻性問題

  • 通過在總線加LOCK#鎖的方式
  • 通過緩存一緻性協定

這2種方式都是硬體層面上提供的方式。

在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決緩存不一緻的問題。因為CPU和其他部件進行通信都是通過總線來進行的,如果對總線加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件通路(如記憶體),進而使得隻能有一個CPU能使用這個變量的記憶體。

但是上面的方式會有一個問題,由于在鎖住總線期間,其他CPU無法通路記憶體,導緻效率低下。

由于總線加Lock鎖的方式效率低下,後來便出現了緩存一緻性協定。最出名的就是Intel 的MESI協定。

MESI協定

MESI協定保證了每個緩存中使用的共享變量的副本是一緻的。它核心的思想是:當CPU寫資料時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信号通知其他CPU将該變量的緩存行置為無效狀态,是以當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從記憶體重新讀取。

深入了解Java記憶體模型:保障多線程程式的正确執行

CPU亂序執行優化

除了增加高速緩存外,為了使得處理器内部的運算單元能夠盡量被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之後将亂序執行的結果重組,保證該結果與順序執行是一緻的,但不保證程式中各個語句執行的先後順序和輸入的順序一緻。Java虛拟機的即時編譯器也有類似的指令重排序(Instrution Reorder)優化。

處理器記憶體模型的幾種類型

順序一緻性記憶體模型

處理器的記憶體模型和後面要說的Java記憶體模型通常會以順序一緻性記憶體模型作為參考。這裡有必要先簡單介紹下順序一緻性模型。

順序一緻性是一個被計算機科學家理想化了的理論參考模型,它為程式員提供了極強的記憶體可見性保證。順序一緻性記憶體模型有兩大特性:

  • 一個線程中的所有操作必須按照程式的順序執行。
  • (不管程式是否同步)所有線程都隻能看到一個單一的操作執行順序。在順序一緻性記憶體模型,每個操作都必須原子執行且立即對所有線程可見。
深入了解Java記憶體模型:保障多線程程式的正确執行

在概念上,順序一緻性記憶體模型有一個單一的全局記憶體,這個記憶體通過一個左右擺動的開關可以連接配接到任意一個線程,同時每一個線程必須按照程式的順序的順序來執行記憶體讀/寫操作。

不同類型的記憶體模型

根據對順序一緻性記憶體模型不同類型的讀/寫操作組合的執行順序執行放松,可以把常見處理器的記憶體模型劃分為如下幾種類型。

  • 放松程式中寫-讀的順序,由此産生了Total Store Ordering記憶體模型(簡稱TSO)。
  • 在上面的基礎上,繼續放松程式中寫-寫操作的順序,由此産生了Partial Store Ordering記憶體模型(簡稱為PSO)。
  • 在前面兩條的基礎上,繼續放松程式中讀-寫和讀-讀操作順序,由此産生了Relaxed Memory Order記憶體模型(簡稱為RMO)和PowerPC記憶體模型。
深入了解Java記憶體模型:保障多線程程式的正确執行

各種處理器的記憶體模型,從上到下,模型由強變弱。越是追求性能的處理器,記憶體模型設計得就越弱。因為這些處理器希望記憶體模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優化來提高性能。

Java記憶體模型

什麼是JMM

JMM 是 Java 記憶體模型,與JVM 記憶體模型是兩回事,JMM 的主要目标是定義程式中變量的通路規則,如下圖所示,所有的共享變量都存儲在主記憶體中共享。每個線程有自己的工作記憶體,工作記憶體中儲存的是主記憶體中變量的副本,線程對變量的讀寫等操作必須在自己的工作記憶體中進行,而不能直接讀寫主記憶體中的變量。

深入了解Java記憶體模型:保障多線程程式的正确執行

在多線程進行資料互動時,例如線程 A 給一個共享變量指派後,由線程 B 來讀取這個值,A 修改完變量是修改在自己的工作區記憶體中,B 是不可見的,隻有從 A 的工作區寫回主記憶體,B 再從主記憶體讀取自己的工作區才能進行進一步的操作。由于指令重排序的存在,這個寫—讀的順序有可能被打亂。是以 JMM 需要提供原子性、可見性、有序性的保證。

JMM保證

深入了解Java記憶體模型:保障多線程程式的正确執行

原子性

JMM保證對除了long和double之外的基本資料類型的讀取和寫入是原子性的。這意味着在多線程環境下,一個線程執行的讀寫操作要麼完全執行,要麼沒有執行,不會出現中間狀态。此外,Java提供的關鍵字synchronized也可以保證原子性。當使用synchronized關鍵字修飾代碼塊或方法時,它會将代碼塊或方法标記為臨界區,確定同一時間隻有一個線程可以執行該臨界區的代碼,進而保證原子性。

可見性

JMM通過使用記憶體屏障和緩存一緻性協定來保證可見性。當一個線程對共享變量進行寫操作時,JMM會将該變量的最新值重新整理到主記憶體中,并使其他線程的工作記憶體失效,強制它們從主記憶體中重新擷取最新值。這樣可以確定其他線程能夠看到最新的變量值,進而保證了可見性。

使用volatile關鍵字可以實作可見性。當一個變量被聲明為volatile時,對該變量的讀寫操作都會直接在主記憶體中進行,而不會使用線程的工作記憶體。這樣可以確定對volatile變量的修改對其他線程立即可見。

有序性

JMM通過禁止指令重排序(volatile)和使用happens-before原則來保證有序性。指令重排序是指處理器在執行指令時可能會對指令進行優化,改變其執行順序,但不改變程式的語義。在多線程環境下,指令重排序可能導緻線程之間觀察到的執行順序與程式中的順序不一緻,進而引發錯誤。

volatile

volatile 關鍵字通過使用記憶體屏障(memory barrier)和禁止指令重排序來保證可見性和有序性。

  1. 可見性:當一個變量被聲明為volatile時,在每次對該變量的寫操作完成後,JVM會強制将寫入操作立即重新整理到主記憶體中,而不是隻線上程的工作記憶體中保留副本。同時,對于其他線程來說,在讀取該變量之前,JVM會強制要求從主記憶體中擷取最新的值,而不是使用線程的工作記憶體中的副本。這樣可以確定在一個線程修改了volatile變量的值後,其他線程能夠立即看到最新的值,進而保證了可見性。
  2. 有序性:volatile關鍵字還可以保證變量操作的有序性。在volatile變量的寫操作之後,JVM會插入一個記憶體屏障,這個屏障會阻止在寫操作之後的指令重排序。類似地,在volatile變量的讀操作之前,JVM會插入另一個記憶體屏障,這個屏障會阻止在讀操作之前的指令重排序。這樣可以確定對volatile變量的操作按照程式的順序進行,避免了指令重排序帶來的問題,進而保證了有序性。

記憶體屏障是一種硬體或軟體層面的機制,用于控制指令的執行順序和記憶體通路的順序。它可以阻止指令重排序,確定記憶體操作按照預期順序執行。記憶體屏障的插入和處理由JVM和硬體共同完成,以確定volatile變量的可見性和有序性。

下面是通過volatile實作線程安全單例方法的例子。

java複制代碼public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {
        // 私有構造函數
    }

    public static Singleton getInstance() {
        if (instance == null) {  // 檢查執行個體是否已經建立
            synchronized (Singleton.class) {
                if (instance == null) {  // 雙重檢查鎖定
                    instance = new Singleton();  // 建立執行個體
                }
            }
        }
        return instance;
    }
}

           

在這個示例中,使用了雙重檢查鎖定(double-checked locking)的方式來實作延遲加載的線程安全單例模式。關鍵點是将instance聲明為volatile,以保證多線程環境下對instance的可見性。

在getInstance()方法中,首先檢查instance是否已經建立,如果尚未建立,才會進行同步塊的操作。在同步塊内部,再次檢查instance是否為null,這是為了防止在多個線程通過第一次檢查後,其中一個線程已經建立了執行個體,其他線程不需要再次建立。隻有在第二次檢查時,如果instance仍然為null,才建立執行個體。

通過使用volatile關鍵字,可以確定在一個線程對instance進行寫操作後,其他線程能夠立即看到這個修改,進而避免了多個線程同時建立執行個體的問題,保證了線程安全性。

happens-before

happens-before是Java記憶體模型(Java Memory Model,JMM)中的一個概念,它用于定義多線程程式中操作之間的偏序關系,確定操作按照預期順序執行。

happens-before包括一系列規則:

  1. 程式順序規則(Program Order Rule):在一個線程内,按照程式的順序,前面的操作"happens-before"于後續的操作。也就是說,一個線程内的操作按照代碼的順序執行,後續的操作可以看到前面的操作的影響。
  2. volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作"happens-before"于後續對該變量的讀操作。這個規則確定了對volatile變量的寫操作對于後續的讀操作是可見的。
  3. 在這個示例中,通過将flag聲明為volatile變量,保證了寫操作的"happens-before"于後續的讀操作。這意味着在reader()方法中,如果flag的值為true,那麼它一定能夠看到writer()方法設定的flag的更新。
java複制代碼public class HappensBeforeExample {
    private volatile boolean flag = false;

    public void writer() {
        flag = true; // 寫操作
    }

    public void reader() {
        if (flag) { // 讀操作
            System.out.println("Value is true");
        }
    }
}

           
  1. 傳遞性規則(Transitive Rule):如果操作A"happens-before"于操作B,并且操作B"happens-before"于操作C,則操作A"happens-before"于操作C。這個規則保證了操作之間的傳遞性,即如果A先于B,B先于C,那麼A必然先于C。
  2. 在下面示例中,假設有兩個線程,一個線程執行writer()方法,另一個線程執行reader()方法。 根據傳遞性規則,在示例代碼中,當writer()方法執行寫操作A(x = 42)之後,緊接着執行寫操作B(flag = true)。然後,當reader()方法執行讀操作C(if (flag))時,它能夠看到在寫操作B之前對flag的修改。是以,根據傳遞性規則,reader()方法執行讀操作D(System.out.println("x = " + x))時,它也能夠看到在寫操作A之前對x的修改,列印出更新後的值42。
java複制代碼public class HappensBeforeExample {
    private int x = 0;
    private volatile boolean flag = false;

    public void writer() {
        x = 42;        // 寫操作 A
        flag = true;   // 寫操作 B
    }

    public void reader() {
        if (flag) {    // 讀操作 C
            System.out.println("x = " + x);   // 讀操作 D
        }
    }
}

           
  1. 螢幕鎖規則(Monitor Lock Rule):對一個鎖的解鎖操作"happens-before"于後續對該鎖的加鎖操作。這個規則確定了對螢幕鎖的解鎖操作對于後續的加鎖操作是可見的,即保證了線程之間的同步順序。這個規則中說的鎖其實就是Java裡的 synchronized。
  2. 線程啟動規則(Thread Start Rule):一個線程的啟動操作"happens-before"于其後續的所有操作。這個規則保證了一個線程啟動後的操作對于其他線程是可見的。
  3. 在下面示例中,startThread()方法建立一個新的線程并啟動它,而新線程中執行的代碼對變量x進行寫操作。在主線程中,調用printValue()方法執行對變量x的讀操作并列印其值。當調用thread.start()啟動新線程時,新線程中的寫操作(x = 42)在主線程的讀操作之前發生。是以,根據線程啟動規則,主線程中的讀操作printValue()能夠看到新線程中對x的修改,輸出更新後的值42。
csharp複制代碼public class HappensBeforeExample {
    private int x = 0;

    public void startThread() {
        Thread thread = new Thread(() -> {
            x = 42;  // 寫操作,在新線程中執行
        });
        thread.start();  // 啟動新線程
    }

    public void printValue() {
        System.out.println("x = " + x);  // 讀操作
    }
}

           
  1. 線程終止規則(Thread Termination Rule):一個線程的所有操作"happens-before"于其終止操作。這個規則保證了一個線程的所有操作對于其他線程是可見的。

繼續閱讀