1. 程式人生 > >Java記憶體模型原理總結(轉自51CTO)

Java記憶體模型原理總結(轉自51CTO)

轉載地址:http://developer.51cto.com/art/201811/587220.htm

【51CTO.com原創稿件】這篇文章主要介紹模型產生的問題背景,解決的問題,處理思路,相關實現規則,環環相扣,希望讀者看完這篇文章後能對 Java 記憶體模型體系產生一個相對清晰的理解,知其然知其所以然。

 

記憶體模型產生背景

 

 

在介紹 Java 記憶體模型之前,我們先了解一下物理計算機中的併發問題,理解這些問題可以搞清楚記憶體模型產生的背景。

 

物理機遇到的併發問題與虛擬機器中的情況有不少相似之處,物理機的解決方案對虛擬機器的實現有相當的參考意義。

 

 

物理機的併發問題

 

 

硬體的效率問題

 

計算機處理器處理絕大多數執行任務都不可能只靠處理器“計算”就能完成,處理器至少需要與記憶體互動,如讀取運算資料、儲存運算結果,這個 I/O 操作很難消除(無法僅靠暫存器完成所有運算任務)。

 

由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,為了避免處理器等待緩慢的記憶體完成讀寫操作,現代計算機系統通過加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體。

 

快取作為記憶體和處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速執行,當運算結束後再從快取同步回記憶體之中。 

快取一致性問題

 

基於快取記憶體的儲存系統互動很好的解決了處理器與記憶體速度的矛盾,但是也為計算機系統帶來更高的複雜度,因為引入了一個新問題:快取一致性。

 

在多處理器的系統中(或者單處理器多核的系統),每個處理器(每個核)都有自己的快取記憶體,而它們有共享同一主記憶體(Main Memory)。

 

當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致。 

 

為此,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議進行操作,來維護快取的一致性。

程式碼亂序執行優化問題

 

為了使得處理器內部的運算單元儘量被充分利用,提高運算效率,處理器可能會對輸入的程式碼進行亂序執行。

 

處理器會在計算之後將亂序執行的結果重組,亂序優化可以保證在單執行緒下該執行結果與順序執行的結果是一致的,但不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致。

亂序執行技術是處理器為提高運算速度而做出違背程式碼原有順序的優化。在單核時代,處理器保證做出的優化不會導致執行結果遠離預期目標,但在多核環境下卻並非如此。

 

在多核環境下, 如果存在一個核的計算任務依賴另一個核計算任務的中間結果。

 

而且對相關資料讀寫沒做任何防護措施,那麼其順序性並不能靠程式碼的先後順序來保證,處理器最終得出的結果和我們邏輯得到的結果可能會大不相同。

以上圖為例進行說明,CPU 的 core2 中的邏輯 B 依賴 core1 中的邏輯 A 先執行:

  • 正常情況下,邏輯 A 執行完之後再執行邏輯 B。
  • 在處理器亂序執行優化情況下,有可能導致 flag 提前被設定為 true,導致邏輯 B 先於邏輯 A 執行。

 

 

Java 記憶體模型的組成分析

 

 

 

記憶體模型概念

 

 

為了更好解決上面提到的系列問題,記憶體模型被總結提出,我們可以把記憶體模型理解為在特定操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。

 

不同架構的物理計算機可以有不一樣的記憶體模型,Java 虛擬機器也有自己的記憶體模型。

 

Java 虛擬機器規範中試圖定義一種 Java 記憶體模型(Java Memory Model,簡稱 JMM)來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果,不必因為不同平臺上的物理機的記憶體模型的差異,對各平臺定製化開發程式。

 

更具體一點說,Java 記憶體模型提出目標在於,定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。

 

此處的變數(Variables)與 Java 程式設計中所說的變數有所區別,它包括了例項欄位、靜態欄位和構成數值物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的。

 

注:如果區域性變數是一個 reference 型別,它引用的物件在 Java 堆中可被各個執行緒共享,但是 reference 本身在 Java 棧的區域性變量表中,它是執行緒私有的。

 

 

Java 記憶體模型的組成

 

 

主記憶體

 

Java 記憶體模型規定了所有變數都儲存在主記憶體(Main Memory)中(此處的主記憶體與介紹物理硬體的主記憶體名字一樣,兩者可以互相類比,但此處僅是虛擬機器記憶體的一部分)。

 

