一文帶你深入理解Java記憶體模型,小白也能看得懂!火速收藏!

今日分享開始啦,請大家多多指教~

Java記憶體模型含義?什麼是Java記憶體模型?

Java記憶體模式即Java Memory Model(簡稱JMM),遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式中各個執行緒在各個平臺下都達到一致的記憶體訪問效果。

一文帶你深入理解Java記憶體模型,小白也能看得懂!火速收藏!

Java記憶體模型的好處?

要想知道的Java記憶體模型的好處,就對比沒有Java記憶體模型的情況,在此之前,主流程式語言(C/C++)沒有實現自己獨立的記憶體模型,直接使用物理硬體和作業系統的記憶體模型,因此,會由於不同平臺上的記憶體模型的差異,可能導致程式在一套平臺上併發正常執行,在另一套平臺併發訪問出錯。

Java自身獨立的記憶體模型使其可以實現在不同平臺上正常併發,是其實現跨平臺的關鍵。

主記憶體與工作記憶體

Java記憶體模型的主要目標是

一文帶你深入理解Java記憶體模型,小白也能看得懂!火速收藏!

一文帶你深入理解Java記憶體模型,小白也能看得懂!火速收藏!

JVM是對物理計算機的模擬,JMM是JVM內部的一套執行緒訪問記憶體的規劃,是對物理機中處理器訪問主存的模擬。JMM是執行緒訪問記憶體的一種規範,所以,JVM是存在的,JMM是不存在的。

在物理計算機中,處理器CPU要與主記憶體進行資料互動,但是由於兩者(處理器和主記憶體)之間的速度不匹配的剪刀差,所以現代計算機的解決方式是在處理器和主記憶體之間加一個快取記憶體,快取和主存之間約定好“快取一致性協議”即可,執行的時候,對於處理器中需要的資料,先從快取中找,找不到再到主存中去取,寫入到快取中,處理器再從快取中取,總之處理器不直接與主存互動,這是學生年代《計算機組成原理》中介紹過的。

工作中使用Java開發時,實際上Java記憶體模型也借鑑了物理計算機這個模型,由於Java是支援多執行緒的語言,對於程式中建立的多個執行緒,需要訪問程式碼中的某個公共變數(即計算機主記憶體中的變數)時,不直接訪問記憶體(像處理器不直接訪問記憶體一樣),而是將主存中的變數放在工作記憶體中去,執行緒從自己的工作記憶體中取,下一次又要讀寫變數時,直接從工作記憶體中取,如果工作記憶體中沒有,就再次將主記憶體的目標變數複製到工作記憶體,再由工作記憶體提供給Java執行緒,總之Java執行緒不直接與主記憶體互動,和上面(物理計算機)一樣。

各個執行緒對變數讀寫:各個執行緒中儲存了被該執行緒使用的變數在主記憶體的複製(就像快取中儲存主存中的資料複製一樣),執行緒對變數的讀寫都必須在工作記憶體中進行,不能直接訪問主記憶體。

執行緒間變數值的傳遞:不同執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞需要透過主記憶體來完成。

注意:上下兩個圖中都有主記憶體,但是僅有類似對比意義,實際上是不一樣的,上圖(物理計算機記憶體模型)中的主記憶體是指計算機的物理記憶體條,下圖(Java記憶體模型)中的主記憶體是指JVM申請的那部分物理記憶體(即不是全部物理記憶體,否則整個電腦上就跑一個Java程式,其他應用程式就跑不動了)。

主記憶體和工作記憶體資料互動(原子性:八種原子性操作和八條原則)

八種原子性操作

對於物理機來說,快取記憶體和主記憶體之間的互動有協議,同樣的,Java記憶體中每個執行緒的工作記憶體和JVM佔用的主記憶體的互動是由JVM定義瞭如下的8種操作來完成的,每種操作必須是原子性的。JVM中主記憶體和工作記憶體互動,就是一個變數如何從主記憶體傳輸到工作記憶體中,如何把修改後的變數從工作記憶體同步回主記憶體。

1)lock(鎖定):作用於主記憶體的變數,一個變數在同一時間只能一個執行緒鎖定,該操作表示這條執行緒獨佔這個變數

2)unlock(解鎖):作用於主記憶體的變數,表示這個變數的狀態由處於鎖定狀態被釋放,這樣其他執行緒才能對該變數進行鎖定

3)read(讀取):作用於主記憶體變數,表示把一個主記憶體變數的值傳輸到執行緒的工作記憶體,以便隨後的load操作使用(解釋:主記憶體–>工作記憶體,讀取主記憶體)

