Volatile全面解析,實現原理及作用分析

在java中,volatile有如下作用,一個是禁止指令重排序(編譯時指令重排序 和 CPU亂序執行)。另一個是保證多線程共享變量的內存可見性。爲了講解volatile時如何禁止指令重排序,防止CPU亂序執行,以及保證變量內存可見性。首先我們需要了解這些概念,即什麼是編譯時指令重排序?爲什麼要指令重排序?爲什麼CPU會亂序執行?什麼是內存可見性?之後,最後來看volatile到底是如何實現的,以及它是如何實現這些功能的。

1 編譯時指令重排序

在編譯階段,編譯器能夠對很 大一個範圍的代碼進行分析,能夠從更大的範圍內分辨出可以併發的指令,並將其儘量靠近排列讓處理器更容易預取和併發執行,充分利用處理器的亂序併發功能。 所以現代的高性能編譯器在目標碼優化上都具備對指令進行亂序優化的能力。並且可以對訪存的指令進行進一步的亂序,減少邏輯上不必要的訪存,以及儘量提高 Cache命中率和CPU的LSU(load/store unit)的工作效率。所以在打開編譯器優化以後,看到生成的彙編碼並不嚴格按照代碼的邏輯順序是正常的。和處理器一樣,如果想要告訴編譯器不要去對某些 指令亂序優化,也要通過一些方式來告訴編譯器。通常可以通過volatile關鍵字來告訴編譯器對哪些變量的訪問操作不能進行重排序優化。

2 CPU亂序執行

即使在編譯階段,編譯生成的指令是順序的。在CPU執行指令的時候,也會存在亂序執行的問題,其中造成亂序執行的一個原因,得從CPU的指令流水技術說起。關於指令流水,詳細的可以去看 CPU的結構和功能——指令流水及中斷系統,下面簡單介紹一下指令流水:
一條指令在執行的時候,會分爲很多個週期,如:取指週期,間址週期,執行週期,中斷週期等等。首先我們假設指令的執行分爲取指週期和執行週期兩個階段,如果指令串行執行:
在這裏插入圖片描述
上圖的執行過程是完全的串行操作,一條指令解釋過程執行結果以後,再開始下一條指令的開始過程,那麼實際上,如果我們再控制器實現過程中把取指部件和執行部件完全的獨立開進行設計的話,那麼在取指階段只會用到取指令部件,那麼執行指令階段我們只會用到執行指令部件,這樣的話,當上圖中第二條指令的取指令部件運行的時候,其執行指令部件是空閒的。如果我們採用這種結果這種方式去解釋一條指令的話,總有一個部件是空閒的,控制器的利用率非常低。

爲了加快指令執行速度,充分利用CPU,就就會採用流水線的方式去執行指令:
在這裏插入圖片描述
上圖爲指令二級流水, 取指和執行在時間上是重疊的,使得取指部件和執行部件都都得到了充分的利用。整個指令週期就會減半,理想情況下速度會提升一倍,但只是理想情況,下面看看影響指令流水效率加倍的因素。這只是一個指令二級流水,如果是指令六級流水,效率提升的就更大:
在這裏插入圖片描述
上圖是一個六級流水線,橫軸表示時間,縱軸表示指令,這個流水線被分成了六級,這六級的功能分別是:取指令,指令譯碼,形成操作數地址,取操作數,執行,結果的寫回,所謂結果的寫回是指把運算結果寫回到指令的寄存器中,或者是寫回到給定的內存單元中。

上圖中一共給出了9條指令,假設六級流水線每段時間都是相同的,這樣的話,我們採用串行方式完成一條指令,一條指令就需要6個時間單位,9條指令就需要54個時間單位。如果採用流水線方式只是用了14個時間單位,當然是假設9條指令不衝突並且沒有條件轉移指令的情況下。

