Java內存模型的相關探究

介紹

  • JMM概念,目標,Java內存模型圖等
  • CPU和編譯器的亂序(重排序)
  • 內存屏障,類型,規則
  • 緩存一致協議,緩存行概念
  • JMM定義的8種基本操作和8種規則
  • happends-before法則
  • volatile關鍵字底層實現,所提供的功能,使用條件
  • synchronized關鍵字

這裏分享整理筆記,之前也是很困惑這塊,花了時間去查資料理解。下面是我根據網上的資料和自己的一點理解,對Java內存模型方面的知識進行一個大致整合,方便後續回憶,如有錯誤,感謝指出;(當然了,還沒有整理完全,像final的作用,synchronized的原理等等。。。)

下面每個章節前的引用都有鏈接,若想探究可以查看;

wiki

JMM(Java Memory Model,Java內存模型)描述了,Java編程語言中的線程如何通過內存進行交互(interact)。和描述單線程執行代碼一起,JMM提供了Java編程語言的一些語義(semantics)。

這裏解釋一下語義,語義和語法的區分。語法是定義句子的文法結構,也就是結構正確;語義則規定表達的意義。一般來說,語法會在編譯時就會報錯,因爲編譯器會檢查你寫的是否符合規則;而語義則是屬於邏輯錯誤,就是沒有達到預期的效果。例如,不能對負數開平方,結果你給sqrt函數傳了一個負數。

背景

Java編程語言提供了線程功能。對於開發人員而言,線程之間的同步非常困難,而又因爲Java應用程序可以在各種處理器和操作系統上運行,再次加重了其複雜程度。爲了能夠得出程序行爲是怎麼樣的,Java的設計者必須清楚地定義所有Java程序的可能行爲,換句話說,就是所有事情都得自己來做。

在多處理器體系結構中,各個處理器擁有自己的緩存,而緩存和內存的不同步就有可能會導致看到相同共享數據的不同值。但是一般來說,不希望線程之間完全保持同步,因爲從性能的角度來看,代價太高。

JMM定義

詳情可以看博文:https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html

Java內存模型的主要目標是:定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣底層細節。簡單來說:對於賦值a=3,在什麼條件下,讀取變量的線程可以看到這個值(引用Java併發編程實戰的一句)。此處的變量與Java編程時所說的變量不一樣,指包括了實例字段、靜態字段和構成數組對象的元素,但是不包括局部變量與方法參數,後者是線程私有的,不會被共享。

Java內存模型中規定了所有的共享變量都存儲在主內存中,每條線程還有自己的工作內存(可以與處理器的高速緩存類比),線程的工作內存中保存了該線程使用到的變量到主內存副本拷貝,線程對變量的所有操作(讀取、賦值)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同線程之間無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要在主內存來完成,線程、主內存和工作內存的交互關係如下圖所示。

直接從引用博客中copy圖片:
在這裏插入圖片描述CacheCoherence,緩存一致協議

JMM屏蔽各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果,這是設計的目的。

CPU和編譯器的亂序

詳情可以看博文:http://blog.sina.com.cn/s/blog_4adc4b090102vt2n.html

一般來說目前的cpu是採用流水線來執行指令,而一個指令的執行也會被分爲多個不同的段(階段):取值(IF),譯碼(ID),訪存(MEM),執行(EX),寫回(WB)5個段。而當第一條指令完成IF後,第二條指令就可以開始IF了,重複利用使得多條指令同時執行,大大提高效率。和流水線相對的就是串行了,就是要等前一個指令的5個段都完成纔開始下一個指令。

而指令重排序(亂序)的目的,是爲了優化流水線,提高效率。而流水線阻塞的情況有三種:

  1. 結構相關:資源衝突,如都要使用某個部件。
  2. 數據相關:後一個指令需要前一個指令的執行結果。
  3. 控制相關:涉及到跳轉,分支指令。

其中指令重排序是數據相關解決方法之一:

int a = 1;
a++;
a = (a*10+2)/4;
int b = 2;

這樣子,b的賦值和前面是沒有數據相關的,所以這個操作可能在a++之前就完成了。

