探索Java記憶體模型

pjmike_pj發表於2018-11-23

原文部落格地址:pjmike的部落格

前言

本文主要是對《深入理解 Java 記憶體模型——程曉明》和《深入理解Java虛擬機器》記憶體模型部分的一個知識總結,其中也參考了一些其他優秀文章。

作業系統語義

計算機在執行程式時,每條指令都是在CPU中執行的,而程式執行的資料都存在主存裡,但是讀寫主存中的資料沒有CPU中執行指令的速度快,如果每次都讀取主存。效率就會比較低,所以現代作業系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache) 來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無需等待緩慢的記憶體讀寫了,其中大致結構如下圖所示:

cpu_cache

cpu cache 作為主記憶體和CPU暫存器之間的緩衝區,以便CPU可以快速的讀寫資料。快取記憶體本質是為了調和CPU得過快訪問速度和記憶體過慢的速度的一個硬體,現代計算機一般都有三級快取記憶體,L1、L2、L3,訪問速度依次遞減。因為CPU要從記憶體中讀取資料的時候會很慢,大部分時間會浪費在等待上,所以引入cache,把預計將要讀取的資料先存放到cache中,這樣CPU就可以先到cache中讀取,從而節約了等待時間,如果cache中沒有要讀取的資料,那麼繼續往下到記憶體中讀取。以上提及的CPU cache,暫存器和主存實際上都屬於計算機儲存結構的內容,如下圖所示(摘自 blog.csdn.net/qq_27680317…)

os_save_structure

關於計算機儲存結構更細緻的介紹,可以查閱經典書籍《深入理解計算機系統-第六章》的內容。

CPU快取記憶體雖好,但是它卻帶來了一個問題就是:快取一致性問題。在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體,當多個處理器都有自己的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致的問題,比較典型的就是執行 i++ 這類非原子操作。

為了解決快取一致性問題,需要各個處理器訪問快取時遵循快取一致性協議(最常見的MESI協議),在讀寫時要根據協議來進行操作。在一致性協議下,處理器、快取記憶體、主記憶體間的交換關係可以像下圖這樣描述 (出自《深入理解Java虛擬機器》):

cpu_cache

而記憶體模型也由此孕育而生,記憶體模型可以理解為在特定的操作協議對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不同的記憶體模型,而JVM也有自己的記憶體模型(後面要談到的Java記憶體模型)。在記憶體模型中也存在處理器的重排序,對輸入程式碼進行亂序執行進而達到優化的目的。

下面將進入本文的主題Java記憶體模型的介紹

Java 記憶體模型的抽象結構

Java虛擬機器規範試圖定義一種Java記憶體模型來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java 程式在各種平臺下都能達到一致的記憶體訪問結果。下面從執行緒通訊的角度來看一下Java記憶體模型是如何定義的

執行緒間通訊

執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞,當然在程式間通訊也有這兩種機制。在共享記憶體中,執行緒之間共享程式的公共狀態,通過寫-讀記憶體中的公共狀態進行隱式通訊;而在訊息傳遞的併發模型中,執行緒之間沒有公共狀態,執行緒之間必須通過傳送訊息來顯示進行通訊。

Java併發採用的是共享記憶體模型,Java 執行緒之間的通訊總是隱式進行的。這裡的共享記憶體指的是當多個執行緒處於同一程式時,多個執行緒共享程式的地址空間和其他資源等,簡單說就是有一個記憶體區域供多個執行緒共同使用

Java 執行緒之間的通訊由 Java 記憶體模型(簡稱JMM)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。

Java 記憶體模型抽象

從抽象的角度看,JMM定義了執行緒與主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體中,每一個執行緒都有一個私有的本地記憶體,本地記憶體中儲存了該執行緒以讀/寫 共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。JMM抽象示意圖如下:

java_memory

  • 執行緒A 把本地記憶體A 中更新過的共享變數重新整理到主記憶體中去
  • 執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數