在流水線的基礎上,對指令的解釋速度進一步提高的方法。前面講解的是用一條流水線如何提高指令的解釋速度,利用這個思路,盡心進一步的擴展,如果我們使用多條流水線,有幾條指令同時進入到不同的流水線中進行解釋,這樣的話速度會被進一步的提高。這種方法就是超標量技術。
超標量技術就是每個時鐘週期內,多條獨立指令進入到不同的流水線中執行。需要配置多條流水線,多個功能部件。
在這裏插入圖片描述
如上圖中,有三條流水線,在每個時鐘週期,可以有三條獨立的指令分別進入每一個流水線執行,這樣,指令的解釋速度和用一條流水線相比,最高加速比可以達到三倍。利用這種方法,通常情況下可以在執行當中不去調整指令的執行順序,指令的執行順序在編譯過程中採用優化技術,把多條可以並行執行的,獨立的指令挑選出來,搭配起來,讓他們同時進入到三條流水線執行,這種方法就是超標量的方法。

有了超標量技術,在一個指令週期內能併發執行多條指令,處理器從Cache預取了一批指令後,就會分析找出那些互相沒有關聯可以併發執行的指令,然後送到幾個獨立的執行單元進行併發執行。就有可能將多條無關聯指令分別送到兩個算術單元去同時執行。

通常來說訪存指令(由LSU單元執行)所需要的指令週期可能很多(可能要幾十甚至上百個週期),而一般的算術指令通常在一個指令週期就搞定。所以有的可能代碼中的訪存指令耗費了多個週期完成執行後,其他幾個執行單元可能已經把後面有多條邏輯上無關的算術指令都執行完了,這就產生了亂序。

另外訪存指令之間也存在亂序的問題。高級的CPU可以根據自己Cache的組織特性,將訪存指令重新排序執行。訪問一些連續地址的可能會先執行,因爲這時候Cache命中率高。有的還允許訪存的Non-blocking,即如果前面一條訪存指令因爲Cache不命中,造成長延時的存儲訪問時,後面的 訪存指令可以先執行以便從Cache取數。這也就造成了指令亂序執行的問題。

當然,不是所有的指令,CPU都能對其進行亂序優化,對於前後存在依賴關係的指令,CPU是不能改變其執行次序的。

3 內存可見性

內存可見性,簡單說就是,當有多個CPU(多線程)共享內存的時候,當一個線程對內存中的變了做了修改,而另一個線程中不能夠立即知道這種修改。爲什麼會出現這種情況,就需要從硬件層面開始說起。

3.1 CPU高速緩存

我們都知道,CPU在執行指令的時候,需要訪存操作,需要與內存交互,如讀取運算數據、存儲運算結果等,這個I/O操作是很難消除的(無法僅靠寄存器來完成所有運算任務)。由於計算機的存儲設備與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的高速緩存(Cache)來作爲內存與處理器之間的緩衝:將運算需要使用到的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步回內存之中,這樣處理器就無須等待緩慢的內存讀寫了。
在這裏插入圖片描述

3.2 緩存一致性問題

基於高速緩存的存儲交互很好地解決了處理器與內存的速度矛盾,但是也爲計算機系統帶來更高的複雜度,因爲它引入了一個新的問題:緩存一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的高速緩存,而它們又共享同一主內存(Main Memory),如上圖所示。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能導致各自的緩存數據不一致,如果真的發生這種情況,那同步回到主內存時以誰的緩存數據爲準呢?這就是緩存不一致的問題。

3.3 總線鎖

爲了解決多CPU共享內存時,保證多個CPU的緩存數據的一致性,最初,操作系統採用總線鎖的機制。CPU和內存之間的通過總線連接通信,所謂總線鎖,就是當一個CPU通過總線訪問內存的時候,其在總線上發出一個LOCK#信號,標誌其獨佔總線,即獨佔內存,其他處理器就不能通過總線讀寫內存,也就是阻塞了其他CPU的訪存操作,使該處理器可以獨享此共享內存。

3.4 緩存一致性協議

總線鎖確實能解決緩存不一致的問題,但是缺點也很明顯,總線鎖定把CPU和內存的通信給鎖住了,使得在鎖定期間,其他處理器不能操作其他內存地址的數據,嚴重的降低了CPU和內存的利用率,所以後來的CPU都提供了緩存一致性機制,如MESI協議。