像這樣有依賴關係的指令如果捱得很近,後一條指令必定會因爲等待前一條執行的結果,而在流水線中阻塞很久,佔用流水線的資源。而CPU的亂序,作爲優化的一種手段,則試圖通過指令重排將這樣的兩條指令拉開距離, 以至於後一條指令進入CPU的時候,前一條指令結果已經得到了,那麼也就不再需要阻塞等待了。這裏的意思是將不相關的指令插入相關指令的中間,以達到減少阻塞時間的目的。

int a = 1;
a++;
int b = 2; //放在中間
a = (a*10+2)/4;

CPU的亂序並不是在指令執行之前去調整,取指令的時候是順序取的,但是最後執行的時候是亂序,順序流入,亂序流出

相比於CPU的亂序,編譯器的亂序纔是真正對指令順序做了調整,之所以出現編譯器亂序優化根本原因在於處理器只能分析一小塊指令,但編譯器卻可以在很大範圍內進行代碼分析,做出更優策略,充分利用處理器的亂序執行功能。

亂序執行,雖然可以保證顯示因果關係不變,但是如果是隱式因果它們就不一定會知道了(在多線程情況下)。

兩個線程
A:
	a = 1;
	isReady = true;
	=>>不存在因果
B:
	if(isReady)
		doSomeThing(a);

亂序:
	1.
		isReady = true;
	2.
		if(isReady)
			doSomeThing(a);
	3.
		a = 1;

這樣的出來的結果就不會符合預期了。

再來看一個例子,單例模式的DCL機制:

public class Singleton {
  private static Singleton instance = null;

  private Singleton() { }

  public static Singleton getInstance() {
     if(instance == null) { //這裏可能有問題
        synchronzied(Singleton.class) {
           if(instance == null) {
               instance = new Singleton();  //
           }
        }
     }
     return instance;
   }
}

上面這段代碼,初看沒問題,但是在併發模型下,可能會出錯,那是因爲instance= new Singleton()並非一個原子操作,它實際上下面這三個操作:

memory = allocate();    //1:分配對象的內存空間
ctorInstance(memory);   //2:初始化對象
instance = memory;      //3:設置instance指向剛分配的內存地址

上面操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM是可以針對它們進行指令的優化重排序的,經過重排序後如下:

memory = allocate();    //1:分配對象的內存空間
instance = memory;      //3:instance指向剛分配的內存地址,此時對象還未初始化
ctorInstance(memory);   //2:初始化對象

在多線程場景下,可能A線程執行到了3,B線程發現指針install已經不爲空就直接繼續執行,這樣在沒有初始化的情況下執行程序很顯然會出錯。

對DCL的分析也告訴我們一條經驗原則:對引用(包括對象引用和數組引用)的非同步訪問,即使得到該引用的最新值,卻並不能保證也能得到其成員變量(對數組而言就是每個數組元素)的最新值。

內存屏障

內存屏障主要解決了兩個問題:單處理器下的亂序問題和多處理器下的內存同步問題。

內存屏障(Memory Barrier):

內存屏障(Memory Barrier),分爲兩類:

  1. 編譯器Memory Barrier
  2. CPU Memory Barrier

在硬件層又分爲讀寫屏障。

很多時候,編譯器和 CPU 引起內存亂序訪問不會帶來什麼問題,但一些特殊情況下,程序邏輯的正確性依賴於內存訪問順序,這時候內存亂序訪問會帶來邏輯上的錯誤,如同上面例子所示。

內存屏障主要提供三個功能:

  1. 確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成
  2. 強制將對緩存的修改操作立即寫入主存,利用緩存一致性機制,並且緩存一致性機制會阻止同時修改由兩個以上CPU緩存的內存區域數據
  3. 如果是寫操作,它會導致其他CPU中對應的緩存行無效

而需要注意的是,內存屏障保證的是:一個CPU的多個操作的順序(被另一個CPU所觀察到的順序),而不保證"兩個CPU的操作順序"(多線程環境下)。

內存屏障的四種類型

詳情可以看博文:https://blog.csdn.net/butterBallj/article/details/82425939

在JSR規範中定義了4種內存屏障

LoadLoad屏障,例如

Load1;LoadLoad;Load2

