天天看點

Java 記憶體模型

📦 本文以及示例源碼已歸檔在 javacore

Java 記憶體模型(Java Memory Model),簡稱 JMM。

JVM 中試圖定義一種 JMM 來屏蔽各種硬體和作業系統的記憶體通路差異,以實作讓 Java 程式在各種平台下都能達到一緻的記憶體通路效果。

一、實體記憶體模型

實體機遇到的并發問題與虛拟機中的情況有不少相似之處,實體機對并發的處理方案對于虛拟機的實作也有相當大的參考意義。

硬體處理效率

實體記憶體的第一個問題是:硬體處理效率。

  • 絕大多數的運算任務都不可能隻靠處理器“計算”就能完成,處理器至少需要與記憶體互動,如讀取運算資料、存儲運算結果,這個 I/O 操作是很難消除的(無法僅靠寄存器完成所有運算任務)。
  • 由于計算機的儲存設備與處理器的運算速度有幾個數量級的差距 ,這種速度上的沖突,會降低硬體的處理效率。是以,現代計算機都不得不 加入高速緩存(Cache) 來作為記憶體和處理器之間的緩沖。将需要用到的資料複制到緩存中,讓運算能快速進行,當運算結束後再從緩存同步會記憶體中,這樣處理器就無需等待緩慢的記憶體讀寫了。

緩存一緻性

高速緩存解決了 硬體效率問題,但是引入了一個新的問題:緩存一緻性(Cache Coherence)。

在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主記憶體。當多個處理器的運算任務都涉及同一塊主記憶體區域時,将可能導緻各自的緩存資料不一緻。

為了解決緩存一緻性問題,需要各個處理器通路緩存時都遵循一些協定,在讀寫時要根據協定來進行操作。

Java 記憶體模型

代碼亂序執行優化

除了高速緩存以外,為了使得處理器内部的運算單元盡量被充分利用,處理器可能會對輸入代碼進行亂序執行(Out-Of-Order Execution)優化。處理器會在計算之後将亂序執行的結果重組,保證該結果與順序執行的結果是一緻的,但不保證程式中各個語句計算的先後順序與輸入代碼中的順序一緻。

Java 記憶體模型

亂序執行技術是處理器為提高運算速度而做出違背代碼原有順序的優化。

  • 單核環境下,處理器保證做出的優化不會導緻執行結果遠離預期目标,但在多核環境下卻并非如此。
  • 多核環境下, 如果存在一個核的計算任務依賴另一個核的計算任務的中間結果,而且對相關資料讀寫沒做任何防護措施,那麼其順序性并不能靠代碼的先後順序來保證。
Java 記憶體模型

二、Java 記憶體模型

記憶體模型

這個概念。我們可以了解為:在特定的操作協定下,對特定的記憶體或高速緩存進行讀寫通路的過程抽象。不同架構的實體計算機可以有不一樣的記憶體模型,JVM 也有自己的記憶體模型。

JVM 中試圖定義一種 Java 記憶體模型(Java Memory Model, JMM)來屏蔽各種硬體和作業系統的記憶體通路差異,以實作讓 Java 程式 在各種平台下都能達到一緻的記憶體通路效果。

主記憶體和工作記憶體

JMM 的主要目标是 定義程式中各個變量的通路規則,即在虛拟機中将變量存儲到記憶體和從記憶體中取出變量這樣的底層細節。此處的變量(Variables)與 Java 程式設計中所說的變量有所差別,它包括了執行個體字段、靜态字段和構成數值對象的元素,但不包括局部變量與方法參數,因為後者是線程私有的,不會被共享,自然就不會存在競争問題。為了獲得較好的執行效能,JMM 并沒有限制執行引擎使用處理器的特定寄存器或緩存來和主存進行互動,也沒有限制即使編譯器進行調整代碼執行順序這類優化措施。

JMM 規定了所有的變量都存儲在主記憶體(Main Memory)中。

每條線程還有自己的工作記憶體(Working Memory),工作記憶體中保留了該線程使用到的變量的主記憶體的副本。工作記憶體是 JMM 的一個抽象概念,并不真實存在,它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬體和編譯器優化。

Java 記憶體模型