執行緒之間的通訊圖如下:

java_memory_message

從上圖看出執行緒A向執行緒B傳送訊息,要經歷主記憶體。

總結一下,JMM是一種規範,或者說一種抽象模型,目的是解決由於多執行緒通過共享記憶體進行通訊時,存在的本地記憶體資料不一致、編譯器重排序、處理器重排序造成的問題。

重排序

為了提高效能,編譯器和處理器常常會對指令做重排序,分為以下三種型別:

  • 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序
  • 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序
  • 記憶體系統的重排序:由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

從Java 原始碼到最終實際執行的指令序列,會分別經歷下面 3 種重排序,如圖所示:

java_resort

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

這裡的處理器重排序與CPU多級快取之間會出現亂序執行優化操作含義一樣

處理器重排序

現代的處理器使用寫緩衝區臨時儲存向記憶體寫入的資料。寫緩衝區可以保證指令流水線持續執行,它可以避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。同時,通過以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,可以減少對記憶體匯流排的佔用。

每個處理器上的寫緩衝區,僅對它所在的處理器可見,這個特性會對記憶體操作的執行順序產生影響:處理器對記憶體的讀 / 寫操作的執行順序,不一定與記憶體實際發生的讀 / 寫操作順序一致!

舉個例子說明:

java_processor

假設處理器 A 和處理器 B 按程式的順序並行執行記憶體訪問,最終卻可能得到 x = y = 0 的結果。具體的原因如下圖所示:

java_processor_memory

這裡處理器 A 和處理器 B 可以同時把共享變數寫入自己的寫緩衝區(A1,B1),然後從記憶體中讀取另一個共享變數(A2,B2),最後才把自己寫快取區中儲存的髒資料重新整理到記憶體中(A3,B3)。當以這種時序執行時,程式就可以得到 x = y = 0 的結果。

以處理器A以例,雖然處理器A 執行記憶體操作的順序為:A1——>A2 ,但記憶體操作實際發生的順序是 A2 ——> A1 ,這就發生了寫-讀操作重排序。現代的處理器都會允許對 寫-讀操作進行重排序。

記憶體屏障指令

為了保證記憶體的可見性,Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。JMM把記憶體屏障指令分為4類,如表所示:

屏障型別 指令示例 說明
LoadLoad Barriers Load1; LoadLoad; Load2 確保 Load1 資料的裝載,之前於 Load2 及所有後續裝載指令的裝載。
StoreStore Barriers Store1; StoreStore; Store2 確保 Store1 資料對其他處理器可見(重新整理到記憶體),之前於 Store2 及所有後續儲存指令的儲存。
LoadStore Barriers Load1; LoadStore; Store2 確保 Load1 資料裝載,之前於 Store2 及所有後續的儲存指令重新整理到記憶體。
StoreLoad Barriers Store1; StoreLoad; Load2 確保 Store1 資料對其他處理器變得可見(指重新整理到記憶體),之前於 Load2 及所有後續裝載指令的裝載。 StoreLoad Barriers 會使該屏障之前的所有記憶體訪問指令(儲存和裝載指令)完成之後,才執行該屏障之後的記憶體訪問指令

happens-before 簡介

JSR-133 記憶體模型使用 happens-before 的概念來闡述操作之間的記憶體可見性。在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在 happens-before 關係。這裡提到的兩個操作既可以是在一個執行緒之內,也可以是在不同執行緒之間。

與程式設計師密切相關的 happends-before 規則如下:

  • 程式順序規則:一個執行緒中的每個操作,happens-before 於該執行緒中的任意後續操作
  • 監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖
  • volatile 變數規則:對一個 volatile域的寫,happens-before 於任意後續對這個 volatile 域的讀
  • 傳遞性:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C.

happens-before 的本質是要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。這不意味著 前一個操作必須在後一個操作之前執行。

happens-before 與 JMM 的關係如下圖所示:

happen-before