Load1和Load2代表兩條讀取指令。在Load2要讀取的數據被訪問前,保證Load1讀取的數據被讀取完畢。

StoreStore屏障,例如

Store1; StoreStore; Store2

Store1 和 Store2代表兩條寫入指令。在Store2寫入執行前,保證Store1的寫入操作對其它處理器可見

LoadStore屏障,例如

Load1; LoadStore; Store2

在Store2被寫入前,保證Load1要讀取的數據被讀取完畢。

StoreLoad屏障,例如

Store1; StoreLoad; Load2

在Load2讀取操作執行前,保證Store1的寫入對所有處理器可見。StoreLoad屏障的開銷是四種屏障中最大的。

Java volatile內存屏障規則:

詳情可以看博文:https://blog.csdn.net/onroad0612/article/details/81382032

  1. 在每一個volatile寫操作前面插入一個StoreStore屏障。這確保了在進行volatile寫之前,前面的所有普通的寫操作都已經刷新到了內存。
  2. 在每一個volatile寫操作後面插入一個StoreLoad屏障。這樣可以避免volatile寫操作與後面可能存在的volatile讀寫操作發生重排序。
  3. 在每一個volatile讀操作後面插入一個LoadLoad屏障。這樣可以避免volatile讀操作和後面普通的讀操作進行重排序。
  4. 在每一個volatile讀操作後面插入一個LoadStore屏障。這樣可以避免volatile讀操作和後面普通的寫操作進行重排序。

緩存一致性協議和MESI

緩存一致性協議有多種,但是通常使用的是:嗅探(snooping)協議。

該協議的基本思想:

  1. 所有內存的傳輸都發生在一條共享的總線上,而所有的處理器都可以看見這些總線
  2. 緩存本身是獨立的,而內存是共享的,所有的內存訪問都要經過仲裁(在一個指令週期中,只有一個CPU緩存可以讀寫內存)
  3. CPU緩存不僅僅只是在做內存傳輸的時候才和總線打交道,而是在不停地在嗅探總線上所發生的數據交換,跟蹤其他緩存在做什麼。正因爲如此,當一個緩存代表其所屬CPU去讀寫內存時,其他處理器都會得到通知,一次來實現緩存之間的同步。
  4. 只要當某一個處理器一寫內存,其他處理器馬上知道這塊內存在他們的緩存段中已經失效。

MESI

MESI協議是目前主流的緩存一致性協議,在該協議中,每個緩存行有四個狀態,可用2bit表示,分別爲:

  1. M(Modified,修改):這行數據有效,數據被修改了,和內存中的不一致,數據只存在於本Cache,一段時間寫回內存中;
  2. E(Exclusive,獨佔):這行數據有效,和內存中的一致,數據只存在於本Cache;
  3. S(share,共享):這行數據有效,和內存中的一致,數據存在於很多Cache中;
  4. I(Invalid,失效):這行數據無效。

只有當狀態爲M/E的時候,CPU才能去寫這個緩存行。換句話說,就是只有在這兩個狀態下,CPU是獨佔這個緩存行的。

CPU會先向總線發送請求,要求獨佔,獲取獨佔權後,就會通知其他處理器,將他們擁有相同緩存行的狀態置爲失效。也就是說,只有獲取了獨佔權,CPU才能開始修改數據,並且該緩存行只有一份拷貝(在我這裏),所以不會和其他的有衝突。

若是其他處理器想要讀取這個緩存行(通過嗅探總線得知消息),如果是獨佔狀態,那麼緩存行必須要先回到共享;如果是修改狀態,那麼緩存行必須要先寫回內存,然後再轉爲共享。