MESI代表了緩存行的四種狀態,CPU中每個緩存行(caceh line)使用額外的兩位(bit)表示當前緩存行處於哪種狀態。

M: 被修改(Modified)
該緩存行只被緩存在該CPU的緩存中,並且是被修改過的(dirty),即與主存中的數據不一致,該緩存行中的內容需要在未來的某個時間點(允許其它CPU讀取主存中相應內存之前)寫回(write back)主存。當被寫回主存之後,該緩存行的狀態會變成獨享(exclusive)狀態。

E: 獨享的(Exclusive)
該緩存行只被緩存在該CPU的緩存中,它是未被修改過的(clean),與主存中數據一致。該狀態可以在任何時刻當有其它CPU讀取該內存時變成共享狀態(shared)。同樣地,當CPU修改該緩存行中內容時,該狀態可以變成Modified狀態。

S: 共享的(Shared)
該狀態意味着該緩存行可能被多個CPU緩存,並且各個緩存中的數據與主存數據一致(clean),當有一個CPU修改該緩存行中,其它CPU中該緩存行可以被作廢(變成無效狀態(Invalid))。

I: 無效的(Invalid)
該緩存是無效的(可能有其它CPU修改了該緩存行)。

至於MESI狀態轉換:
在MESI協議中,每個Cache的Cache控制器不僅知道自己的讀寫操作,而且也監聽(snoop)其它Cache的讀寫操作。每個Cache line所處的狀態根據本核和其它核的讀寫操作在4個狀態間進行遷移。
在這裏插入圖片描述
Local Read:表示本內核讀本Cache中的值
Local Write:表示本內核寫本Cache中的值
Remote Read:表示其它內核讀其Cache中的值
Remote Write:表示其它內核寫其Cache中的值
箭頭表示本Cache line狀態的遷移,環形箭頭表示狀態不變。

當內核需要訪問的數據不在本Cache中,而其它Cache有這份數據的備份時,本Cache既可以從內存中導入數據,也可以從其它Cache中導入數據,不同的處理器會有不同的選擇。MESI協議爲了使自己更加通用,沒有定義這些細節,只定義了狀態之間的遷移,下面的描述假設本Cache從內存中導入數據。

當前狀態 事件 行爲 下一個狀態
I(Invalid) Local Read 如果其它Cache沒有這份數據,本Cache從內存中取數據,Cache line狀態變成E;
如果其它Cache有這份數據,且狀態爲M,則將數據更新到內存,本Cache再從內存中取數據,2個Cache 的Cache line狀態都變成S;
如果其它Cache有這份數據,且狀態爲S或者E,本Cache從內存中取數據,這些Cache 的Cache line狀態都變成S
E/S
Local Write 從內存中取數據,在Cache中修改,狀態變成M;
如果其它Cache有這份數據,且狀態爲M,則要先將數據更新到內存;
如果其它Cache有這份數據,則其它Cache的Cache line狀態變成I
M
Remote Read 既然是Invalid,別的核的操作與它無關 I
Remote Write 既然是Invalid,別的核的操作與它無關 I
E(Exclusive) Local Read 從Cache中取數據,狀態不變 E
Local Write 修改Cache中的數據,狀態變成M M
Remote Read 數據和其它核共用,狀態變成了S S
Remote Write 數據被修改,本Cache line不能再使用,狀態變成I I
S(Shared) Local Read 從Cache中取數據,狀態不變 S
Local Write 修改Cache中的數據,狀態變成M;
其它核共享的Cache line狀態變成I
M
Remote Read 狀態不變 S
Remote Write 數據被修改,本Cache line不能再使用,狀態變成I I
M(Modified) Local Read 從Cache中取數據,狀態不變 M
Local Write 修改Cache中的數據,狀態不變 M
Remote Read 這行數據被寫到內存中,使其它核能使用到最新的數據,狀態變成S S
Remote Write 這行數據被寫到內存中,使其它核能使用到最新的數據,由於其它核會修改這行數據,狀態變成I I

在這裏插入圖片描述

3.5 store buffer

