深讀源碼-java同步系列之JMM(Java Memory Model)

簡介

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

緩存行雖然能極大地提高程序運行的效率,但是在多線程對共享變量的訪問過程中又帶來了新的問題,也就是非常著名的“僞共享”。

關於僞共享的問題,我們這裏就不展開講了,有興趣的可以看之前《什麼是僞共享》的相關內容。

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

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

爲了解決上面提到的多個緩存讀寫一致性以及亂序排序優化的問題,這就有了內存模型,它定義了共享內存系統中多線程讀寫操作行爲的規範。

Java內存模型

Java內存模型(Java Memory Model,JMM)是在硬件內存模型基礎上更高層的抽象,它屏蔽了各種硬件和操作系統對內存訪問的差異性,從而實現讓Java程序在各種平臺下都能達到一致的併發效果,它規範了Java虛擬機與計算機內存是如何協同工作的。

每一個運行在Java虛擬機裏的線程都擁有自己的線程棧。這個線程棧包含了這個線程調用的方法當前執行點相關的信息。一個線程僅能訪問自己的線程棧。一個線程創建的本地變量對其它線程不可見,僅自己可見。即使兩個線程執行同樣的代碼,這兩個線程任然在在自己的線程棧中的代碼來創建本地變量。因此,每個線程 擁有每個本地變量的獨有版本。

所有原始類型的本地變量都存放在線程棧上,因此對其它線程不可見。一個線程可能向另一個線程傳遞一個原始類型變量的拷貝,但是它不能共享這個原始類型變量自身。

堆上包含在Java程序中創建的所有對象,無論是哪一個對象創建的。這包括原始類型的對象版本。如果一個對象被創建然後賦值給一個局部變量,或者用來作爲另一個對象的成員變量,這個對象任然是存放在堆上。

下面這張圖演示了調用棧和本地變量存放在線程棧上,對象存放在堆上。

  • 一個本地變量可能是原始類型,在這種情況下,它總是“呆在”線程棧上。

  • 一個本地變量也可能是指向一個對象的一個引用。在這種情況下,引用(這個本地變量)存放在線程棧上,但是對象本身存放在堆上。

  • 一個對象可能包含方法,這些方法可能包含本地變量。這些本地變量任然存放在線程棧上,即使這些方法所屬的對象存放在堆上。

  • 一個對象的成員變量可能隨着這個對象自身存放在堆上。不管這個成員變量是原始類型還是引用類型。

  • 靜態成員變量跟隨着類定義一起也存放在堆上。

  • 存放在堆上的對象可以被所有持有對這個對象引用的線程訪問。當一個線程可以訪問一個對象時,它也可以訪問這個對象的成員變量。如果兩個線程同時調用同一個對象上的同一個方法,它們將會都訪問這個對象的成員變量,但是每一個線程都擁有這個本地變量的私有拷貝。

下圖演示了上面提到的點:

Image.png

兩個線程擁有一些列的本地變量。其中一個本地變量(Local Variable 2)執行堆上的一個共享對象(Object 3)。這兩個線程分別擁有同一個對象的不同引用。這些引用都是本地變量,因此存放在各自線程的線程棧上。這兩個不同的引用指向堆上同一個對象。

注意,這個共享對象(Object 3)持有Object2和Object4一個引用作爲其成員變量(如圖中Object3指向Object2和Object4的箭頭)。通過在Object3中這些成員變量引用,這兩個線程就可以訪問Object2和Object4。

這張圖也展示了指向堆上兩個不同對象的一個本地變量。在這種情況下,指向兩個不同對象的引用不是同一個對象。理論上,兩個線程都可以訪問Object1和Object5,如果兩個線程都擁有兩個對象的引用。但是在上圖中,每一個線程僅有一個引用指向兩個對象其中之一。

 Java內存模型定義了程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出這樣的底層細節。爲了獲得更好的執行效能,Java內存模型並沒有限制執行引擎使用處理器的特定寄存器或緩存來和主內存進行交互,也沒有限制即時編譯器調整代碼的執行順序等這類權利。

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

除了主內存,每條線程還有自己的工作內存,此處可與CPU的高速緩存進行類比。工作內存中保存着該線程使用到的變量的主內存副本的拷貝,線程對變量的操作都必須在工作內存中進行,包括讀取和賦值等,而不能直接讀寫主內存中的變量,不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞必須通過主內存來完成。

線程、工作內存、主內存三者的關係如下圖所示:

JMM

注意,這裏所說的主內存、工作內存跟Java虛擬機內存區域劃分中的堆、棧是不同層次的內存劃分,如果兩者一定要勉強對應起來,主內存主要對應於堆中對象的實例部分,而工作內存主要對應與虛擬機棧中的部分區域。

從更低層次來說,主內存主要對應於硬件內存部分,工作內存主要對應於CPU的高速緩存和寄存器部分,但也不是絕對的,主內存也可能存在於高速緩存和寄存器中,工作內存也可能存在於硬件內存中。

JMM

當對象和變量被存放在計算機中各種不同的內存區域中時,就可能會出現一些具體的問題。主要包括如下兩個方面:

-線程對共享變量修改的可見性

-當讀,寫和檢查共享變量時出現race conditions

共享對象可見性

如果兩個或者更多的線程在沒有正確的使用volatile聲明或者同步的情況下共享一個對象,一個線程更新這個共享對象可能對其它線程來說是不可見的。

想象一下,共享對象被初始化在主存中。跑在CPU上的一個線程將這個共享對象讀到CPU緩存中。然後修改了這個對象。只要CPU緩存沒有被刷新回主存,對象修改後的版本對跑在其它CPU上的線程都是不可見的。這種方式可能導致每個線程擁有這個共享對象的私有拷貝,每個拷貝停留在不同的CPU緩存中。

下圖示意了這種情形。跑在左邊CPU的線程拷貝這個共享對象到它的CPU緩存中,然後將count變量的值修改爲2。這個修改對跑在右邊CPU上的其它線程是不可見的,因爲修改後的count的值還沒有被刷新回主存中去。

Image.png

解決這個問題你可以使用Java中的volatile關鍵字。volatile關鍵字可以保證直接從主存中讀取一個變量,如果這個變量被修改後,總是會被寫回到主存中去。

Race Conditions

如果兩個或者更多的線程共享一個對象,多個線程在這個共享對象上更新變量,就有可能發生race conditions

想象一下,如果線程A讀一個共享對象的變量count到它的CPU緩存中。再想象一下,線程B也做了同樣的事情,但是往一個不同的CPU緩存中。現在線程A將count加1,線程B也做了同樣的事情。現在count已經被增在了兩個,每個CPU緩存中一次。

如果這些增加操作被順序的執行,變量count應該被增加兩次,然後原值+2被寫回到主存中去。

然而,兩次增加都是在沒有適當的同步下併發執行的。無論是線程A還是線程B將count修改後的版本寫回到主存中取,修改後的值僅會被原值大1,儘管增加了兩次。

下圖演示了上面描述的情況:

Image.png

解決這個問題可以使用Java同步塊。一個同步塊可以保證在同一時刻僅有一個線程可以進入代碼的臨界區。同步塊還可以保證代碼塊中所有被訪問的變量將會從主存中讀入,當線程退出同步代碼塊時,所有被更新的變量都會被刷新回主存中去,不管這個變量是否被聲明爲volatile。

內存間的交互操作

關於主內存與工作內存之間具體的交互協議,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、use、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內存模型》


參考鏈接:https://www.cnblogs.com/tong-yuan/p/10884421.html

參考鏈接:http://www.tianshouzhi.com/api/tutorials/mutithread/66

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章