詳細描述如下:

  1. 一個處於M狀態的緩存行,必須時刻監聽所有試圖讀取該緩存行對應的主存地址的操作,如果監聽到,則必須在此操作執行前把其緩存行中的數據寫回內存。
  2. 一個處於S狀態的緩存行,必須時刻監聽使該緩存行無效或者獨佔該緩存行的請求,如果監聽到,則必須把其緩存行狀態設置爲I。
  3. 一個處於E狀態的緩存行,必須時刻監聽其他試圖讀取該緩存行對應的主存地址的操作,如果監聽到,則必須把其緩存行狀態設置爲S。
  4. 當CPU需要讀取數據時,如果其緩存行的狀態是I的,則需要從內存中讀取,並把自己狀態變成S,如果不是I,則可以直接讀取緩存中的值,但在此之前,必須要等待其他CPU的監聽結果,如其他CPU也有該數據的緩存且狀態是M,則需要等待其把緩存更新到內存之後,再讀取。
  5. 當CPU需要寫數據時,只有在其緩存行是M或者E的時候才能執行,否則需要發出特殊的RFO指令(Read Or Ownership,這是一種總線事務),通知其他CPU置緩存無效(I),這種情況下會性能開銷是相對較大的。在寫入完成後,修改其緩存狀態爲M,表示緩存行已經修改。

也就是說處於M狀態,並不會馬上回寫,而是等待有新的讀取操作纔會回寫;

S狀態下,只有當其他處理器進行回寫的時候纔會設置爲失效,重新從內存中讀取;

緩存行

  • 緩存行是分段的,一個段對應一塊存儲空間,我們稱之爲緩存行,它是CPU緩存中可分配的最小存儲單元(Cache由很多緩存行構成),大小32B,64B,128B不等(和CPU架構有關),通常是64B。

當CPU看見一條讀取內存的指令時,它會把內存地址傳給一級數據緩存,若沒有,就會去內存或更高一級的緩存中加載整個緩存段。

下面解決方法中,硬件使用LOCK#來鎖總線,效率太低,所以最好能做到,使用多組緩存,但是它們的行爲看起來就像是一組一樣,所以緩存一致性協議就是爲了保持多組緩存一致而設計的

緩存一致性問題

緩存一致性問題的原因就是因爲多核處理器的緩存和主存不同的問題。

解決方法,硬件上:

  1. 通過發出Lock#信號在總線加鎖的方式,這種方式會導致其他CPU無法訪問內存,效率低。所以後來的處理器度採用鎖緩存來帶鎖總線。
  2. 緩存一致性協議:最出名的是Intel的MESI協議,該協議保證了每個緩存中使用的共享變量的副本是一致的。
    1. 思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從內存重新讀取。

JMM定義的內存交互操作

8種基本操作

詳情可以看博文:https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html

Java內存模型定義了以下八種操作來完成,將一個變量從主內存拷貝到工作內存以及從工作內存同步到主內存的實現細節:

  1. lock(鎖定):作用於主內存的變量,把一個變量標識爲一條線程獨佔狀態。
  2. unlock(解鎖):作用於主內存變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。
  3. read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用
  4. load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  5. use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  6. assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  7. store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。
  8. write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中。

從這8個操作可以看出,若要從主存取數據到工作內存,需要按順序執行read,load操作(並不需要連續執行)。反之,則需要按順序執行store和write操作。

執行操作所要滿足的8個規則

  1. 不允許read和load、store和write操作之一單獨出現
  2. 不允許一個線程丟棄它的最近assign的操作,即變量在工作內存中改變了之後必須同步到主內存中
  3. 不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步回主內存中
  4. 一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量。也就是對一個變量進行use和store操作之前,必須先執行過了assign和load操作
  5. 一個變量在同一時刻只允許一條線程對其進行lock操作,lock和unlock必須成對出現
  6. 如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量前需要重新執行load或assign操作初始化變量的值
  7. 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量
  8. 對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)

我看見有人畫了一個圖,有助於理解:https://www.processon.com/view/5d2821ffe4b0aad4c92f23c0

happends-before法則

JMM可以通過happens-before關係向程序員提供跨線程的內存可見性保證(如果A線程的寫操作a與B線程的讀操作b之間存在happens-before關係,儘管a操作和b操作在不同的線程中執行,但JMM向程序員保證a操作將對b操作可見)。

  • 程序順序規則:一個線程中的每個操作happens-before於該線程中的任意後續操作
  • 監視器鎖(同步)規則:對於一個監視器的解鎖,happens-before於隨後對這個監視器的加鎖
  • volatile變量規則:對一個volatile變量的寫操作happen—before後面對該變量的讀操作
  • 線程啓動規則:Thread對象的start()方法happen—before與此線程中的每一個動作
  • 線程終止規則:線程的所有操作都happen—before對此線程的終止檢測,可以通過Thread.join()、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
  • 線程中斷規則:對線程interrupt()方法的調用happen—before發生於被中斷線程的代碼檢測到中斷時事件的發生
  • 對象終結規則:一個對象的初始化完成(構造函數執行結束)happen—before它的finalize()方法的開始
  • 傳遞性:如果操作A happen—before操作B,操作B happen—before操作C,那麼可以得出A happen—before操作C。