說了緩存一致性協議,好像就能夠解決問題了,但是,在這裏你會發現,又有新的問題出現了。MESI協議中:當cpu0寫數據到本地cache的時候,如果不是M或者E狀態,需要發送一個invalidate消息給cpu1,只有收到cpu1的ack之後cpu0才能繼續執行,在這個過程中cpu0需要等待,這大大影響了性能。於是CPU設計者引入了store buffer,這個buffer處於CPU與cache之間。
在這裏插入圖片描述
在cpu和cache之間引入store buffer之後,當我們要進行寫cache操作的時候,不直接將數據寫cache,先將數據寫入store buffer,store buffer 在某個時刻(storebuffer存儲滿了,或者通過內存屏障強制刷新時等等)就會完成一系列的同步行爲(發出invalid—並等待ack—接受ack—寫cache)。因爲此時數據還沒有寫入緩存,所以不需要等待ack返回,此時的緩存其實還是一致的。當收到ack之後可以把store buffer中的數據寫入cache,因爲其他CPU中的緩存數據已經失效,此時寫cache,依舊保持是一致的。這樣CPU就不會因爲等待響應而影響性能。

加入storebuffer後,確實解決了CPU等待響應影響性能的問題,但是依舊存在問題:

問題一:storebuffer和cache數據不一致:

int a = b = 0;
public void fun(){
	a = a + 1;
	b = a + 1;
}

上述代碼中,cpu執行a = a + 1;後,此時a = 1這個值被放到storebuffer裏了,然後繼續執行b=a+1,這時候cpu的cacheline中保存的a還是原來的0.這個時候就會導致計算出的b的值是不對的。因爲我們在storebuffer裏和cache中的a是不一致的,所以導致這種錯誤的結果。因此CPU設計者通過使用"store Forwarding"的方式解決這個問題,就是在執行取數的時候,先去storebuffer中查找對應的數據,如果查到就使用storebuffer中的最新值。
問題二:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	System.out.print(a);
}

假設有如上程序,現在CPU0執行fun0,CPU1執行fun1。並且此時,在CPU0中,由於之前程序執行的原因,現在a的緩存狀態是S, b的緩存狀態是E。兩個CPU按照如下順序執行(理解下面的執行,必須要先理解前面講到的MESI協議):
CPU0:執行a = a + 1,在計算出a+1的結果後,需要將a+1的值寫回Cache,但是因爲現在有了StoreBuffer,a的值不會立即寫入緩存,會先寫StoreBuffer。接下來CPU0繼續執行b = b + 1,一樣會先將結果寫StoreBuffer。
CPU0:接下來,CPU0需要將StoreBuffer的值同步回Cache中,由於a的狀態是S,需要發送失效消息並且等待ack後才能同步回Cache。但是因爲b的狀態是E,不需要發送失效消息等待ack, 直接就同步回Cache了,並且將b的緩存行狀態修改爲M。所以此時在CPU0中,對於aCache中a的值是0,starebuffer中a的值是1;對於b,storebuffer中的值已經同步回Cache,cache中的b就是最新值1。
CPU1:CPU1開始執行,先判斷b == 0,因爲CPU1中沒有b的值,CPU1會先看其他的CPU的Cache中是否有b的值,發現CPU0的Cache中有b的值,並且b的緩存行狀態爲M,所以首先CPU0要先Cache中將b的值寫回主存。然後CPU1從主存中獲取b的值,並且CPU0和CPU1緩存中b的緩存行狀態都變成S狀態。並且CPU0和CPU1的Cache中b的值都是1。
CPU1:CPU1執行打印a的值,因爲CPU1的Cache中沒有a,所以和b一樣先判斷其他的CPU的Cache中有沒有a,發現CPU0的緩存中有a。因爲CPU0的a的最新修改的值還在Storebuffer中沒有同步回Cache,所以在CPU0的cache中a的緩存行狀態還是S。所以CPU1直接從內存中讀取a的值,最終打印出的a的值就是0。