工作記憶體

 

每條執行緒都有自己的工作記憶體(Working Memory,又稱本地記憶體,可與前面介紹的處理器快取記憶體類比),執行緒的工作記憶體中儲存了該執行緒使用到的變數的主記憶體中的共享變數的副本拷貝。

 

工作記憶體是 JMM 的一個抽象概念,並不真實存在。它涵蓋了快取,寫緩衝區,暫存器以及其他的硬體和編譯器優化。

 

Java 記憶體模型抽象示意圖如下:

 

JVM 記憶體操作的併發問題

 

 

結合前面介紹的物理機的處理器處理記憶體的問題,可以類比總結出 JVM 記憶體操作的問題,下面介紹的 Java 記憶體模型的執行處理將圍繞解決這兩個問題展開。

 

工作記憶體資料一致性 

 

各個執行緒操作資料時會儲存使用到的主記憶體中的共享變數副本,當多個執行緒的運算任務都涉及同一個共享變數時,將導致各自的共享變數副本不一致,如果真的發生這種情況,資料同步回主記憶體以誰的副本資料為準? 

 

Java 記憶體模型主要通過一系列的資料同步協議、規則來保證資料的一致性,後面再詳細介紹。

 

指令重排序優化 

 

Java 中重排序通常是編譯器或執行時環境為了優化程式效能而採取的對指令進行重新排序執行的一種手段。

 

重排序分為兩類:編譯期重排序和執行期重排序,分別對應編譯時和執行時環境。 

 

同樣的,指令重排序不是隨意重排序,它需要滿足以下兩個條件:

  • 在單執行緒環境下不能改變程式執行的結果。即時編譯器(和處理器)需要保證程式能夠遵守 as-if-serial 屬性。

通俗地說,就是在單執行緒情況下,要給程式一個順序執行的假象。即經過重排序的執行結果要與順序執行的結果保持一致。

  • 存在資料依賴關係的不允許重排序。

 

多執行緒環境下,如果執行緒處理邏輯之間存在依賴關係,有可能因為指令重排序導致執行結果與預期不同,後面再展開 Java 記憶體模型如何解決這種情況。

 

 

Java 記憶體間的互動操作

 

 

在理解 Java 記憶體模型的系列協議、特殊規則之前,我們先理解 Java 中記憶體間的互動操作。

 

 

互動操作流程

 

 

為了更好理解記憶體的互動操作,以執行緒通訊為例,我們看看具體如何進行執行緒間值的同步:

執行緒 1 和執行緒 2 都有主記憶體中共享變數 x 的副本,初始時,這 3 個記憶體中 x 的值都為 0。

 

執行緒 1 中更新 x 的值為 1 之後同步到執行緒 2 主要涉及兩個步驟: 

  • 執行緒 1 把執行緒工作記憶體中更新過的 x 的值重新整理到主記憶體中。

  • 執行緒 2 到主記憶體中讀取執行緒 1 之前已更新過的 x 變數。

 

從整體上看,這兩個步驟是執行緒 1 在向執行緒 2 發訊息,這個通訊過程必須經過主記憶體。

 

JMM 通過控制主記憶體與每個執行緒本地記憶體之間的互動,來為各個執行緒提供共享變數的可見性。

 

 

記憶體互動的基本操作

 

 

關於主記憶體與工作記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節,Java 記憶體模型中定義了下面 8 種操作來完成。

 

虛擬機器實現時必須保證下面介紹的每種操作都是原子的,不可再分的(對於 double 和 long 型的變數來說,load、store、read、和 write 操作在某些平臺上允許有例外)。

8 種基本操作,如下圖:

  • lock (鎖定) ,作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。
  • unlock (解鎖) ,作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read (讀取) ,作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的 load 動作使用。
  • load (載入) ,作用於工作記憶體的變數,它把 read 操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use (使用) ,作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時就會執行這個操作。
  • assign (賦值) ,作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store (儲存) ,作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後 write 操作使用。
  • write (寫入) ,作用於主記憶體的變數,它把 Store 操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

 

 

Java 記憶體模型執行規則

 

 

 

記憶體互動基本操作的 3 個特性

 

 

