1. 程式人生 > >何為記憶體模型(JMM)?

何為記憶體模型(JMM)?

前言

任何一門語言都有其語言規範,從邏輯上我們可劃分為語法規範和語義規範,語法規範則是描述瞭如何通過相關語法編寫可執行的程式,而語義規範則是指通過語法編寫的程式所構造出的具體含義。語言只要具備儲存(比如堆、棧),我們此時必須定義儲存行為規則,這種行為規則就是記憶體模型。Java初始版本記憶體模型允許行為安全洩漏,此外,它阻止了幾乎所有的單執行緒編譯器優化操作,因此,從Java 1.5開始,引入了新的記憶體模型來修復這些缺陷,接下來我們來詳細瞭解看看其記憶體模型到底是啥玩意,若有錯誤之處,還望批評指正。

記憶體模型(JMM)

我們知道大多數情況下編寫的程式按順序而執行,此時也是按照對應順序儲存在記憶體中,很顯然,讀取應遵循該順序進行的最新寫入,這是最原始的單核模型,隨著時代的進步、技術也才隨之發展,此時出現了多處理器體系結構,執行緒共享記憶體已凸顯出對於併發程式設計的優勢,但共享記憶體必然要使用同步機制使得記憶體中的資料一致,而同步機制卻對系統性能產生很大影響,為了避免這種情況,通過對應策略使得儲存資料一致性,當然這些策略是放寬的,如此將導致意外情況的出現,因為開發者很難推理執行程式最終結果,所以在多執行緒情況下我們尤其關心記憶體模型,但是問題隨之變得複雜了起來,記憶體模型定義了在實現該記憶體的共享記憶體體系結構上執行的多執行緒程式的所有可能結果,從本質上講,可認為它是對可能值的規範,它允許返回對記憶體的讀取訪問,從而指定平臺的多執行緒語義。 Java記憶體模型(JMM)設計有兩個目標:應該允許儘可能多的編譯器優化、一般情況下開發者不必瞭解其所有複雜性可以更容易進行多執行緒程式設計,但是這項任務巨大,從某種意義上來講,這種模型增加了太多的不確定性,為了實現第二個目標,為了開發者能夠更好的可程式設計,於是JMM提供一個較弱的保證,稱之為無資料競爭保證(Data Race Free),簡稱為DRF,它保證:如果程式不包含資料競爭,則允許行為可以通過交錯語義來描述,換句話說,如果程式的所有順序一致的執行沒有資料爭用,則所有執行似乎都是順序一致的,所以我們可認為JMM是無資料爭用的記憶體模型,DRF通過順序一致性來保證。這裡我們只是抽象概括了記憶體模型,接下來將通過大量的篇幅來進一步分析順序一致性以及通過順序一致性怎麼就保證了記憶體模型。

順序一致性(Sequential Consistency簡稱SC)

我們知道在多執行緒情況下由於競爭條件的存在會發生資料競爭,那麼如何解決資料競爭呢?這就涉及到資料競爭自由度(Data Race Freeness)的概念:程式結果計算的正確性依賴於執行時的相關時序或多執行緒交替時就會產生競爭條件從而發生資料競爭,那麼反過來講,資料競爭自由度則是正確同步的程式具有順序一致性,那麼到底何為順序一致性(sequential consistency)呢?執行結果都與所有處理器的操作相同按順序執行,每個操作處理器按程式指定的順序依次出現,單個記憶體引用(載入或儲存)完成的順序稱為執行順序,語句在原始程式碼中的排序方式稱為程式順序(Program Order以下簡稱為PO),執行順序決定總的順序,執行順序與程式順序(每個執行緒中的指令順序所決定的順序)一致,並且每次讀取儲存位置時都會看到寫入的最後一個值,這意味著,執行順序好像具有按總順序(Total Order)執行的結果形態,但執行的實際順序不一定按總順序進行,因為編譯器的優化和指令的並行執行是允許的。講到這裡,感覺很抽象,接下來我們通過簡單的例子來介紹執行順序、總順序、程式順序概念。

public class Main {
    public static void main(String[] args) {
        int x = 1;
        int y, z, m;
        if (x == 1) {
            y = 2;
        } else {
            z = 1;
        }
        z = y;
    }
}

上述我們初始化分配x==1,而y、z、m都等於0,接下來進入判斷語句,總順序則是由read(x):2、write(y,2)、read(y):2組成,程式順序就是總順序,而執行順序是實際在記憶體中的操作順序,由於快取一致性協議的存在改善了系統處理效能,同時為了解決快取副本需要使用同步機制使得快取副本一致,但是同步機制大大降低了效能,於是通過重排序進一步改善效能,接踵而來的重排序能夠保持SC(順序一致性)嗎,我們看如下例子:

public class Main {
    public static void main(String[] args) {
        int a = 1;
        int b = 2;
        System.out.println(a);
        System.out.println(b);
    }
}

上述在多執行緒情況下進行重排序可能先打印出2,然後打印出1,重排序並未影響實際列印結果,重排序優化了效能,使得程式碼執行的更快,但是要是如下例子呢?

class ReadWriteExample {
    int a, b = 0;

    void write() {
        b = 2;
        a = 1;
    }

    void read() {
        int r1 = a;
        int r2 = b;
        System.out.println(r1);
        System.out.println(r2);
    }
}

 如果按照SC定義在多執行緒情況下執行,要麼a = 1最後執行,要麼r2 = b最後執行,所以r1和r2可能的結果為(0,*)或者(*,2),但是若按照如下形式執行重排序,此時r1和r2的結果為(1,0),所以我們可得出結論:重排序並不能保持SC,可能會打破SC。

因為指令重排序的可能性,所以順序一致性並不意味著操作按照特定的總順序執行,儘管順序一致性具有非常清晰而明確的語義,但編譯器很難靜態地確定它是否會進行指令重排序或允許並行執行操作以保留安全性,這種語義阻止了許多(但不是全部)針對順序程式碼的常見編譯器優化,因此,對於未正確同步(即包含資料競爭)的程式,對順序一致性採取了比較弱的含義。那麼問題來了,Java中的記憶體模型究竟強記憶體模型還是弱記憶體模型呢?作業系統的記憶體模型是強記憶體模型,因為它對正常記憶體操作和同步操作做出明確的區分,而Java的記憶體模型是弱記憶體模型,它對正常記憶體操作和同步操作沒有做出具體的區分,尤其是針對同步操作,它僅僅只提供了指導方向或基本思想即:同步操作在執行過程中要引起其他操作的可見性和順序一致性限制。在弱記憶體模型中,並不對所有動作進行排序,僅對一些有限的原語施加硬性排序,在JMM中,這些原語包裝在它們各自的同步動作中。接下來我們進入到進入到利用同步操作構建弱模型的排序情況。在《Java併發程式設計實戰》一書中對記憶體模型的定義為:通過動作的形式進行描述的、所謂動作,包括變數的讀寫、監視器的加鎖和釋放鎖、執行緒的啟動和拼接,這裡指代的動作即為同步動作(Synchronization Action),通過volatile進行讀寫、通過lock進行讀鎖和釋放鎖、執行緒的啟動(thread.start)、執行緒的終止(thread.join)。既然講到同步動作對記憶體模型的定義,那麼逃脫不了對同步順序(Synchronization Order以下簡稱為SO)的詳細瞭解,因為同步操作來源於同步順序,同步順序(SO)是涵蓋所有同步操作的總順序,JMM提供了兩個附加約束:SO-PO一致性和SO一致性。 接下來我們通過簡單的例子來解開這些約束。

class ReadWriteExample {
    volatile int x, y = 0;

    void m1() {
        x = 1;
        int r1 = y;
    }

    void m2() {
        y = 1;
        int r2 = x;
    }
}

上述通過volatile關鍵字修飾變數x和y,因此滿足SO,正常PO是write(x,1)、read(y,?)、write(y,1)、read(x,?),但是SO則可能是:【write(x,1)、read(y,?)、read(x,?)、write(y,1),SO與PO執行不一致】和【write(x,1)、read(y,?)、write(y,1)、read(x,?),SO與PO執行一致】和【write(x,1)、write(y,1)、read(x,?)、read(y,?),SO與PO執行一致】。通過分析我們知道SO-PO一致性就是和正常執行程式操作一樣,而SO一致性告訴我們要知道SO所有在此之前的動作,尤其是在不同執行緒中。所以我們可推出:同步操作(SA)是順序一致性(SC)的,在宣告為volatile的變數程式中,我們可以對結果進行推理,由於SA是SC,通過SO就足以推理結果,即使是所有動作進行了交替執行。然而SO並不能構建實用的弱記憶體模型,只能說SO構建了弱記憶體模型的基本骨架,其原因是:要麼將所有操作轉換為SA,要麼讓非SA操作不受限制的進行排序,很顯然這樣還是會破壞程式實際結果,若為了達到SC,需要將整個程式進行鎖定,但是這又以犧牲效能為代價。接下來我們繼續看看如下例子:

class ReadWriteExample {
    int x;
    volatile int y;

    void m1() {
        x = 1;
        y = 1;
    }

    void m2() {
        int r1 = y;
        int r2 = x;
    }
}