線程對變量的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變量。不同的線程間也無法直接通路對方工作記憶體中的變量,線程間變量值的傳遞均需要通過主記憶體來完成。

Java 記憶體模型

說明:

這裡說的主記憶體、工作記憶體與 Java 記憶體區域中的堆、棧、方法區等不是同一個層次的記憶體劃分。

JMM 記憶體操作的問題

類似于實體記憶體模型面臨的問題,JMM 存在以下兩個問題:

  • 工作記憶體資料一緻性 - 各個線程操作資料時會儲存使用到的主記憶體中的共享變量副本,當多個線程的運算任務都涉及同一個共享變量時,将導緻各自的的共享變量副本不一緻。如果真的發生這種情況,資料同步回主記憶體以誰的副本資料為準? Java 記憶體模型主要通過一系列的資料同步協定、規則來保證資料的一緻性。
  • 指令重排序優化 - Java 中重排序通常是編譯器或運作時環境為了優化程式性能而采取的對指令進行重新排序執行的一種手段。重排序分為兩類:編譯期重排序和運作期重排序,分别對應編譯時和運作時環境。 同樣的,指令重排序不是随意重排序,它需要滿足以下兩個條件:
    • 在單線程環境下不能改變程式運作的結果。即時編譯器(和處理器)需要保證程式能夠遵守

      as-if-serial

      屬性。通俗地說,就是在單線程情況下,要給程式一個順序執行的假象。即經過重排序的執行結果要與順序執行的結果保持一緻。
    • 存在資料依賴關系的不允許重排序。
    • 多線程環境下,如果線程處理邏輯之間存在依賴關系,有可能因為指令重排序導緻運作結果與預期不同。

記憶體間互動操作

JMM 定義了 8 個操作來完成主記憶體和工作記憶體之間的互動操作。JVM 實作時必須保證下面介紹的每種操作都是 原子的(對于 double 和 long 型的變量來說,load、store、read、和 write 操作在某些平台上允許有例外 )。

  • lock

    (鎖定) - 作用于主記憶體的變量,它把一個變量辨別為一條線程獨占的狀态。
  • unlock

    (解鎖) - 作用于主記憶體的變量,它把一個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定。
  • read

    (讀取) - 作用于主記憶體的變量,它把一個變量的值從主記憶體傳輸到線程的工作記憶體中,以便随後的

    load

    動作使用。
  • write

    (寫入) - 作用于主記憶體的變量,它把 store 操作從工作記憶體中得到的變量的值放入主記憶體的變量中。
  • load

    (載入) - 作用于工作記憶體的變量,它把 read 操作從主記憶體中得到的變量值放入工作記憶體的變量副本中。
  • use

    (使用) - 作用于工作記憶體的變量,它把工作記憶體中一個變量的值傳遞給執行引擎,每當虛拟機遇到一個需要使用到變量的值得位元組碼指令時就會執行這個操作。
  • assign

    (指派) - 作用于工作記憶體的變量,它把一個從執行引擎接收到的值賦給工作記憶體的變量,每當虛拟機遇到一個給變量指派的位元組碼指令時執行這個操作。
  • store

    (存儲) - 作用于工作記憶體的變量,它把工作記憶體中一個變量的值傳送到主記憶體中,以便随後

    write

    操作使用。

如果要把一個變量從主記憶體中複制到工作記憶體,就需要按序執行

read

load

操作;如果把變量從工作記憶體中同步回主記憶體中,就需要按序執行

store

write

操作。但 Java 記憶體模型隻要求上述操作必須按順序執行,而沒有保證必須是連續執行。

JMM 還規定了上述 8 種基本操作,需要滿足以下規則:

  • read 和 load 必須成對出現;store 和 write 必須成對出現。即不允許一個變量從主記憶體讀取了但工作記憶體不接受,或從工作記憶體發起回寫了但主記憶體不接受的情況出現。
  • 不允許一個線程丢棄它的最近 assign 的操作,即變量在工作記憶體中改變了之後必須把變化同步到主記憶體中。
  • 不允許一個線程無原因的(沒有發生過任何 assign 操作)把資料從工作記憶體同步回主記憶體中。
  • 一個新的變量隻能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load 或 assign )的變量。換句話說,就是對一個變量實施 use 和 store 操作之前,必須先執行過了 load 或 assign 操作。
  • 一個變量在同一個時刻隻允許一條線程對其進行 lock 操作,但 lock 操作可以被同一條線程重複執行多次,多次執行 lock 後,隻有執行相同次數的 unlock 操作,變量才會被解鎖。是以 lock 和 unlock 必須成對出現。
  • 如果對一個變量執行 lock 操作,将會清空工作記憶體中此變量的值,在執行引擎使用這個變量前,需要重新執行 load 或 assign 操作初始化變量的值。
  • 如果一個變量事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作,也不允許去 unlock 一個被其他線程鎖定的變量。
  • 對一個變量執行 unlock 操作之前,必須先把此變量同步到主記憶體中(執行 store 和 write 操作)