4)load(載入):作用於執行緒的工作記憶體的變數,表示把read操作從主記憶體中讀取的變數的值放到工作記憶體的變數副本中(副本是相對於主記憶體的變數而言的)(解釋:主記憶體–>工作記憶體,寫入工作記憶體)

5)use(使用):作用於執行緒的工作記憶體中的變數,表示把工作記憶體中的一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時就會執行該操作(解釋:工作記憶體–>執行引擎,變數操作)

6)assign(賦值):作用於執行緒的工作記憶體的變數,表示把執行引擎返回的結果賦值給工作記憶體中的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時就會執行該操作(解釋:執行引擎–>工作記憶體,變數賦值)

7)store(儲存):作用於執行緒的工作記憶體中的變數,把工作記憶體中的一個變數的值傳遞給主記憶體,以便隨後的write操作使用(解釋:工作記憶體–>主記憶體,讀取工作記憶體)

8)write(寫入):作用於主記憶體的變數,把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中(解釋:工作記憶體–>主記憶體,寫入主記憶體)

對於Java程式中的變數讀取語句,要把一個變數從主記憶體傳輸到工作記憶體,就要順序的執行read和load操作;

對於Java程式中的變數寫入語句,要把一個變數從工作記憶體回寫到主記憶體,就要順序的執行store和write操作。

八條規則

對於普通變數,虛擬機器只是要求順序的執行,並沒有要求連續的執行,所以如下也是正確的。對於兩個執行緒,分別從主記憶體中讀取變數a和b的值,並不一樣要read a; load a; read b; load b; 也會出現如下執行順序:read a; read b; load b; load a; 對於這8種操作,虛擬機器也規定了一系列規則,在執行這8種操作的時候必須遵循如下的規則:

1)read和load、store和write:對於Java程式中的讀取和寫入,不允許read和load、store和write操作之一單獨出現,也就是不允許從主記憶體讀取了變數的值但是工作記憶體不接收的情況,或者不允許從工作記憶體將變數的值回寫到主記憶體但是主記憶體不接收的情況

2)assign:對於Java程式中的執行引擎運算返回結果,不允許一個執行緒丟棄最近的assign操作,也就是不允許執行緒在自己的工作執行緒中修改了變數的值卻不同步/回寫到主記憶體

3)assign:不允許一個執行緒回寫沒有修改的變數到主記憶體,也就是如果執行緒工作記憶體中變數沒有發生過任何assign操作,是不允許將該變數的值回寫到主記憶體

4)use-load,store-assign:先後原則,變數只能在主記憶體中產生,源頭必須是主記憶體,不允許在工作記憶體中直接使用一個未被初始化的變數,也就是沒有執行load或者assign操作,也就是說在執行use、store之前必須對相同的變數執行了load、assign操作

5)lock:一個變數在同一時刻只能被一個執行緒對其進行lock操作,也就是說一個執行緒一旦對一個變數加鎖後,在該執行緒沒有釋放掉鎖之前,其他執行緒是不能對其加鎖的,但是同一個執行緒對一個變數加鎖後,可以繼續加鎖,同時在釋放鎖的時候釋放鎖次數必須和加鎖次數相同。

6)lock:對變數執行lock操作,就會清空工作空間該變數的值,執行引擎使用這個變數之前,需要重新load或者assign操作初始化變數的值

7)unlock:不允許對沒有lock的變數執行unlock操作,如果一個變數沒有被lock操作,那也不能對其執行unlock操作,當然一個執行緒也不能對被其他執行緒lock的變數執行unlock操作

8)unlock:對一個變數執行unlock之前,必須先把變數同步回主記憶體中,也就是執行store和write操作

當然,最重要的還是如開始所說,這8個動作必須是原子的,不可分割的。

分解Java程式練習

常量讀取零步操作,變數讀取一步操作;

常量賦值一步操作,變數賦值兩步操作;

常量計算並寫入兩步操作,變數計算並寫入三步操作。

解釋(八個原子性操作):

常量讀取零步操作,啥都不幹

變數讀取一步操作,讀取變數a,主記憶體–>工作記憶體,先讀取主記憶體read,再寫入工作記憶體load,根據下面規則1,兩個不能拆開,所以變數讀取是原子操作。

常量賦值一步操作,int a=1,工作記憶體–>主記憶體,先讀取工作記憶體store,再寫入主記憶體write,根據下面規則1,兩個不能拆開,所以常量賦值給變數是原子操作。

變數賦值兩步操作,int b=a,先變數a 主記憶體–>工作記憶體,然後變數b 工作記憶體–>主記憶體,兩步操作。