好了,說到這,問題也就出現了,就是在多線程的情況下,可能線程1對於線程0的執行結果順序是有依賴的。就如上面的程序CPU0中順序執行a = a + 1; b = b + 1,但是,最終的結果是b的結果先同步回Cache,a的值還在StoreBuffer中,導致了在CPU1中雖然監聽到了b的最新值,但是最終打印出的a的結果卻不是最新的。
這種問題,對於CPU本身來說,是無法解決的,因爲對於CPU來說,他執行的時候,不會去關心其他的CPU的指令執行的順序問題。這種情況,這種順序問題,只有編寫程序的人最清楚,就需要編程人員去解決,所以CPU就提供了memory_barrier。

3.5 內存屏障

3.5.1 內存屏障與StoreBuffer

內存屏障的一個作用就是爲了解決上面這種,storebuffer中的數據無法及時刷新到Cache導致的問題。加入內存屏障後,當CPU執行完a = a + 1後,接下來執行smp_mb(),這個smp_mb()的作用就是要將此刻CPU的StoreBuffer中的內容強制刷新到Cache後(當然還是需要發送失效消息並等待ACK),才能繼續執行b = b + 1。修改代碼如下:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	smp_mb(); // memory barrier
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	System.out.print(a);
}

如上代碼,在fun0中加入內存屏障,CPU0執行完內存屏障指令後,StoreBuffer中的a會被刷新回Cache中,接下來執行b = b + 1。在CPU1中,執行後,最終打印出的結果就是a的最新值1。

3.5.2 內存屏障與invalidate queue

但是這裏依舊存在問題,還是如下代碼:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	smp_mb(); // memory barrier
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	System.out.print(a);
}

現在CPU0執行fun0,CPU1執行fun1。並且此時,在CPU0中,由於之前程序執行的原因,現在a的緩存狀態是S, b的緩存狀態是E。在CPU1中,a的緩存狀態是S。
當a的值被修改,並且執行smp_mb將a的值從storebuffer寫回到Cache中的時候,需要發送失效消息給CPU1。因爲CPU1此時可能會接受很多的失效消息,並來不及處理,這些來不及處理的消息又不能丟棄,這個時候該怎麼辦呢?所以CPU內部就提供了失效隊列invalidate queue。接受到的失效消息,CPU並不會及時去處理,會將消息直接放在失效隊列中,之後再去處理。
好,有了失效隊列,又會出現問題,當CPU0將a的失效消息發送給CPU1的時候,CPU1將失效消息放入緩存隊列一直沒有及時處理。此時CPU0中的a已經改變了,但是因爲CPU1對失效隊列的處理不及時導致CPU1不能及時拿到最新的a值。雖然CPU0中有內存屏障不會導致程序不會得出錯誤的結果,但是會導致CPU0一直阻塞,導致CPU1一直不能執行while循環。
對於這種問題,內存屏障依舊可以解決:

int a = b = 0;
//CPU0
public void fun0(){
	a = a + 1;
	smp_mb(); // memory barrier
	b = b + 1;
}
//CPU1
public void fun1(){
	while(b == 0){
		continue;
	}
	smp_mb(); // memory barrier
	System.out.print(a);
}

內存屏障的另一個作用就是,可以刷新invalidate queue及時處理,處理完之後才繼續向下執行。所以內存屏障同時刷新storebuffer和invalidate queue。

3.5.3 全屏障,讀屏障,寫屏障

但是在上面的場景中看,fun0中不需要刷新失效隊列,而fun1中不需要刷新storebuffer。所以cpu提供了全屏障,讀屏障,寫屏障。
全屏障:同時刷新storebuffer和invalidate queue。
讀屏障:只刷新invalidate queue。
寫屏障:只刷新storebuffe。
所以在上面的代碼中,我們只需要在fun0中加入寫屏障,在fun1中加入讀屏障即可。