如上圖所示,一個 happens-before 規則對應於一個或多個編譯器和處理器重排序規則。

資料依賴性

如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分為如下3種型別:

名稱 程式碼示例 說明
寫後讀 a = 1;b=2 寫一個變數後,再讀這個變數
寫後寫 a = 1;a=2 寫一個變數後,再寫這個變數
讀後寫 a = b;b =1 讀一個變數後,再寫這個變數

編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序

注意:這裡說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮,實際開發中,還是多執行緒居多

as-if-serial 語義

as-if-serial 語義的意思是: 不管怎樣重排序,單執行緒程式的執行結果不會被改變,編譯器,runtime和處理器都必須遵循as-if-serial 語義

為了遵守 as-if-serial 編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。但是如果操作之間沒有資料依賴關係,這些操作就可能被編譯器和處理器重排序。

程式順序規則

程式的執行順序實際上是根據 happens-before 的程式順序規則,JMM僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前,至於執行順序就不一定。

重排序對多執行緒的影響

在單執行緒程式中,對存在控制依賴的操作做重排序,不會改變執行結果(這也是 as-if-serial 語義允許對存在控制的操作做重排序的原因);但在多執行緒程式中,對存在控制依賴的重排序操作,可能會改變程式的執行結果。

volatile 的記憶體語義

volatile的特性

舉例說明:

public class VolatileExample {

    volatile long vl = 0L;//使用volatile宣告64位的long型變數

    public long getVl() {
        return vl;  //單個volatile變數的讀
    }

    public void setVl(long vl) {
        this.vl = vl; //單個 volatile變數的寫
    }

    public void getAndIncrement() {
        vl++; //複合(多個) volatile變數的讀/寫
    }
}
複製程式碼

假設有多個執行緒分別呼叫上面程式的三個方法,這個程式在語義上和下面程式等價:

public class VolatileExample {

    long vl = 0L;//宣告64位的long型普通變數

    public synchronized long getVl() {
        return vl;  //對單個普通變數的讀用一個鎖同步
    }

    public void setVl(long vl) {
        this.vl = vl; //對單個普通變數的寫用一個鎖同步
    }

    public void getAndIncrement() { //普通方法呼叫
        long temp = getVl();        //呼叫已同步的讀方法
        temp += 1L;                 //普通寫操作
        setVl(temp);                //呼叫已同步的寫操作
    }
}
複製程式碼

一個volatile變數的單個讀/寫操作,與一個普通變數的讀/寫操作都是使用一個鎖來來同步,它們之間的執行效果相同

鎖的 happens-before 規則來保證釋放鎖和獲取鎖的兩個執行緒之間的記憶體可見性,這意味著對一個 volatile變數的讀,總是能看到任意執行緒對這個 volatile變數最後的寫入

鎖的語義決定了臨界區程式碼的執行具有原子性,這一點跟程式間同步機制是類似的。多個volatile操作或類似於 volatile ++ 這種複合操作,這些不具有原子性。比如我將上面那段程式碼中的getAndIncrement()javap反編譯得到如下位元組碼:

public void getAndIncrement();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=5, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field vl:J
         5: lconst_1
         6: ladd
         7: putfield      #2                  // Field vl:J
        10: return
複製程式碼

原方法中只有 v1++一條語句,通過反編譯得到的位元組碼卻有好幾條,這也說明v1++是個複合操作。

當 getfield訪問欄位指令將 v1的值放在運算元棧頂時,volatile保證了 v1 此時是正確的,但是在執行 lconst_1、ladd這些指令時,其他執行緒可能已經把v1的值增大了,而在運算元棧頂的值就變成了過期的資料,所以 putfield 執行後就可能把較小的值同步回主記憶體中。

總之,volatile變數有如下特性:

  • 可見性: 對一個 volatile變數的讀,總是能看到任意執行緒對這個 volatile變數最後的寫入
  • 原子性:對任意單個 volatile 變數的讀/寫具有原子性,但類似於 volatile++ 這種複合操作不具有原子性