Java 記憶體模型

三、Java 記憶體模型規則

記憶體互動操作的三大特性

上文介紹了 Java 記憶體互動的 8 種基本操作,它們遵循 Java 記憶體三大特性:原子性、可見性、有序性。

而這三大特性,歸根結底,是為了實作多線程的 資料一緻性,使得程式在多線程并發,指令重排序優化的環境中能如預期運作。

原子性

原子性即一個操作或者多個操作,要麼全部執行(執行的過程不會被任何因素打斷),要麼就都不執行。即使在多個線程一起執行的時候,一個操作一旦開始,就不會被其他線程所幹擾。

在 Java 中,為了保證原子性,提供了兩個進階的位元組碼指令

monitorenter

monitorexit

。這兩個位元組碼,在 Java 中對應的關鍵字就是

synchronized

是以,在 Java 中可以使用

synchronized

來保證方法和代碼塊内的操作是原子性的。

可見性

可見性是指當多個線程通路同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。

JMM 是通過 "變量修改後将新值同步回主記憶體, 變量讀取前從主記憶體重新整理變量值" 這種依賴主記憶體作為傳遞媒介的方式來實作的。

Java 實作多線程可見性的方式有:

  • volatile

  • synchronized

  • final

有序性

有序性規則表現在以下兩種場景: 線程内和線程間

  • 線程内 - 從某個線程的角度看方法的執行,指令會按照一種叫“串行”(

    as-if-serial

    )的方式執行,此種方式已經應用于順序程式設計語言。
  • 線程間 - 這個線程“觀察”到其他線程并發地執行非同步的代碼時,由于指令重排序優化,任何代碼都有可能交叉執行。唯一起作用的限制是:對于同步方法,同步塊(

    synchronized

    關鍵字修飾)以及

    volatile

    字段的操作仍維持相對有序。

在 Java 中,可以使用

synchronized

volatile

來保證多線程之間操作的有序性。實作方式有所差別:

  • volatile

    關鍵字會禁止指令重排序。
  • synchronized

    關鍵字通過互斥保證同一時刻隻允許一條線程操作。

先行發生原則

JMM 為程式中所有的操作定義了一個偏序關系,稱之為

先行發生原則(Happens-Before)

先行發生原則非常重要,它是判斷資料是否存在競争、線程是否安全的主要依據,依靠這個原則,我們可以通過幾條規則一攬子地解決并發環境下兩個操作間是否可能存在沖突的所有問題。
  • 程式次序規則 - 一個線程内,按照代碼順序,書寫在前面的操作先行發生于書寫在後面的操作。
  • 管程鎖定規則 - 一個

    unLock

    操作先行發生于後面對同一個鎖的

    lock

    操作。
  • volatile 變量規則 - 對一個

    volatile

    變量的寫操作先行發生于後面對這個變量的讀操作。
  • 線程啟動規則 -

    Thread

    對象的

    start()

    方法先行發生于此線程的每個一個動作。
  • 線程終止規則 - 線程中所有的操作都先行發生于線程的終止檢測,我們可以通過

    Thread.join()

    方法結束、Thread.isAlive() 的傳回值手段檢測到線程已經終止執行。
  • 線程中斷規則 - 對線程

    interrupt()

    方法的調用先行發生于被中斷線程的代碼檢測到中斷事件的發生,可以通過

    Thread.interrupted()

    方法檢測到是否有中斷發生。
  • 對象終結規則 - 一個對象的初始化完成先行發生于它的

    finalize()

    方法的開始。
  • 傳遞性 - 如果操作 A 先行發生于 操作 B,而操作 B 又 先行發生于 操作 C,則可以得出操作 A 先行發生于 操作 C。