說到內存屏障,這裏再說一點,還是對於上面的代碼,對於fun0如果不加內存屏障,還有一種情況下會出現問題,前面也講到過,就是CPU在具體執行指令的時候,對於前後沒有依賴關係的指令,CPU會允許亂序執行。所以可能出現的一種情況就是先執行了b = b + 1;,再執行a = a + 1;這種情況下,CPU1及時得到了b的最新值,但是打印出的a的值卻是不對的。而內存屏障還有一個作用就是說可以防止指令亂序執行,就是CPU在執行指令優化的時候,不能將內存屏障後面的指令優先於前面的指令指令。

3.5.4 總結

1、不能將內存屏障後面的指令優先於前面的指令執行(禁止CPU指令連續執行)。
2、刷新storebuffer和invalidate queue(寫屏障和讀屏障)。
總結也就是,必須等到內存屏障前面的指令都執行完,並且刷新完storebuffer或者invalidate queue(根據不同的屏障執行不同的刷新)才能繼續往下執行。

4 Java內存模型

“內存模型”一詞,可以理解爲在特定的操作協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不一樣的內存模型,而Java虛擬機也有自己的內存模型,並且這裏介紹的內存訪問操作與硬件的緩存訪問操作具有很高的可比性。

上面這段話是《深入理解JVM》中的原話,對上面這段話,我個人的理解是對於不同架構的物理機器,對內存或高速緩存進行讀寫訪問的過程是不一樣的,內存模型是對這種訪問的一種抽象,所以,不同架構的物理機器有不同的內存模型。所以對於不同架構的物理機器,操作內存/高速緩存的讀寫的規則協議是不一樣的,即編寫操作的指令也是不一樣的。對java,要通過JVM做到一處編寫到處運行,所以不能像C那樣直接依賴於硬件架構,所以JVM定義了自己的內存模型,而JVM內存模型就是對不同的底層各種硬件架構的一層抽象。編寫java程序的時候,我們只關心JVM內存模型,java指令在編譯成最終的硬件執行指令的時候,JVM會將java代碼根據不同的硬件結構轉換成操作這種硬件內存的特定的執行指令。

正如《深入理解JVM》中所說:Java虛擬機規範中試圖定義一種Java內存模型(Java Memory Model,JMM)來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。在此之前,主流程序語言(如C/C++等)直接使用物理硬件和操作系統的內存模型,因此,會由於不同平臺上內存模型的差異,有可能導致程序在一套平臺上併發完全正常,而在另外一套平臺上併發訪問卻經常出錯,因此在某些場景就必須針對不同的平臺來編寫程序。

Java內存模型規定了所有的變量都存儲在主內存(Main Memory)中(此處的主內存與介紹物理硬件時的主內存名字一樣,兩者也可以互相類比,但此處僅是虛擬機內存的一部分)。每條線程還有自己的工作內存(Working Memory,可與前面講的處理器高速緩存類比),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量。不同的線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存三者的交互關係如圖所示。
在這裏插入圖片描述
看到這兒,就能理解了,所謂的java內存模型,就是對不同硬件架構內存訪問的一個抽象和操作定義,在不同的硬件平臺上,可能主內存,工作內存對應到的具體的硬件結構也都是不同的。而最終具體如何操作主內存和工作內存之間的交互,依舊依賴於不同的硬件結構。只是java內存模型屏蔽掉了這些差異,或者說,我們使用java內存模型,最終編譯轉換成不同平臺的執行指令,也由JVM根據不同的平臺就行轉換。

5 volatile實現

對問文章開頭將的Volatile關鍵字的作用,這裏重述一遍:
1、禁止java代碼編譯時重排序。
2、禁止CPU的亂序執行。
3、保證變量內存可見性。
那volatile是如何做到的呢?其實都是通過內存屏障實現的。具體我們看一下volatile在JVM層面的實現,如有如下java程序:

public class VolatileTest {
    public volatile static int a = 0;
    public static int b = 2;

    public static void main(String[] args)  {
        a = 9;
        b = 4;
    }
}

通過javap -v 命令查看相應的字節碼:
在這裏插入圖片描述
通過對字節碼的分析,會發現,對於加了volatile關鍵字的變量,在字節碼中會打上一個標識ACC_VOLATILE,那這個標識有什麼用呢?