在介紹記憶體互動的具體的 8 種基本操作之前,有必要先介紹一下操作的 3 個特性。

 

Java 記憶體模型是圍繞著在併發過程中如何處理這 3 個特性來建立的,這裡先給出定義和基本實現的簡單介紹,後面會逐步展開分析。

 

原子性(Atomicity) 

 

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

 

即使在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所幹擾。

 

可見性(Visibility) 

 

可見性是指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。 

 

正如上面“互動操作流程”中所說明的一樣,JMM 是通過線上程 1 變數工作記憶體修改後將新值同步回主記憶體,執行緒 2 在變數讀取前從主記憶體重新整理變數值,這種依賴主記憶體作為傳遞媒介的方式來實現可見性。

 

有序性(Ordering) 

 

有序性規則表現在以下兩種場景:

  • 執行緒內,從某個執行緒的角度看方法的執行,指令會按照一種叫“序列”(as-if-serial)的方式執行,此種方式已經應用於順序程式語言。
  • 執行緒間,這個執行緒“觀察”到其他執行緒併發地執行非同步的程式碼時,由於指令重排序優化,任何程式碼都有可能交叉執行。

唯一起作用的約束是:對於同步方法,同步塊(synchronized 關鍵字修飾)以及 volatile 欄位的操作仍維持相對有序。

 

Java 記憶體模型的一系列執行規則看起來有點繁瑣,但總結起來,是圍繞原子性、可見性、有序性特徵建立。

 

歸根究底,是為實現共享變數的在多個執行緒的工作記憶體的資料一致性,多執行緒併發,指令重排序優化的環境中程式能如預期執行。

 

 

happens-before 關係

 

 

介紹系列規則之前,首先了解一下 happens-before 關係:用於描述下 2 個操作的記憶體可見性。如果操作 A happens-before 操作 B,那麼 A 的結果對 B 可見。

 

happens-before 關係的分析需要分為單執行緒和多執行緒的情況:

  • 單執行緒下的 happens-before,位元組碼的先後順序天然包含 happens-before 關係:因為單執行緒內共享一份工作記憶體,不存在資料一致性的問題。 

在程式控制流路徑中靠前的位元組碼 happens-before 靠後的位元組碼,即靠前的位元組碼執行完之後操作結果對靠後的位元組碼可見。

  • 然而,這並不意味著前者一定在後者之前執行。實際上,如果後者不依賴前者的執行結果,那麼它們可能會被重排序。

多執行緒下的 happens-before,多執行緒由於每個執行緒有共享變數的副本,如果沒有對共享變數做同步處理,執行緒 1 更新執行操作 A 共享變數的值之後,執行緒 2 開始執行操作 B,此時操作 A 產生的結果對操作 B 不一定可見。

 

為了方便程式開發,Java 記憶體模型實現了下述支援 happens-before 關係的操作: 

  • 程式次序規則,一個執行緒內,按照程式碼順序,書寫在前面的操作 happens-before 書寫在後面的操作。
  • 鎖定規則,一個 unLock 操作 happens-before 後面對同一個鎖的 lock 操作。
  • volatile 變數規則,對一個變數的寫操作 happens-before 後面對這個變數的讀操作。
  • 傳遞規則,如果操作 A happens-before 操作 B,而操作 B 又 happens-before 操作 C,則可以得出操作 A happens-before 操作 C。
  • 執行緒啟動規則,Thread 物件的 start() 方法 happens-before 此執行緒的每個一個動作。
  • 執行緒中斷規則,對執行緒 interrupt() 方法的呼叫 happens-before 被中斷執行緒的程式碼檢測到中斷事件的發生。
  • 執行緒終結規則,執行緒中所有的操作都 happens-before 執行緒的終止檢測,我們可以通過 Thread.join() 方法結束、Thread.isAlive() 的返回值手段檢測到執行緒已經終止執行。
  • 物件終結規則,一個物件的初始化完成 happens-before 它的 finalize() 方法的開始。

 

 

記憶體屏障

 

 

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

 

記憶體屏障是被插入兩個 CPU 指令之間的一種指令,用來禁止處理器指令發生重排序(像屏障一樣),從而保障有序性的。

 