記憶體屏障

Java 中如何保證底層操作的有序性和可見性?可以通過記憶體屏障。

記憶體屏障是被插入兩個 CPU 指令之間的一種指令,用來禁止處理器指令發生重排序(像屏障一樣),進而保障有序性的。另外,為了達到屏障的效果,它也會使處理器寫入、讀取值之前,将主記憶體的值寫入高速緩存,清空無效隊列,進而保障可見性。

舉個例子:

Store1;
Store2;
Load1;
StoreLoad;  //記憶體屏障
Store3;
Load2;
Load3;
複制代碼
           

對于上面的一組 CPU 指令(Store 表示寫入指令,Load 表示讀取指令),StoreLoad 屏障之前的 Store 指令無法與 StoreLoad 屏障之後的 Load 指令進行交換位置,即重排序。但是 StoreLoad 屏障之前和之後的指令是可以互換位置的,即 Store1 可以和 Store2 互換,Load2 可以和 Load3 互換。

常見有 4 種屏障

  • LoadLoad

    屏障 - 對于這樣的語句

    Load1; LoadLoad; Load2

    ,在 Load2 及後續讀取操作要讀取的資料被通路前,保證 Load1 要讀取的資料被讀取完畢。
  • StoreStore

    Store1; StoreStore; Store2

    ,在 Store2 及後續寫入操作執行前,保證 Store1 的寫入操作對其它處理器可見。
  • LoadStore

    Load1; LoadStore; Store2

    ,在 Store2 及後續寫入操作被執行前,保證 Load1 要讀取的資料被讀取完畢。
  • StoreLoad

    Store1; StoreLoad; Load2

    ,在 Load2 及後續所有讀取操作執行前,保證 Store1 的寫入對所有處理器可見。它的開銷是四種屏障中最大的(沖刷寫緩沖器,清空無效化隊列)。在大多數處理器的實作中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能。

Java 中對記憶體屏障的使用在一般的代碼中不太容易見到,常見的有

volatile

synchronized

關鍵字修飾的代碼塊(後面再展開介紹),還可以通過

Unsafe

這個類來使用記憶體屏障。

volatile 變量的特殊規則

volatile

是 JVM 提供的 最輕量級的同步機制。

volatile

的中文意思是不穩定的,易變的,用

volatile

修飾變量是為了保證變量在多線程中的可見性。

volatile 變量的特性

volatile

變量具有兩種特性:

  • 保證變量對所有線程的可見性。
  • 禁止進行指令重排序
保證變量對所有線程的可見性

這裡的可見性是指當一條線程修改了 volatile 變量的值,新值對于其他線程來說是可以立即得知的。而普通變量不能做到這一點,普通變量的值線上程間傳遞均需要通過主記憶體來完成。

線程寫 volatile 變量的過程:

  1. 改變線程工作記憶體中 volatile 變量副本的值
  2. 将改變後的副本的值從工作記憶體重新整理到主記憶體

線程讀 volatile 變量的過程:

  1. 從主記憶體中讀取 volatile 變量的最新值到線程的工作記憶體中
  2. 從工作記憶體中讀取 volatile 變量的副本

注意:保證可見性不等同于 volatile 變量保證并發操作的安全性

在不符合以下兩點的場景中,仍然要通過枷鎖來保證原子性:

  • 運算結果并不依賴變量的目前值,或者能夠確定隻有單一的線程修改變量的值。
  • 變量不需要與其他狀态變量共同參與不變限制。

但是如果多個線程同時把更新後的變量值同時重新整理回主記憶體,可能導緻得到的值不是預期結果:

舉個例子: 定義

volatile int count = 0

,2 個線程同時執行 count++ 操作,每個線程都執行 500 次,最終結果小于 1000,原因是每個線程執行 count++ 需要以下 3 個步驟:

  1. 線程從主記憶體讀取最新的 count 的值
  2. 執行引擎把 count 值加 1,并指派給線程工作記憶體
  3. 線程工作記憶體把 count 值儲存到主記憶體 有可能某一時刻 2 個線程在步驟 1 讀取到的值都是 100,執行完步驟 2 得到的值都是 101,最後重新整理了 2 次 101 儲存到主記憶體。