volatile 寫-讀的記憶體語義

volatile 寫-讀的記憶體語義 是:

  • 當寫一個 volatile 變數時,JMM 會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。
  • 當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體讀取共享變數

假定一個volatile 變數 flag,普通變數 a,下面圖示展示了共享變數的狀態示意圖:

java_volatile

volatile記憶體語義的實現

下表是 JMM 針對 編譯器制定的 volatile 重排序規則表:

java_volatile_resort_table

從表中可以看出:

  • 當第二個操作為 volatile 寫時,不管第一個操作是什麼,都不能重排序。這個規則保證 volatile 寫之前的操作不會被 編譯器重排序到 volatile 寫之後
  • 當第一個操作為 volatile 讀時,不管第二個操作是什麼,都不能重排序,這個規則確保 volatile 讀之後的操作不會被編譯器重排序到 volatile 讀之前。

為了實現 volatile 的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,JMM採用保守策略,下面是基於保守策略的JMM記憶體屏障插入策略:

  • 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障
  • 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障
  • 在每個 volatile 讀操作的後面插入一個 LoadLoad屏障
  • 在每個 volatile 讀操作的後面插入一個 LoadStore屏障

下面是保守策略下,volatile 寫操作 插入記憶體屏障後生成的指令序列示意圖:

java_volatile_write

下面是保守策略下,volatile 讀操作插入記憶體屏障後生成的指令序列示意圖

java_volatile_read

在實際執行時,只要不改變 volatile 寫 - 讀 的記憶體語義,編譯器可以根據具體情況省略不必要的屏障。示例如下:

public class VolatileTest2 {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1;     //第一個volatile 讀
        int j = v2;     //第二個volatile 讀
        a = i + j;      //普通寫
        v1 = i + 1;     //第一個volatile 寫
        v2 = j * 2;     //第二個volatile 寫
    }
}
複製程式碼

針對 readAndWrite() 方法,編譯器在生成位元組碼時可以做如下優化:

java_volatile_read_and_write

鎖的內部語義

鎖的釋放和獲取的記憶體語義

  • 當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中去
  • 當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。

鎖記憶體語義的實現

在Java中一般有兩種同步方式:Synchronized和可重入鎖ReentrantLock。《併發程式設計的藝術》書中是藉助 ReentrantLock 來分析鎖記憶體語義的實現的

public class ReentrantLockExample {
    int a = 0;
    ReentrantLock lock = new ReentrantLock();

    public void writer() {
        lock.lock();               //獲取鎖
        try {
            a++;
        } finally {
            lock.unlock();         //釋放鎖
        }
    }

    public void reader() {
        lock.lock();               //獲取鎖
        try{
            int i = a;
            System.out.println("i : " + a);
        } finally {
            lock.unlock();         //釋放鎖
        }
    }
}
複製程式碼

在 RenntrantLock中,呼叫 lock() 方法獲取鎖,呼叫 unlock() 方法釋放鎖

而 ReentrantLock 的實現依賴於 Java 同步器框架 AbstractQueuedSynchronizer (簡稱AQS),AQS 使用一個整型的 volatile 變數來維護同步狀態,這個 volatile 變數是 ReentrantLock 記憶體語義實現的關鍵。

concurrent包的實現

仔細分析 concurrent 包的原始碼實現,會發現一個通用化的實現模式

  • 首先,宣告共享變數為 volatile
  • 然後,使用 CAS 的原子條件更新來實現執行緒之間的同步
  • 同時,配合以volatile 的讀/寫和 CAS 所具有的 volatile 讀和寫的記憶體語義來實現執行緒之間的通訊

AQS(Java 同步器框架 AbstractQueuedSynchronizer),非阻塞資料結構和原子變數類 (java.util.concurrent.atomic 包中的類),這些 concurrent 包中的基礎類都是使用 這種模式來實現的,而 concurrent 包中的高層類又是依賴於 這些基礎來實現的。從整體來看,concurrent包的示意圖如下:

java_concurrent

final的記憶體語義

final 域的重排序規則

對於 final域,編譯器和處理器要遵守兩個 重排序規則。

  • 在建構函式內對一個final域的寫入,與隨後把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序
  • 初次讀一個包含 final 域 的物件的引用,與隨後初次讀這個 final 域,這兩個操作之間不能重排序

寫 final 域的重排序規則

寫 final 域的 重排序規則禁止把 final 域 的寫重排序到建構函式之外。這個規則的實現 包含下面 2個方面:

  • JMM 禁止編譯器把 final 域的寫重排序到建構函式之外
  • 編譯器會在 final 域的寫之後,建構函式 return 之前,插入一個 StoreStore 屏障,這個屏障禁止處理器把 final 域的寫重排序到建構函式之外

讀 final 域的重排序規則

讀 final 域的重排序規則是,在一個執行緒中,初次讀物件引用 與初次讀該物件包含的 final 域,JMM 禁止 處理器重排序這兩個操作(注意,這個規則僅僅針對 處理器)。編譯器會在讀 final 域操作的前面插入一個 LoadLoad屏障

final 域為引用型別

對於 引用型別,寫 final 域的重排序規則對編譯器和處理器增加了如下約束:

在建構函式內對一個 final 引用 的物件 的成員域的寫入,與隨後在建構函式 外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。

Java 記憶體模型總結

Java 記憶體模型可以說是圍繞著在併發過程中如何處理原子性、可見性和有序性這3個特徵來建立的:

原子性

即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼不執行

在單執行緒環境下我們一般的操作都是原子性操作,Java記憶體模型中的 volatile 關鍵字也只是保證操作在單執行緒環境下是原子的,但是在多執行緒環境下,Java 只保證了基本型別的變數和賦值操作才是原子性的。((注:在32位的JDK環境下,對64位資料的讀取不是原子性操作,如long、double)。要想在多執行緒環境下保證原子性,則可以通過鎖、synchronized來確保。

可見性

可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改

比如對於 volatile變數,Java記憶體模型是通過在變數修改後將新值立即同步回主內部才能,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性

有序性

有序性是指程式執行的順序按照程式碼的先後順序執行

在Java記憶體模型中,為了效率會發生編譯器重排序和處理器重排序,重排序一般不會影響單執行緒的執行結果,但是會對多執行緒產生影響

Java提供了 volatile 和 synchronized 關鍵字來保證執行緒之間操作的有序性,volatile 關鍵字本身就包含了 禁止指令重排序的語義,而synchronized 則是由 "一個變數在同一時刻只允許一條執行緒對其進行Lock操作"。

再來說說 happens -before 原則,它闡述了操作之間的記憶體可見性,這個在Java記憶體模型中比較重要。JMM 把 happens-before 要求禁止的重排序分為下面兩類:

  • 會改變程式執行結果的重排序
  • 不會改變程式執行結果的重排序

JMM 對這兩種不同性質的重排序,採用了不同的策略,如下:

  • 對於會改變程式執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序
  • 對於不會改變程式執行結果的重排序,JMM對編譯器和處理器不做要求 (JMM允許這種重排序)

happen_before

小結

以上內容是一個關於Java記憶體模型的知識筆記,算是對Java記憶體模型的一個大致認識。要認識Java記憶體模型,首先我們應該瞭解CPU的快取記憶體機制,認識作業系統層面的記憶體模型,然後再到Java虛擬機器定義的記憶體模型,Java虛擬機器定義Java記憶體模型是為了遮蔽掉各種硬體和作業系統的記憶體訪問差異,最後是Java記憶體模型中的一些核心內容,比如重排序,Volatile記憶體語義,鎖的記憶體語義等等。

參考資料 & 鳴謝

相關文章