另外,為了達到屏障的效果,它也會使處理器寫入、讀取值之前,將主記憶體的值寫入快取記憶體,清空無效佇列,從而保障可見性。

 

舉個例子說明:

  1. Store1;  
  2. Store2;    
  3. Load1;    
  4. StoreLoad;  //記憶體屏障 
  5. Store3;    
  6. Load2;    
  7. Load3; 

對於上面的一組 CPU 指令(Store 表示寫入指令,Load 表示讀取指令,StoreLoad 代表寫讀記憶體屏障),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 這個類來使用記憶體屏障。

 

 

8 種操作同步的規則

 

 

JMM 在執行前面介紹 8 種基本操作時,為了保證記憶體間資料一致性,JMM 中規定需要滿足以下規則:

  • 規則 1:如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順序的執行 read 和 load 操作,如果把變數從工作記憶體中同步回主記憶體中,就要按順序的執行 store 和 write 操作。
  • 但 Java 記憶體模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。
  • 規則 2:不允許 read 和 load、store 和 write 操作之一單獨出現。
  • 規則 3:不允許一個執行緒丟棄它的最近 assign 的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中。
  • 規則 4:不允許一個執行緒無原因的(沒有發生過任何 assign 操作)把資料從工作記憶體同步回主記憶體中。
  • 規則 5:一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load 或 assign )的變數。
  • 即對一個變數實施 use 和 store 操作之前,必須先執行過了 load 或 assign 操作。
  • 規則 6:一個變數在同一個時刻只允許一條執行緒對其進行 lock 操作,但 lock 操作可以被同一條執行緒重複執行多次,多次執行 lock 後,只有執行相同次數的 unlock 操作,變數才會被解鎖。所以 lock 和 unlock 必須成對出現。
  • 規則 7:如果對一個變數執行 lock 操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行 load 或 assign 操作初始化變數的值。
  • 規則 8:如果一個變數事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作;也不允許去 unlock 一個被其他執行緒鎖定的變數。
  • 規則 9:對一個變數執行 unlock 操作之前,必須先把此變數同步到主記憶體中(執行 store 和 write 操作)。

 

看起來這些規則有些繁瑣,其實也不難理解:

  • 規則 1、規則 2,工作記憶體中的共享變數作為主記憶體的副本,主記憶體變數的值同步到工作記憶體需要 read 和 load 一起使用。

工作記憶體中的變數的值同步回主記憶體需要 store 和 write 一起使用,這 2 組操作各自都是一個固定的有序搭配,不允許單獨出現。

  • 規則 3、規則 4,由於工作記憶體中的共享變數是主記憶體的副本,為保證資料一致性,當工作記憶體中的變數被位元組碼引擎重新賦值,必須同步回主記憶體。如果工作記憶體的變數沒有被更新,不允許無原因同步回主記憶體。
  • 規則 5,由於工作記憶體中的共享變數是主記憶體的副本,必須從主記憶體誕生。
  • 規則 6、7、8、9,為了併發情況下安全使用變數,執行緒可以基於 lock 操作獨佔主記憶體中的變數,其他執行緒不允許使用或 unlock 該變數,直到變數被執行緒 unlock。

 

 

volatile 型變數的特殊規則

 

 

volatile 的中文意思是不穩定的,易變的,用 volatile 修飾變數是為了保證變數的可見性。

 

volatile 的語義

 

volatile 主要有下面 2 種語義:

  • 保證可見性
  • 禁止進行指令重排序

 

保證可見性,保證了不同執行緒對該變數操作的記憶體可見性。這裡保證可見性不等同於 volatile 變數併發操作的安全性,保證可見性具體一點解釋:

  • 執行緒對變數進行修改之後,要立刻回寫到主記憶體。
  • 執行緒對變數讀取的時候,要從主記憶體中讀,而不是從執行緒的工作記憶體。

 

但是如果多個執行緒同時把更新後的變數值同時重新整理回主記憶體,可能導致得到的值不是預期結果。

 

舉個例子:定義 volatile int count = 0,2 個執行緒同時執行 count++ 操作,每個執行緒都執行 500 次,最終結果小於 1000。

 