語義 2 禁止進行指令重排序

具體一點解釋,禁止重排序的規則如下:

  • 當程式執行到

    volatile

    變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;
  • 在進行指令優化時,不能将在對

    volatile

    變量通路的語句放在其後面執行,也不能把

    volatile

    變量後面的語句放到其前面執行。

普通的變量僅僅會保證該方法的執行過程中所有依賴指派結果的地方都能擷取到正确的結果,而不能保證指派操作的順序與程式代碼中的執行順序一緻。

volatile boolean initialized = false;

// 下面代碼線程A中執行
// 讀取配置資訊,當讀取完成後将initialized設定為true以通知其他線程配置可用
doSomethingReadConfg();
initialized = true;

// 下面代碼線程B中執行
// 等待initialized 為true,代表線程A已經把配置資訊初始化完成
while (!initialized) {
     sleep();
}
// 使用線程A初始化好的配置資訊
doSomethingWithConfig();
複制代碼
           

上面代碼中如果定義 initialized 變量時沒有使用 volatile 修飾,就有可能會由于指令重排序的優化,導緻線程 A 中最後一句代碼 "initialized = true" 在 “doSomethingReadConfg()” 之前被執行,這樣會導緻線程 B 中使用配置資訊的代碼就可能出現錯誤,而 volatile 關鍵字就禁止重排序的語義可以避免此類情況發生。

volatile 的原理

具體實作方式是在編譯期生成位元組碼時,會在指令序列中增加記憶體屏障來保證,下面是基于保守政策的 JMM 記憶體屏障插入政策:

  • 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。 該屏障除了保證了屏障之前的寫操作和該屏障之後的寫操作不能重排序,還會保證了 volatile 寫操作之前,任何的讀寫操作都會先于 volatile 被送出。
  • 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障。 該屏障除了使 volatile 寫操作不會與之後的讀操作重排序外,還會重新整理處理器緩存,使 volatile 變量的寫更新對其他線程可見。
  • 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。 該屏障除了使 volatile 讀操作不會與之前的寫操作發生重排序外,還會重新整理處理器緩存,使 volatile 變量讀取的為最新值。
  • 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。 該屏障除了禁止了 volatile 讀操作與其之後的任何寫操作進行重排序,還會重新整理處理器緩存,使其他線程 volatile 變量的寫更新對 volatile 讀操作的線程可見。

volatile 的使用場景

總結起來,就是“一次寫入,到處讀取”,某一線程負責更新變量,其他線程隻讀取變量(不更新變量),并根據變量的新值執行相應邏輯。例如狀态标志位更新,觀察者模型變量值釋出。

long 和 double 變量的特殊規則

JMM 要求 lock、unlock、read、load、assign、use、store、write 這 8 種操作都具有原子性,但是對于 64 位的資料類型(long 和 double),在模型中特别定義相對寬松的規定:允許虛拟機将沒有被

volatile

修飾的 64 位資料的讀寫操作分為 2 次 32 位的操作來進行,即允許虛拟機可選擇不保證 64 位資料類型的 load、store、read 和 write 這 4 個操作的原子性。由于這種非原子性,有可能導緻其他線程讀到同步未完成的“32 位的半個變量”的值。

不過實際開發中,Java 記憶體模型強烈建議虛拟機把 64 位資料的讀寫實作為具有原子性,目前各種平台下的商用虛拟機都選擇把 64 位資料的讀寫操作作為原子操作來對待,是以我們在編寫代碼時一般不需要把用到的 long 和 double 變量專門聲明為 volatile。

final 型量的特殊規則

我們知道,final 成員變量必須在聲明的時候初始化或者在構造器中初始化,否則就會報編譯錯誤。 final 關鍵字的可見性是指:被 final 修飾的字段在聲明時或者構造器中,一旦初始化完成,那麼在其他線程無須同步就能正确看見 final 字段的值。這是因為一旦初始化完成,final 變量的值立刻回寫到主記憶體。

參考資料

  • 《Java 并發程式設計實戰》
  • 《Java 并發程式設計的藝術》
  • 《深入了解 Java 虛拟機》
  • 了解 Java 記憶體模型