死磕 java同步系列之JMM(Java Memory Model)

彤哥讀原始碼發表於2019-05-18

簡介

Java記憶體模型是在硬體記憶體模型上的更高層的抽象,它遮蔽了各種硬體和作業系統訪問的差異性,保證了Java程式在各種平臺下對記憶體的訪問都能達到一致的效果。

硬體記憶體模型

在正式講解Java的記憶體模型之前,我們有必要先了解一下硬體層面的一些東西。

在現代計算機的硬體體系中,CPU的運算速度是非常快的,遠遠高於它從儲存介質讀取資料的速度,這裡的儲存介質有很多,比如磁碟、光碟、網路卡、記憶體等,這些儲存介質有一個很明顯的特點——距離CPU越近的儲存介質往往越小越貴越快,距離CPU越遠的儲存介質往往越大越便宜越慢。

所以,在程式執行的過程中,CPU大部分時間都浪費在了磁碟IO、網路通訊、資料庫訪問上,如果不想讓CPU在那裡白白等待,我們就必須想辦法去把CPU的運算能力壓榨出來,否則就會造成很大的浪費,而讓CPU同時去處理多項任務則是最容易想到的,也是被證明非常有效的壓榨手段,這也就是我們常說的“併發執行”。

但是,讓CPU併發地執行多項任務並不是那麼容易實現的事,因為所有的運算都不可能只依靠CPU的計算就能完成,往往還需要跟記憶體進行互動,如讀取運算資料、儲存運算結果等。

前面我們也說過了,CPU與記憶體的互動往往是很慢的,所以這就要求我們要想辦法在CPU和記憶體之間建立一種連線,使它們達到一種平衡,讓運算能快速地進行,而這種連線就是我們常說的“快取記憶體”。

快取記憶體的速度是非常接近CPU的,但是它的引入又帶來了新的問題,現代的CPU往往是有多個核心的,每個核心都有自己的快取,而多個核心之間是不存在時間片的競爭的,它們可以並行地執行,那麼,怎麼保證這些快取與主記憶體中的資料的一致性就成為了一個難題。

為了解決快取一致性的問題,多個核心在訪問快取時要遵循一些協議,在讀寫操作時根據協議來操作,這些協議有MSI、MESI、MOSI等,它們定義了何時應該訪問快取中的資料、何時應該讓快取失效、何時應該訪問主記憶體中的資料等基本原則。

JMM

而隨著CPU能力的不斷提升,一層快取就無法滿足要求了,就逐漸衍生出了多級快取。

按照資料讀取順序和CPU的緊密程度,CPU的快取可以分為一級快取(L1)、二級快取(L2)、三級快取(L3),每一級快取儲存的資料都是下一級的一部分。

這三種快取的技術難度和製作成本是相對遞減的,容量也是相對遞增的。

所以,在有了多級快取後,程式的執行就變成了:

當CPU要讀取一個資料的時候,先從一級快取中查詢,如果沒找到再從二級快取中查詢,如果沒找到再從三級快取中查詢,如果沒找到再從主記憶體中查詢,然後再把找到的資料依次載入到多級快取中,下次再使用相關的資料直接從快取中查詢即可。

而載入到快取中的資料也不是說用到哪個就載入哪個,而是載入記憶體中連續的資料,一般來說是載入連續的64個位元組,因此,如果訪問一個 long 型別的陣列時,當陣列中的一個值被載入到快取中時,另外 7 個元素也會被載入到快取中,這就是“快取行”的概念。

JMM

快取行雖然能極大地提高程式執行的效率,但是在多執行緒對共享變數的訪問過程中又帶來了新的問題,也就是非常著名的“偽共享”。

關於偽共享的問題,我們這裡就不展開講了,有興趣的可以看彤哥之前釋出的【雜談 什麼是偽共享(false sharing)?】章節的相關內容。