常量計算並寫入兩步操作,int a=1+1,先 1+1=2 返回結果,執行引擎–>工作記憶體,使用assign命令,然後變數a 工作記憶體–>主記憶體,總共兩步操作。

變數計算並寫入三步操作,int b=a+1,先變數a 主記憶體–>工作記憶體,然後 a+1 返回結果,執行引擎–>工作記憶體,最後變數b 工作記憶體–>主記憶體,三步操作。

注意,先用int,先不考慮long和double,這兩個64位的。

long double型變數的特殊規則

Java記憶體模型要求對主記憶體和工作記憶體交換的八個動作是原子的,正如上面所講,但是對long和double有一些特殊規則。原因是什麼呢?

其實,問題倒不是出現在8個動作上,這個8個動作是確實是原子性操作,這一點是毋庸置疑的,問題出在long和double這兩種基本資料型別上。

八個動作中lock、unlock、read、load、use、assign、store、write對待32位的基本資料型別都是原子操作,對待long和double這兩個64位的資料,java虛擬機器規範對java記憶體模型的規定中特別定義了一條相對寬鬆的規則:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行,也就是允許虛擬機器不保證對64位資料的read、load、store和write這4個動作的操作是原子的。

這也就是我們常說的long和double的非原子性協定(Nonautomic Treatment of double and long Variables)。

原子性、可見性與有序性

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

1)原子性:

由Java記憶體模型來直接保證原子性的變數操作包括read、load、use、assign、store、write這6個動作,雖然存在long和double的特例,但基本可以忽略不計,目前虛擬機器基本都對其實現了原子性。如果需要更大範圍的控制,lock和unlock也可以滿足需求。

lock和unlock雖然沒有被虛擬機器直接開放給使用者使用,但是提供了位元組碼層次的指令monitorenter和monitorexit對應這兩個操作,對應到java程式碼就是synchronized關鍵字,因此在synchronized塊之間的程式碼都具有原子性(這是程式設計師所熟知的)。

2)可見性:

可見性是指一個執行緒修改了一個變數的值後,其他執行緒立即可以感知到這個值的修改。正如前面所說,volatile型別的變數在修改後會立即同步給主記憶體,在使用的時候會從主記憶體重新讀取,是依賴主記憶體為中介來保證多執行緒下變數對其他執行緒的可見性的。

除了volatile,synchronized和final也可以實現可見性。synchronized關鍵字是透過unlock之前必須把變數同步回主記憶體來實現的,final則是在初始化後就不會更改,所以只要在初始化過程中沒有把this指標傳遞出去也能保證對其他執行緒的可見性。

3)有序性:

有序性從不同的角度來看是不同的。單純單執行緒來看都是有序的,但到了多執行緒就會跟我們預想的不一樣。可以這麼說:如果在本執行緒內部觀察,所有操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句說的就是“執行緒內表現為序列的語義”,後半句指的是“指令重排序”現象和主記憶體與工作記憶體之間同步存在延遲的現象。

保證有序性的關鍵字有volatile和synchronized,volatile禁止了指令重排序,而synchronized則由“一個變數在同一時刻只能被一個執行緒對其進行lock操作”來保證。

總體來看,synchronized對三種特性(原子性、可見性、有序性)都有支援,雖然簡單,但是如果無控制的濫用對效能就會產生較大影響。

synchronized關鍵字是絕對安全的,因為它可以同時保證原子性、可見性、有序性,但是這並不意味著synchronized關鍵字可以隨意使用,事實上,synchronized是一種重量級鎖,對效能的影響還是比較大的,本文第五部分介紹鎖最佳化就是為了解決synchronized重量級鎖的效能損耗問題。

有序性:先行發生原則

有序性:八條先行發生原則

Java記憶體模型具備一些 先天的“有序性”,即不需要透過任何手段就能夠得到保證的有序性 ,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推匯出來,那麼它們就不能保證它們的有序性,虛擬機器可以隨意地對它們進行重排序。

下面就來具體介紹下happens-before原則(先行發生原則):

(1)程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;

(2)鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;

(3)volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作;

(4)傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;

(5)執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作;

(6)執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生;

(7)執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以透過Thread。join()方法結束、Thread。isAlive()的返回值手段檢測到執行緒已經終止執行;

(8)物件終結規則:一個物件的初始化完成先行發生於他的finalize()方法的開始。

這8條規則中,前4條規則是比較重要的,後4條規則都是顯而易見的。

下面我們來解釋一下前4條規則:

第一條規則:對於程式次序規則來說,我的理解就是一段程式程式碼的執行在單個執行緒中看起來是有序的。注意,雖然這條規則中提到“書寫在前面的操作先行發生於書寫在後面的操作”,這個應該是程式看起來執行的順序是按照程式碼順序執行的,因為虛擬機器可能會對程式程式碼進行指令重排序。

雖然進行重排序,但是最終執行的結果是與程式順序執行的結果一致的,它只會對不存在資料依賴性的指令進行重排序。因此,在單個執行緒中,程式執行看起來是有序執行的,這一點要注意理解。事實上,這個規則是用來保證程式在單執行緒中執行結果的正確性,但無法保證程式在多執行緒中執行的正確性。

第二條規則也比較容易理解,也就是說無論在單執行緒中還是多執行緒中,同一個鎖如果處於被鎖定的狀態,那麼必須先對鎖進行了釋放操作,後面才能繼續進行lock操作。

第三條規則是一條比較重要的規則,也是後文將要重點講述的內容。直觀地解釋就是,如果一個執行緒先去寫一個變數,然後一個執行緒去進行讀取,那麼寫入操作肯定會先行發生於讀操作。

第四條規則實際上就是體現happens-before原則具備傳遞性。

時間上先發生與先行發生

時間上先發生:實際執行先發生,實際執行順序從控制檯列印結果就可以看到;

先行發生:指先行發生的操作影響能被後來的觀察到,A先行於B發生,A的操作影響能被B觀察到。

先行發生例項分析——這裡假設A B兩個執行緒分別呼叫setValue() getValue(),結果執行緒不安全:

一文帶你深入理解Java記憶體模型,小白也能看得懂!火速收藏!

如果有兩個執行緒A和B,A先呼叫setValue方法,然後B呼叫getValue方法,那麼B執行緒執行方法返回的結果是什麼?是預設值0,還是客戶端呼叫setter設定後的值呢?

我們去對照先行發生原則一個一個對比。首先是程式次序規則,這裡是多執行緒,不在一個執行緒中,不適用;然後是管程鎖定規則,這裡沒有synchronized,自然不會發生lock和unlock,不適用;後面對於執行緒啟動規則、執行緒終止規則、執行緒中斷規則也不適用,這裡與物件終結規則、傳遞性規則也沒有關係。

使用剛剛上面粗體標記的這句,如果兩個操作之間均不滿足下列規則,並且無法從下列規則推匯出來的話,它們就沒有順序性保障,虛擬機器可以對它們隨意地進行重排序。這個示例就是這樣,不滿足所有規則,所以虛擬機器可以這個例項程式隨意重排序,所以B返回的結果是不確定的,所以這個例項在多執行緒環境下該操作不是執行緒安全的。

這裡告訴我們,“時間上先發生”(setValue實際順序先於getValue)不代表操作上“先行發生”(getValue不一定能觀察到由於setValue所導致的value值變化)

解決思想:

因為這個例項程式碼在多執行緒下是不安全的,返回值是隨機的,要使這個程式在多執行緒下安全,返回值唯一確定,必須滿足上面8條規則中其中一條。

解決方式一:加上管程鎖定規則,getter/setter方法加上synchronized關鍵字或者lock鎖機制,實現原子操作。

解決方式二:加上volatile變數規則,將value變數上加上volatile關鍵字,實現所有執行緒可見。

先行發生例項分析——這裡假設同一執行緒,結果執行緒安全:

int i = 2;

int j = 1;

這裡對i的賦值先行發生於對j賦值的操作,但是程式碼重排序最佳化,也有可能是j的賦值先發生,但是這個例項是安全的,因為這裡是在同一個執行緒內,程式碼重排序不會導致結果發生變化。

這裡告訴我們,由於程式碼重排序最佳化的存在,“先行發生”(因為這裡是假設同一執行緒,i的設定被j觀察到)不代表操作上“時間上先發生”(i不一定比j先賦值,因為程式碼重排序最佳化的存在)

所以,綜上所述,時間先後順序與先行發生原則之間基本沒有太大關係(這裡我們得到的目標定律)。所以,我們衡量併發安全的問題的時候不要受到時間先後順序的干擾,一切以先行發生原則為準。

今日份分享已結束,請大家多多包涵和指點!

一文帶你深入理解Java記憶體模型,小白也能看得懂!火速收藏!

一文帶你深入理解Java記憶體模型,小白也能看得懂!火速收藏!

如何獲取?

轉發分享此文,後臺私信小編:“1”即可獲取。(注:轉發分享,感謝大家)

相關文章