深入瞭解volatile、內存屏障與happens-before規則

推薦閱讀:

大家都知道,在阿里巴巴泰山版開發手冊中有這一段,在併發情況下使用延遲初始化的方法實現單例模式時,需要將目標屬性聲明爲volatile。

volatile關鍵字在 Java 中的作用是保證變量的可見性防止指令重排

一、保證變量的可見性

在知道volatile是如何保證變量的可見性之前,我們先要知道內存不可見的兩個原因:

1、CPU的運行速度是遠遠高於內存的讀寫速度的,爲了不讓CPU等待讀寫內存數據,現代CPU和內存之間都存在一個高速緩存cache(實際上是一個多級寄存器),如下圖:

線程在運行的過程中會把主內存的數據拷貝一份到線程內部cache中,其實就是訪問自己的內部cache。如果線程B把數據加載進內部緩存cache中,線程A再修改了數據。即使重新寫入主內存,但是線程B不會重新從主內存加載變量,看到的還是自己cache中的變量,所以線程B是讀取不到線程A更新後的值。

在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀到處理器緩存裏。volatile變量通過這樣的機制就使得每個線程都能獲得該變量的最新值。 但是,我們也都知道volatile只能保證可見性,不能保證原子性。多個線程同時讀取這個共享變量的值,就算保證其他線程修改的可見性,也不能保證線程之間讀取到同樣的值然後相互覆蓋對方的值的情況。

二、防止指令重排

我們再來看指令重排。

1、定義

指令重排是指在程序執行過程中, 爲了性能考慮, 編譯器和CPU可能會對指令重新排序

介紹指令重排之前,首先介紹一下內存交互操作的8種指令吧。虛擬機實現必須保證每一個操作都是原子的,不可再分的(對於double和long類型的變量來說,load、store、read和write操作在某些平臺上允許例外)

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

既然操作可以被分解爲很多步驟, 那麼多條操作指令就不一定依次序執行,因爲每次只執行一條指令, 依次執行效率太低了。就像小時候學習的煮飯燒水任務時間分配一樣,內存也會很聰明的分配時間。

本來想給大家整一個指令重排序的例子的,但是不管是我自己寫還是用別人的代碼,我的電腦都沒辦法讓它重排序。但是我們都知道,指令重排是確實存在的(CPU確實會進行重排序,但是這種重排序是無法被我們觀測到和控制的)。

一般重排序可以分爲如下三種:

  • 1、編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序;
  • 2、指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序;
  • 3、內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行的。

2、原理

我們來看加了volatile前後的代碼,用的就是阿里規約提供給我們的雙重檢查鎖的代碼。我們分別編譯了兩次,第一個是沒有使用volatile關鍵字修飾的,第二個是使用volatile關鍵字來修飾,然後取出他們的的彙編代碼(實在是設計的地方太底層,其實這裏算是用到了策略模式了)

未使用volatile修飾

  0x000000010d29e93b: mov    %rax,%r10
  0x000000010d29e93e: shr    $0x3,%r10
  0x000000010d29e942: mov    %r10d,0x68(%rsi)
  0x000000010d29e946: shr    $0x9,%rsi
  0x000000010d29e94a: movabs $0xfe403000,%rax
  0x000000010d29e954: movb   $0x0,(%rsi,%rax,1) 

使用volatile修飾

  0x0000000114353959: mov    %rax,%r10
  0x000000011435395c: shr    $0x3,%r10
  0x0000000114353960: mov    %r10d,0x68(%rsi)
  0x0000000114353964: shr    $0x9,%rsi
  0x0000000114353968: movabs $0x10db6e000,%rax
  0x0000000114353972: movb   $0x0,(%rsi,%rax,1)
  0x0000000114353976: lock addl $0x0,(%rsp)    

很明顯,在movb操作後,加了volatile修飾的彙編代碼後面多了一條彙編指令lock addl $0x0,(%rsp),這個操作相當於一個內存屏障,指令重排時不能把後面的指令重排序到內存屏障之前的位置。lock前綴會強制執行原子操作,它的作用是是的本CPU的cache寫入了內存,該寫入動作會引起別的CPU無效化其cache。所以通過這樣一個空操作,可讓前面volatile變量的便是對其他CPU可見

從硬件架構上講,指令重排序是指CPU將多條指令不按程序規定的順序分開發送給各相應的點,但並不是指令任意重排,CPU需要能正確處理指令,以保障程序能得出正確的執行結果。lock addl $0x0,(%rsp) 指令把修改同步到內存時,意味着所有值錢的操作都已經執行完成,這樣便形成了指令重排序無法越過內存屏障的效果。

三、內存屏障

既然指令重排和可見性都依賴了lock,同時lock指令引出了內存屏障,我們就來學習一下什麼是內存屏障。

1、定義

內存屏障:保證屏障前的讀寫指令必須在屏障後的讀寫指令之前執行,通知被Volatile修飾的值,每次讀取都從主存中讀取,每次寫入都同步寫入主存。

內存屏障具體又分爲寫屏障和讀屏障 寫屏障(Store Memory Barrier):強制將緩存中的內容寫入到緩存中或者將該指令之後的寫操作寫入緩存直到之前的內容被刷入到緩存中,也被稱之爲smp_wmb 讀屏障(Load Memory Barrier):強制將無效隊列(volatile寫操作之後失其作廢)中的內容處理完畢,也被稱之爲smp_rmb