在JVM源碼中的accessFlags.hpp中定義了這些標識,並提供了相應的方法用於判斷哪些字段被打標了:
在這裏插入圖片描述
所以,可以通過is_volatile方法,我們可以判斷出哪些字段被volatile修飾了,那這個方法在什麼時候會被用到呢?前面說了,volatile的這些作用是通過內存屏障實現的,所以判斷應該是在解釋執行java字節碼的時候,使用變量的時候,加入了相應的內存屏障。好,我們驗證一下,找到執行JVM中操作變量的指令,這些指令定義在bytecodeInterpreter.cpp中,他們是_getstatic(獲取變量),以及_putstatic(修改變量),先看_putstatic的實現,在_putstatic實現中,會找到這樣一段代碼:
在這裏插入圖片描述
很明顯能看到,確實在對變量賦值後,加入了內存屏障。這裏的內存屏障是JVM層名定義的內存屏障,至於,這個內存屏障是如何實現的?我們下面再詳細說明,這裏我們再看看_getstatic,既然修改volatile變量值得時候加入了內存屏障,那獲取volatile變量值得時候,有沒有加入內存屏障呢?_getstatic有實現邏輯如下:
在這裏插入圖片描述
可以看到,_getstatic的時候,確實也會加內存屏障,但是是有條件的,這個條件大概是什麼意思呢?源碼中沒有明確的備註,我也不太清楚是什麼意思。但是我可以猜測一下,前面講到,在多CPU共享變量的時候,發送失效消息後,只有等到確認ack後,才寫緩存,這樣只需要在修改數據的時候加入寫屏障,就可保證了緩存的一致,保證最終的結果不會出錯。設想如果有這樣一種情況情況,對於有些CPU架構,發送失效消息後,就可以直接寫Cache。對於接受失效消息的一方,如果不及時刷新失效隊列就會導致出現錯誤結果。當然,這點只是個人的猜想。我在現在在我的windows的internx86的電腦上運行,最終生成的彙編代碼中,使用volatile變量是沒有加內存屏障的。

下面我們看一下,最終生成的彙編,是否是真的加入了內存屏障,如有如下代碼:

public class VolatileTest {
    public volatile static int a = 0;
    public static int b = 2;
    public static void main(String[] args)  {
        if (a == 0){
            a = 9;
        }
        b = 4;
    }
}

最終生成的彙編代碼如下:
在這裏插入圖片描述
會發現,編譯成彙編代碼之後,對於對volatile變量的修改操作後,多出了一行lock addl $0x0,(%rsp)指令,這行lock addl $0x0,(%rsp)指令,就相當於加了一個內存全屏障。並且,可以看出,在我本地java代碼最終生成的彙編,值在修改volatile變量的時候增加了內存屏障,使用的時候並沒有增加屏障。這也就是說,對volatile變量讀操作其實和普通的變量沒有區別(除了前面提到的那種在使用的時候也加屏障特殊情況),只有在寫操作的時候,因爲加了內存屏障,會對性能有一定的影響。

6 JVM內存屏障

前面講,java內存模型是對不同底層架構內存交互的一種抽象,而內存屏障是一種解決內存內存之間交互帶來的問題的手段,所以,顯然,JVM內存屏障也是對底層不同的硬件CPU架構提供的內存屏障的一種抽象。先看看JVM內存屏障的定義:

在JVM源碼中,能夠找到這種定義,具體在orderAccess.hpp中:
在這裏插入圖片描述
loadload(Load1,Loadload,Load2): 確保Load1所要讀入的數據能夠在被Load2和後續的load指令訪問前讀入。如果兩個Load有依賴關係,就不需要特定去加這種屏障,因爲CPU本身會保證順序性。若沒有依賴關係又想保證其順序性,可以通過這個屏障實現。
storestore(Store1,StoreStore,Store2): 確保Store1的數據在Store2以及後續Store指令操作相關數據之前對其它處理器可見。這個和前面講解storebuffer時候舉得例子一樣,對CPU0對a和b賦值,爲了讓a的值能夠保證在b之前對其他CPU的可見性,所以加入了寫屏障。
loadstore( Load1; LoadStore; Store2): 確保Load1的數據在Store2和後續Store指令被刷新之前讀取。在等待Store指令可以越過loads指令的亂序處理器上需要使用LoadStore屏障。
storeload(Store1; StoreLoad; Load2): 確保Store1的數據在被Load2和後續的Load指令讀取之前對其他處理器可見。StoreLoad屏障可以防止一個後續的load指令使用的Store1的數據不是另一個處理器在相同內存位置寫入一個新數據。它相當於全屏障,開銷也是最昂貴的。因爲正如前面所講,既要刷新storeBuffer,又要刷新失效隊列。