除此之外,為了使CPU中的運算單元能夠充分地被利用,CPU可能會對輸入的程式碼進行亂序執行優化,然後在計算之後再將亂序執行的結果進行重組,保證該結果與順序執行的結果一致,但並不保證程式中各個語句計算的先後順序與程式碼的輸入順序一致,因此,如果一個計算任務依賴於另一個計算任務的結果,那麼其順序性並不能靠程式碼的先後順序來保證。

與CPU的亂序執行優化類似,java虛擬機器的即時編譯器也有類似的指令重排序優化。

為了解決上面提到的多個快取讀寫一致性以及亂序排序優化的問題,這就有了記憶體模型,它定義了共享記憶體系統中多執行緒讀寫操作行為的規範。

Java記憶體模型

Java記憶體模型(Java Memory Model,JMM)是在硬體記憶體模型基礎上更高層的抽象,它遮蔽了各種硬體和作業系統對記憶體訪問的差異性,從而實現讓Java程式在各種平臺下都能達到一致的併發效果。

Java記憶體模型定義了程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出這樣的底層細節。這裡所說的變數包括例項欄位、靜態欄位,但不包括區域性變數和方法引數,因為它們是執行緒私有的,它們不會被共享,自然不存在競爭問題。

為了獲得更好的執行效能,Java記憶體模型並沒有限制執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動,也沒有限制即時編譯器調整程式碼的執行順序等這類權利。

Java記憶體模型規定了所有的變數都儲存在主記憶體中,這裡的主記憶體跟介紹硬體時所用的名字一樣,兩者可以類比,但此處僅指虛擬機器中記憶體的一部分。

除了主記憶體,每條執行緒還有自己的工作記憶體,此處可與CPU的快取記憶體進行類比。工作記憶體中儲存著該執行緒使用到的變數的主記憶體副本的拷貝,執行緒對變數的操作都必須在工作記憶體中進行,包括讀取和賦值等,而不能直接讀寫主記憶體中的變數,不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞必須通過主記憶體來完成。

執行緒、工作記憶體、主記憶體三者的關係如下圖所示:

JMM

注意,這裡所說的主記憶體、工作記憶體跟Java虛擬機器記憶體區域劃分中的堆、棧是不同層次的記憶體劃分,如果兩者一定要勉強對應起來,主記憶體主要對應於堆中物件的例項部分,而工作記憶體主要對應與虛擬機器棧中的部分割槽域。

從更低層次來說,主記憶體主要對應於硬體記憶體部分,工作記憶體主要對應於CPU的快取記憶體和暫存器部分,但也不是絕對的,主記憶體也可能存在於快取記憶體和暫存器中,工作記憶體也可能存在於硬體記憶體中。

JMM

記憶體間的互動操作

關於主記憶體與工作記憶體之間具體的互動協議,Java記憶體模型定義了以下8種具體的操作來完成:

(1)lock,鎖定,作用於主記憶體的變數,它把主記憶體中的變數標識為一條執行緒獨佔狀態;

(2)unlock,解鎖,作用於主記憶體的變數,它把鎖定的變數釋放出來,釋放出來的變數才可以被其它執行緒鎖定;

(3)read,讀取,作用於主記憶體的變數,它把一個變數從主記憶體傳輸到工作記憶體中,以便後續的load操作使用;

(4)load,載入,作用於工作記憶體的變數,它把read操作從主記憶體得到的變數放入工作記憶體的變數副本中;

(5)use,使用,作用於工作記憶體的變數,它把工作記憶體中的一個變數傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作;

(6)assign,賦值,作用於工作記憶體的變數,它把一個從執行引擎接收到的變數賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時使用這個操作;

(7)store,儲存,作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞到主記憶體中,以便後續的write操作使用;

(8)write,寫入,作用於主記憶體的變數,它把store操作從工作記憶體得到的變數的值放入到主記憶體的變數中;

如果要把一個變數從主記憶體複製到工作記憶體,那就要按順序地執行read和load操作,同樣地,如果要把一個變數從工作記憶體同步回主記憶體,就要按順序地執行store和write操作。注意,這裡只說明瞭要按順序,並沒有說一定要連續,也就是說可以在read與load之間、store與write之間插入其它操作。比如,對主記憶體中的變數a和b的訪問,可以按照以下順序執行:

read a -> read b -> load b -> load a。

另外,Java記憶體模型還定義了執行上述8種操作的基本規則:

(1)不允許read和load、store和write操作之一單獨出現,即不允許出現從主記憶體讀取了而工作記憶體不接受,或者從工作記憶體回寫了但主記憶體不接受的情況出現;

(2)不允許一個執行緒丟棄它最近的assign操作,即變數在工作記憶體變化了必須把該變化同步回主記憶體;

(3)不允許一個執行緒無原因地(即未發生過assign操作)把一個變數從工作記憶體同步回主記憶體;

(4)一個新的變數必須在主記憶體中誕生,不允許工作記憶體中直接使用一個未被初始化(load或assign)過的變數,換句話說就是對一個變數的use和store操作之前必須執行過load和assign操作;

(5)一個變數同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一個執行緒執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才能被解鎖。

(6)如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值;

(7)如果一個變數沒有被lock操作鎖定,則不允許對其執行unlock操作,也不允許unlock一個其它執行緒鎖定的變數;

(8)對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中,即執行store和write操作;

注意,這裡的lock和unlock是實現synchronized的基礎,Java並沒有把lock和unlock操作直接開放給使用者使用,但是卻提供了兩個更高層次的指令來隱式地使用這兩個操作,即moniterenter和moniterexit。

原子性、可見性、有序性

Java記憶體模型就是為了解決多執行緒環境下共享變數的一致性問題,那麼一致性包含哪些內容呢?

一致性主要包含三大特性:原子性、可見性、有序性,下面我們就來看看Java記憶體模型是怎麼實現這三大特性的。

(1)原子性

原子性是指一段操作一旦開始就會一直執行到底,中間不會被其它執行緒打斷,這段操作可以是一個操作,也可以是多個操作。

由Java記憶體模型來直接保證的原子性操作包括read、load、user、assign、store、write這兩個操作,我們可以大致認為基本型別變數的讀寫是具備原子性的。

如果應用需要一個更大範圍的原子性,Java記憶體模型還提供了lock和unlock這兩個操作來滿足這種需求,儘管不能直接使用這兩個操作,但我們可以使用它們更具體的實現synchronized來實現。

因此,synchronized塊之間的操作也是原子性的。

(2)可見性

可見性是指當一個執行緒修改了共享變數的值,其它執行緒能立即感知到這種變化。

Java記憶體模型是通過在變更修改後同步回主記憶體,在變數讀取前從主記憶體重新整理變數值來實現的,它是依賴主記憶體的,無論是普通變數還是volatile變數都是如此。

普通變數與volatile變數的主要區別是是否會在修改之後立即同步回主記憶體,以及是否在每次讀取前立即從主記憶體重新整理。因此我們可以說volatile變數保證了多執行緒環境下變數的可見性,但普通變數不能保證這一點。

除了volatile之外,還有兩個關鍵字也可以保證可見性,它們是synchronized和final。

synchronized的可見性是由“對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中,即執行store和write操作”這條規則獲取的。

final的可見性是指被final修飾的欄位在構造器中一旦被初始化完成,那麼其它執行緒中就能看見這個final欄位了。

(3)有序性

Java程式中天然的有序性可以總結為一句話:如果在本執行緒中觀察,所有的操作都是有序的;如果在另一個執行緒中觀察,所有的操作都是無序的。

前半句是指執行緒內表現為序列的語義,後半句是指“指令重排序”現象和“工作記憶體和主記憶體同步延遲”現象。

Java中提供了volatile和synchronized兩個關鍵字來保證有序性。

volatile天然就具有有序性,因為其禁止重排序。

synchronized的有序性是由“一個變數同一時刻只允許一條執行緒對其進行lock操作”這條規則獲取的。

先行發生原則(Happens-Before)