原因是每個執行緒執行 count++ 需要以下 3 個步驟:

  • 執行緒從主記憶體讀取最新的 count 的值。
  • 執行引擎把 count 值加 1,並賦值給執行緒工作記憶體。
  • 執行緒工作記憶體把 count 值儲存到主記憶體。

 

有可能某一時刻 2 個執行緒在步驟 1 讀取到的值都是 100,執行完步驟 2 得到的值都是 101,最後重新整理了 2 次 101 儲存到主記憶體。

 

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

  • 當程式執行到 volatile 變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行。
  • 在進行指令優化時,不能將在對 volatile 變數訪問的語句放在其後面執行,也不能把 volatile 變數後面的語句放到其前面執行。

普通的變數僅僅會保證該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證賦值操作的順序與程式程式碼中的執行順序一致。

 

舉個例子:

  1. volatile boolean initialized = false; 
  2.  
  3. // 下面程式碼執行緒A中執行 
  4. // 讀取配置資訊,當讀取完成後將initialized設定為true以通知其他執行緒配置可用 
  5. doSomethingReadConfg(); 
  6. initialized = true; 
  7.  
  8. // 下面程式碼執行緒B中執行 
  9. // 等待initialized 為true,代表執行緒A已經把配置資訊初始化完成 
  10. while (!initialized) { 
  11.      sleep(); 
  12. // 使用執行緒A初始化好的配置資訊 
  13. 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 型變數使用場景

 

總結起來,就是“一次寫入,到處讀取”,某一執行緒負責更新變數,其他執行緒只讀取變數(不更新變數),並根據變數的新值執行相應邏輯。例如狀態標誌位更新,觀察者模型變數值釋出。

 

 

final 型變數的特殊規則

 

 

我們知道,final 成員變數必須在宣告的時候初始化或者在構造器中初始化,否則就會報編譯錯誤。 

 

final 關鍵字的可見性是指:被 final 修飾的欄位在宣告時或者構造器中,一旦初始化完成,那麼在其他執行緒無須同步就能正確看見 final 欄位的值。這是因為一旦初始化完成,final 變數的值立刻回寫到主記憶體。

 

 

synchronized 的特殊規則

 

 

通過 synchronized 關鍵字包住的程式碼區域,對資料的讀寫進行控制:

  • 讀資料,當執行緒進入到該區域讀取變數資訊時,對資料的讀取也不能從工作記憶體讀取,只能從記憶體中讀取,保證讀到的是最新的值。

  • 寫資料,在同步區內對變數的寫入操作,在離開同步區時就將當前執行緒內的資料重新整理到記憶體中,保證更新的資料對其他執行緒的可見性。

 

 

long 和 double 型變數的特殊規則

 

 

Java 記憶體模型要求 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。

 

 

總結

 

 

由於 Java 記憶體模型涉及系列規則,網上的文章大部分就是對這些規則進行解析,但是很多沒有解釋為什麼需要這些規則,這些規則的作用。

 

其實這是不利於初學者學習的,容易繞進這些繁瑣規則不知所以然,下面談談我的一點學習知識的個人體會:

 

學習知識的過程不是等同於只是理解知識和記憶知識,而是要對知識解決的問題的輸入和輸出建立連線。

 

知識的本質是解決問題,所以在學習之前要理解問題,理解這個問題要的輸入和輸出,而知識就是輸入到輸出的一個關係對映。

 

知識的學習要結合大量的例子來理解這個對映關係,然後壓縮知識,華羅庚說過:“把一本書讀厚,然後再讀薄”,解釋的就是這個道理,先結合大量的例子理解知識,然後再壓縮知識。

 

以學習 Java 記憶體模型為例:

  • 理解問題:明確輸入輸出,首先理解 Java 記憶體模型是什麼,有什麼用,解決什麼問題 。
  • 理解記憶體模型系列協議:結合大量例子理解這些協議規則。
  • 壓縮知識:大量規則其實就是通過資料同步協議,保證記憶體副本之間的資料一致性,同時防止重排序對程式的影響。

 

參考文章:

  • 《深入學習Java虛擬機器》
  • 深入拆解Java虛擬機器
  • Java核心技術36講
  • Synchronization and the Java Memory Model ——Doug Lea
  • 深入理解 Java 記憶體模型
  • Java記憶體屏障和可見性
  • 記憶體屏障與synchronized、volatile的原理