上面講的,都是JVM層面定義的內存屏障,他們最終的實現,還是依賴於不同的硬件提供的內存屏障功能,隨意對於不同的底層平臺有不同的實現:
在這裏插入圖片描述
下面我們看一下在linux_x86中的實現:

// A compiler barrier, forcing the C++ compiler to invalidate all memory assumptions
static inline void compiler_barrier() {
  __asm__ volatile ("" : : : "memory");
}

inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore()  { compiler_barrier(); }
inline void OrderAccess::storeload()  { fence();            }

inline void OrderAccess::acquire()    { compiler_barrier(); }
inline void OrderAccess::release()    { compiler_barrier(); }

inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
  compiler_barrier();
}

可以很清楚的看到,在linux_x86中,loadload,storestore,loadstore只能禁止編譯時重排序,storeload加入了內存屏障(前面講到的volatile編譯後會生成一個 lock; addl $0,0(%%rsp)指令,這個指令就是linux_x86的內存屏障實現),並且禁止了編譯時重排序。

我們再看看linux_aarch64對內存屏障的實現如下:

inline void OrderAccess::loadload()   { acquire(); }
inline void OrderAccess::storestore() { release(); }
inline void OrderAccess::loadstore()  { acquire(); }
inline void OrderAccess::storeload()  { fence(); }

inline void OrderAccess::acquire() {
  READ_MEM_BARRIER;
}

inline void OrderAccess::release() {
  WRITE_MEM_BARRIER;
}

inline void OrderAccess::fence() {
  FULL_MEM_BARRIER;
}

可以很清楚的看到,loadload,loadstore使用的是讀內存屏障,storestore是寫內存屏障,storeload則是全屏障,這個和我們前面講的CPU內存屏障的實現就一一對應起來了。

7 volatile作用總結

1,保證編譯時不被重排序: 如果cpu只有單核,並且CPU保證不亂序執行。那這個時候,我們需要java語言層面的volatile的支持嗎?當然是需要的,因爲在語言層面編譯器和虛擬機爲了做性能優化,可能會存在編譯時指令重排的可能,而volatile給我們提供了一種能力,我們可以告訴編譯器,什麼可以重排,什麼不可以。

2、保證store buffer和invalid queue中的數據及時被刷新和處理: 假設更進一步,假設java語言層面不會對指令做任何的優化重排,那在多核cpu的場景下,我們還需要volatile關鍵字嗎?答案仍然是需要的,因爲 MESI只是保證了多核cpu的獨佔cache之間的一致性,但是cpu的並不是直接把數據寫入 cache的,正如前面所講,中間還可能有store buffer,invalid queue的存在。因此,有MESI協議遠遠不夠。我們需要volatile提供內存屏障的功能,保證被修改的數據強制從store buffer刷入Cache,強制CPU處理invalid queue中的失效消息,保證數據的立即可見。

3、保證CPU對指令不亂序執行: 我們再來做最後一個假設,假設編譯時不重排序,也沒有store buffer 和invalid queue,數據直接寫cache,這個時候MESI協議就能夠保證緩存一致性了,那現在可以拋棄volatile了吧?還是不可以,CPU執行指令的時候,它們只會保證指令之間有比如控制依賴,數據依賴,地址依賴等等依賴關係的指令間提交的先後順序,而對於完全沒有依賴關係的指令,比如x=1;y=2,它們是不會保證執行提交的順序的,所以必須使用了volatile,保證CPU不能對指令亂序執行。

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