如果Java記憶體模型的有序性都只依靠volatile和synchronized來完成,那麼有一些操作就會變得很囉嗦,但是我們在編寫Java併發程式碼時並沒有感受到,這是因為Java語言天然定義了一個“先行發生”原則,這個原則非常重要,依靠這個原則我們可以很容易地判斷在併發環境下兩個操作是否可能存在競爭衝突問題。

先行發生,是指操作A先行發生於操作B,那麼操作A產生的影響能夠被操作B感知到,這種影響包括修改了共享記憶體中變數的值、傳送了訊息、呼叫了方法等。

下面我們看看Java記憶體模型定義的先行發生原則有哪些:

(1)程式次序原則

在一個執行緒內,按照程式書寫的順序執行,書寫在前面的操作先行發生於書寫在後面的操作,準確地講是控制流順序而不是程式碼順序,因為要考慮分支、迴圈等情況。

(2)監視器鎖定原則

一個unlock操作先行發生於後面對同一個鎖的lock操作。

(3)volatile原則

對一個volatile變數的寫操作先行發生於後面對該變數的讀操作。

(4)執行緒啟動原則

對執行緒的start()操作先行發生於執行緒內的任何操作。

(5)執行緒終止原則

執行緒中的所有操作先行發生於檢測到執行緒終止,可以通過Thread.join()、Thread.isAlive()的返回值檢測執行緒是否已經終止。

(6)執行緒中斷原則

對執行緒的interrupt()的呼叫先行發生於執行緒的程式碼中檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測是否發生中斷。

(7)物件終結原則

一個物件的初始化完成(構造方法執行結束)先行發生於它的finalize()方法的開始。

(8)傳遞性原則

如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A先行發生於操作C。

這裡說的“先行發生”與“時間上的先發生”沒有必然的關係。

比如,下面的程式碼:

int a = 0;

// 操作A:執行緒1對進行賦值操作
a = 1;

// 操作B:執行緒2獲取a的值

int b = a;

如果執行緒1在時間順序上先對a進行賦值,然後執行緒2再獲取a的值,這能說明操作A先行發生於操作B嗎?

顯然不能,因為執行緒2可能讀取的還是其工作記憶體中的值,或者說執行緒1並沒有把a的值重新整理回主記憶體呢,這時候執行緒2讀取到的值可能還是0。

所以,“時間上的先發生”不一定“先行發生”。

再看一個例子:

// 同一個執行緒中
int i = 1;

int j = 2;

根據第一條程式次序原則,int i = 1;先行發生於int j = 2;,但是由於處理器優化,可能導致int j = 2;先執行,但是這並不影響先行發生原則的正確性,因為我們在這個執行緒中並不會感知到這點。

所以,“先行發生”不一定“時間上先發生”。

總結

(1)硬體記憶體架構使得我們必須建立記憶體模型來保證多執行緒環境下對共享記憶體訪問的正確性;

(2)Java記憶體模型定義了保證多執行緒環境下共享變數一致性的規則;

(3)Java記憶體模型提供了工作記憶體與主記憶體互動的8大操作:lock、unlock、read、load、use、assign、store、write;

(4)Java記憶體模型對原子性、可見性、有序性提供了一些實現;

(5)先行發生的8大原則:程式次序原則、監視器鎖定原則、volatile原則、執行緒啟動原則、執行緒終止原則、執行緒中斷原則、物件終結原則、傳遞性原則;

(6)先行發生不等於時間上的先發生;

彩蛋

Java記憶體模型是Java中很重要的概念,理解它非常有助於我們編寫多執行緒程式碼,理解多執行緒的本質,筆者這裡整理了一些不錯的資料提供給大家。

《深入理解Java虛擬機器》

《Java併發程式設計的藝術》

《深入理解java記憶體模型》

關注我的公眾號“彤哥讀原始碼”回覆“JMM”領取上面三本書籍。


歡迎關注我的公眾號“彤哥讀原始碼”,檢視更多原始碼系列文章,與彤哥一起暢遊原始碼的海洋。

qrcode

相關文章