屏障類型 指令示例 說明
LoadLoadBarriers Load1;LoadLoad;Load2 該屏障確保Load1數據的裝載先於Load2及其後所有裝載指令的的操作
StoreStoreBarriers Store1;StoreStore;Store2 該屏障確保Store1立刻刷新數據到內存(使其對其他處理器可見)的操作先於Store2及其後所有存儲指令的操作
LoadStoreBarriers Load1;LoadStore;Store2 確保Load1的數據裝載先於Store2及其後所有的存儲指令刷新數據到內存的操作
StoreLoadBarriers Store1;StoreLoad;Load1 該屏障確保Store1立刻刷新數據到內存的操作先於Load2及其後所有裝載裝載指令的操作.它會使該屏障之前的所有內存訪問指令(存儲指令和訪問指令)完成之後,才執行該屏障之後的內存訪問指令

2、原理

內存屏障在Java中的體現

  • 1、volatile讀之後,所有變量讀寫操作都不會重排序到其前面。
  • 2、volatile讀之前,所有volatile讀寫操作都已完成。
  • 3、volatile寫之後,volatile變量讀寫操作都不會重排序到其前面。
  • 4、volatile寫之前,所有變量的讀寫操作都已完成。

根據JMM規則,結合內存屏障的相關分析得出以下結論

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

如下圖所示:

3、as-if-serial語義

但是用了volatile關鍵字,程序的運行速度必然會受到影響,那麼除了volatile關鍵字以外什麼時候不會發生重排序呢?這裏就要引入as-if-serial語義。

as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器爲了提供並行度),(單線程)程序的執行結果不能被改變。

如果兩個操作訪問同一個變量,且這兩個操作有一個爲寫操作,此時這兩個操作就存在數據依賴性這裏就存在三種情況:1. 讀後寫;2.寫後寫;3. 寫後讀,者三種操作都是存在數據依賴性的。如果重排序會對最終執行結果會存在影響,編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴性關係的兩個操作的執行順序。

int a=1;
int b=2;
int c =a+b;

as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器,runtime和處理器共同爲編寫單線程程序的程序員創建了一個幻覺:單線程程序是按程序的順序來執行的。比如上面計算的代碼,在單線程中,會讓人感覺代碼是一行一行順序執行上,實際上a,b兩行不存在數據依賴性可能會進行重排序,即a,b不是順序執行的。as-if-serial語義使程序員不必擔心單線程中重排序的問題干擾他們,也無需擔心內存可見性問題。

說到底,as-if-serial語義不過是一種最基礎的架構定義,可以類比地球上氧氣的比例約爲21%。

重排序可以分爲兩類:

會改變程序執行結果的重排序。

不會改變程序執行結果的重排序。

JMM對這兩種不同性質的重排序,採取了不同的策略。

  • 對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
  • 對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不做要求(JMM允許進行優化重排序)

volatile就是通過對內存語義的封裝實現了對volatile關鍵字讀寫時的順序和可見。保證了我們所謂的多線程下的可見性,但是還是沒辦法保證多線程下修改數據的同步,因爲同步除了有序和可見還需要滿足原子性。

四、happens-before規則

在Java內存模型中,如果要確保有序性可以靠volatile和synchronized來實現,但是如果所有的有序性都僅僅依靠這兩個關鍵字來完成,那麼有一些操作將會變得很繁瑣,但是我們在編寫Java代碼的時候並沒有感覺到這一點,這是因爲Java語言中有一個“先行發生(happens-before)”的原則。那麼happens-before到底是什麼呢?

happens-before的概念最初由Leslie Lamport在其一篇影響深遠的論文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出。JSR-133(即JavaTM內存模型與線程規範,由JSR-133專家組開發)使用happens-before的概念來指定兩個操作之間的執行順序。

1、定義

happens-before表示的是前一個操作的結果對於後續操作是可見的,它是一種表達多個線程之間對於內存的可見性。所以我們可以認爲在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作必須要存在happens-before關係。

具體的定義爲:

  • 1、如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

  • 2、兩個操作之間存在happens-before關係,並不意味着Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

2、happens-before的8條規則

8條規則定義:

  • 1、程序順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。(在一個線程內一段代碼的執行結果是有序的)
  • 3、監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。(先加鎖後解鎖)
  • 3、volatile變量規則:對於volatile修飾的變量的寫的操作,一定happen-before後續對於volatile變量的讀操作。(讀寫不會重排序,寫操作的結果一定對讀的這個線程可見)
  • 4、傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
  • 5、start()規則:如果線程A執行操作ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作。
  • 6、Join()規則:如果線程A執行操作ThreadB.join()併成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。
  • 7、程序中斷規則:對線程interrupted()方法的調用先行於被中斷線程的代碼檢測到中斷時間的發生。
  • 8、對象finalize規則:一個對象的初始化完成(構造函數執行結束)先行於發生它的finalize()方法的開始。

JMM對編譯器和處理器的束縛已經儘可能少。從上面的分析可以看出,JMM其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。例如,如果編譯器經過細緻的分析後,認定一個鎖只會被單個線程訪問,那麼這個鎖可以被消除。再如,如果編譯器經過細緻的分析後,認定一個volatile變量只會被單個線程訪問,那麼編譯器可以把這個volatile變量當作一個普通變量來對待。這些優化既不會改變程序的執行結果,又能提高程序的執行效率。

3、happens-before與JMM的關係

一個happens-before規則對應於一個或多個編譯器和處理器重排序規則。對於Java程序員來說,happens-before規則簡單易懂。

結語

這段時間爲了寫這篇文章也是看了很多的文章和視頻,很多文章都互相有衝突,很多點我也沒有辦法保證完全正確,只能自己拿着JSR133的文檔去看,看完了以後我推薦大家不要去看orz,真的是學的越多懂得越多不懂的也就更多了。不過學習如逆水行舟,不進則退,如果沒有跳出舒適區(包括學一些很難的東西)的勇氣,那學習這一條路也就到頭了吧~一起加油吧!

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