由於我們通過volatile修改了變數y,所以會對y進行SO,我們可以正確讀取到y = 1,但是對於非SA操作即x變數的值讀取,到底是0還是1呢?不得而知。SO即使以犧牲效能為代價保證了順序一致性,但對非SA操作還需要一個非常弱的語義保證,那就是事先發生(happens-before)。那麼什麼是事先發生呢?為了捕獲有關記憶體操作的基本順序和可見性要求,JMM基於事先發生規則(happens-before)【深入理解Java虛擬機器將其翻譯為先行發生】, 此規則確定了在任何其他動作之前必須發生的動作,換句話說,此順序指定任何讀取必須看到的記憶體更新,僅允許執行不違反此順序的命令。由於此模型提供的保證非常弱,因此可允許單執行緒編譯器優化,但是,發生在模型允許通過執行動作的迴圈證明而憑空產生值的執行之前發生,為避免此類迴圈證明並保證DRF,目前的JMM比模型初始版本記憶體模型處理起來要複雜得多。那麼事先發生規則是如何解決非SA操作的問題呢?通過引入SO的子順序來描述資料的流轉或者說以此來連線執行緒之間的狀態,我們稱之為Synchronization-with Order簡稱為SW,構造SW相當容易,SW並不是完整的順序,不能覆蓋所有同步操作對。我們繼續來看上述程式碼,SW僅對看到的彼此進行操作配對,例如如上通過volatile修飾的變數y,對變數y的寫入與隨後所有y的讀取都將同步,所以SW是根據SO來定義的,由於SO的一致性,對於變數y寫入1僅與讀取1同步,在此示例中,我們看到了讀取和寫入兩個操作之間的SW,該子順序為我們提供了執行緒之間的橋樑,但適用於同步操作,同樣也可以擴充套件到非SA操作,上述程式在多執行緒情況下,將通過PO和SW的並集而得到HB(happens-before),從某種意義上講,HB同時獲得執行緒間和執行緒內的語義,PO將與每個執行緒內的順序操作有關的資訊傳輸到HB中,而在狀態同步時通過SW傳輸,HB是部分排序,並允許使用指令重排序的動作構造等效執行,所以上述可能的執行順序是(write(x,1)、write(y,1)、read(y,1)、read(x,1)),再通俗一點講則是,當執行寫入y時,由於SW(基於SO)的存在立馬對y的讀取完全可見,所以整個HB順序由寫入x到寫入y,很自然的過度到讀取y和讀取x,這是可能性情況之一,如果在執行HB一致性之前就進行了讀取,那麼x和y值可能就是0和0,這屬於HB一致性的邊界情況分析,當然,有的時候結果也會出乎意料,比如在進行x的讀取和寫入時沒有做到HB,可能結果為0和1,那說明存在競爭,換句話說沒有遵守HB一致性,因此不能用來推斷結果。謹記不要將SO一致性和HB一致性概念混淆:SO一致性規則指出同步操作應檢視SO中最新的相關內容,而HB一致性規則指出它指示特定讀取可以觀察到哪些寫入。

 

Java記憶體模型定義了8種操作(lock、unlock、read、load、use、assign、store、write)來進行主記憶體和工作記憶體的互動細節且為原子性,同時針對8種操作之間的規則限定實踐起來非常繁瑣,所以最終通過引入事先發生(happens-before)規則來保證在併發下的執行緒安全,關於以上8種操作的細節請參看《深入理解Java虛擬機器》一書。本文詳細介紹了記憶體模型就是一種規範,提供了可能允許的結果,它的本質是提供了一個弱的DRF保證即順序一致性,而順序一致性則是通過事先發生(happens-before)來保證,而事先發生(happens-before)則是8大規則:程式次序規則、監視器鎖定規則、volatile變數規則、執行緒啟動規則、執行緒終止規則、執行緒中斷規則、物件終結規則、傳遞性。那麼記憶體模型的具體含義是什麼呢?JMM正式定義的基本組成部分是:動作,執行和驗證動作的提交過程,而提交過程又會驗證完整的執行。而動作則是(例如對變數的賦值),而動作彙總於執行,驗證執行通過(po、so、sw、hb)有效執行產生所需的結果,最終提交允許該結果。

 

總結

本文我們步步分析而引入JMM的概念及其本質,最重要的是我們需要明確知道幾個概念:PO、SO、SW、HB,這幾個概念就是對DRF的保證, 程式順序(PO)是對執行緒內的語義描述,同步順序(SO)是同步操作的總順序,同步子順序(SW)是基於SO連線執行緒狀態的橋樑,事先發生(HB)是操作有序性的保