Java內存模型原理,你真的理解透徹了嗎?

內存模型產生背景編程

在介紹 Java 內存模型以前,咱們先了解一下物理計算機中的併發問題,理解這些問題能夠搞清楚內存模型產生的背景。緩存

物理機遇到的併發問題與虛擬機中的狀況有很多類似之處,物理機的解決方案對虛擬機的實現有至關的參考意義。安全

物理機的併發問題多線程

硬件的效率問題架構

計算機處理器處理絕大多數運行任務都不可能只靠處理器「計算」就能完成,處理器至少須要與內存交互,如讀取運算數據、存儲運算結果,這個 I/O 操做很難消除(沒法僅靠寄存器完成全部運算任務)。併發

因爲計算機的存儲設備與處理器的運算速度有幾個數量級的差距,爲了不處理器等待緩慢的內存 完成 讀寫操做,現代計算機系統經過加入一層讀寫速度儘量接近處理器運算速度的高速緩存。app

緩存做爲內存和處理器之間的緩衝: 將運算須要使用到的數據複製到緩存中,讓運算能快速運行,當運算結束後再從緩存同步回內存之中。 編程語言

緩存一致性問題性能

基於高速緩存的存儲系統交互很好的解決了處理器與內存速度的矛盾,可是也爲計算機系統帶來更高的複雜度,由於引入了一個新問題:緩存一致性。學習

在多處理器的系統中(或者單處理器多核的系統),每一個處理器(每一個核)都有本身的高速緩存,而它們有共享同一主內存(Main Memory)。

當多個處理器的運算任務都涉及同一塊主內存區域時,將可能致使各自的緩存數據不一致。 

爲此,須要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議進行操做,來維護緩存的一致性。

代碼亂序執行優化問題

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

處理器會在計算以後將亂序執行的結果重組,亂序優化能夠保證在單線程下該執行結果與順序執行的結果是一致的,但不保證程序中各個語句計算的前後順序與輸入代碼中的順序一致。在此我向你們推薦一個架構學習交流裙。交流學習裙號:821169538,裏面會分享一些資深架構師錄製的視頻錄像 

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

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

並且對相關數據讀寫沒作任何防禦措施,那麼其順序性並不能靠代碼的前後順序來保證,處理器最終得出的結果和咱們邏輯獲得的結果可能會大不相同。

以上圖爲例進行說明,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 (使用) , 做用於工做內存的變量,它把工做內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個須要使用到變量的值的字節碼指令時就會執行這個操做。在此我向你們推薦一個架構學習交流裙。交流學習裙號:821169538,裏面會分享一些資深架構師錄製的視頻錄像

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 指令之間的一種指令,用來禁止處理器指令發生重排序(像屏障同樣),從而保障有序性的。

另外,爲了達到屏障的效果,它也會使處理器寫入、讀取值以前,將主內存的值寫入高速緩存,清空無效隊列,從而保障可見性。

舉個例子說明:

Store1;Store2;Load1;StoreLoad;//內存屏障 Store3;Load2;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 操做)。

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

規則 一、規則 2, 工做內存中的共享變量做爲主內存的副本,主內存變量的值同步到工做內存須要 read 和 load 一塊兒使用。

工做內存中的變量的值同步回主內存須要 store 和 write 一塊兒使用,這 2 組操做各自都是一個固定的有序搭配,不容許單獨出現。

規則 三、規則 4, 因爲工做內存中的共享變量是主內存的副本,爲保證數據一致性,當工做內存中的變量被字節碼引擎從新賦值,必須同步回主內存。若是工做內存的變量沒有被更新,不容許無緣由同步回主內存。

規則 5, 因爲工做內存中的共享變量是主內存的副本,必須從主內存誕生。

規則 六、七、八、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 變量後面的語句放到其前面執行。

普通的變量僅僅會保證該方法的執行過程當中全部依賴賦值結果的地方都能獲取到正確的結果,而不能保證賦值操做的順序與程序代碼中的執行順序一致。

舉個例子:

volatilebooleaninitialized =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 內存屏障插入策略:在此我向你們推薦一個架構學習交流裙。交流學習裙號:821169538,裏面會分享一些資深架構師錄製的視頻錄像

在每一個 volatile 寫操做的前面插入一個 StoreStore 屏障。 該屏障除了保證了屏障以前的寫操做和該屏障以後的寫操做不能重排序,還會保證了 v olatile 寫操做以前,任何的讀寫操做都會先於 volatile 被提交。

在每一個 olatile 寫操做的後面插入一個 StoreLoad 屏障。 該屏障除了使 v olatile 寫操做不會與以後的讀操做重排序外,還會刷新處理器緩存,使 v olatile 變量的寫更新對其餘線程可見。

在每一個 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 內存模型涉及系列規則,網上的文章大部分就是對這些規則進行解析,可是不少沒有解釋爲何須要這些規則,這些規則的做用。

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

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

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

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

相關文章
相關標籤/搜索