而在多線程的環境下,可以通過synchronized,volatile,final,java.util.concurrent包實現這些原則。

A happends-before B的含義

public double rectangleArea(double length , double width){
	double leng;
	double wid;
	leng=length;//A
	wid=width;//B
	double area=leng*wid;//C
	return area;
}

在這樣一個程序中,我們可以說A happends-before B,B happends-before C,所以A happends-before C,但是不能說三者執行的順序是A->B->C,因爲重排序。

因爲A happends-before B,所以A操作產生的結果leng要對B可見,但是我們可以看見B中並沒有使用該變量,所以A和B可以重排序。

那麼A和C可以重排序嗎?不能,因爲C中使用了A中所操作的變量,若重排序,那leng=0->area=0,最後導致達不到預期效果,所以重排序也並不是亂排,而是在保證前後因果的情況進行排序以達到優化的目的。

所以,我們可以說:一個操作時間上先發生於另一個操作,並不代表一個操作happen—before另一個操作,反之亦然

volatile

LOCK指令前綴

通過觀察彙編代碼,會發現volatile關鍵字修飾的變量會多了#lock前綴(我自己當然沒有觀察了)

該指令做了兩件事情:

  • Lock前綴指令會引起處理器緩存會寫到內存

lock前綴指令相當於一個內存屏障(或稱爲內存柵欄),它主要實現三個功能,參照上面內容。

  • 一個處理器的緩存回寫到內存會導致其他處理器的緩存失效

volatile寫-讀的內存語義

  • 當寫入一個volatile變量時,會鎖緩存行,同時讓其他cache的緩存行失效(M狀態)。修改完成之後,若有其他CPU需要用到該變量,在這之前需要把緩存行寫入內存中,然後狀態置爲(S)
  • 當讀一個volatile變量時,如果本地cache的地址已經無效(I),則需要從主內存中讀取所有的共享變量

使用volatile的場景

必須具備以下兩個條件(其實就是先保證原子性):

  1. 對變量的寫不依賴當前值(比如++操作):因爲volatile++並不是原子操作,需要經過一組操作序列完成,讀取到工作內存,修改副本,寫回內存。而volatile不能提供原子特性。
  2. 該變量沒有包含在具有其他變量的不等式中

對於不等式:https://blog.csdn.net/francisshi/article/details/40379055

public class NumberRange { 
	private int lower, upper; 

	public int getLower() { return lower; } 
	public int getUpper() { return upper; } 

	public void setLower(int value) { 
		if (value > upper) 
			throw new IllegalArgumentException(...); 
		lower = value; 
	} 

	public void setUpper(int value) { 
		if (value < lower) 
			throw new IllegalArgumentException(...); 
		upper = value; 
	} 
}

這種限制了範圍的狀態變量,即使將 lower 和 upper 字段定義爲 volatile 類型同樣不能夠充分實現類的線程安全;

所以仍然需要使用同步。否則,如果湊巧兩個線程在同一時間使用不一致的值執行 setLower 和 setUpper 的話,則會使範圍處於不一致的狀態。

例如,如果初始狀態是(0, 5),同一時間內,線程 A 調用 setLower(4) 並且線程 B 調用 setUpper(3),顯然這兩個操作交叉存入的值是不符合條件的,那麼兩個線程都會通過用於保護不變式的檢查,使得最後的範圍值是 (4, 3) —— 一個無效值。

所以我們需要使 setLower()和 setUpper() 操作原子化 —— 而將字段定義爲 volatile 類型是無法實現這一目的的。

發佈了33 篇原創文章 · 獲贊 7 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章