文章目錄
- 1 volatile如何保證可見性
- 2 volatile為什麼不能保證原子性問題
- 3 volatile可以保證有序性的原因
-
- 3.1 單線程禁止重排序的規則 as-if-serial
- 3.1 多線程禁止重排序的規則 happens-before
-
- 3.1.1 happens-before規則的定義和進一步了解
- 3.1.2 happens-before規則的具體内容
-
- 3.1.2.1 volatile變量規則再了解 + 為何volatile可以保證有序性 ★★★
- 3.1.2.2 volatile實作禁止重排序的底層原理(或者說理論) --- 記憶體屏障
- 3.1.2.3 volatile的具體實作方式 --- lock字首指令
- 3.2 結合上文例子再聊一聊volatile是如何保證有序性的
- 4 後記
源碼位址:https://github.com/nieandsun/concurrent-study.git
1 volatile如何保證可見性
上篇文章《【并發程式設計】— 并發程式設計中的可見性、原子性、有序性問題》複現了兩個線程修改相同共享變量的不可見性問題。相信大家肯定都知道,解決該問題的方法之一就是:
在聲明共享變量時加上volatile關鍵字。
那底層原理是什麼呢???
首先應該知道在不加volatile關鍵字時,即使線程2已經将共享變量的值進行了修改,但是由于線程1一直在使用着該共享變量(并不會在某個時刻去同步一下主記憶體),是以線程1無法感覺到線程2對共享變量的修改(可以參照上文中的圖1去了解)。
這時候你肯定會想,如果線程2把共享變量修改了之後,直接能通知線程1就好了。。。
volatile關鍵字就做到了這一點,它具體的實作原理可以用下圖進行解釋:
注意:
下面提到的總線應該是不正确的,但是道理還是這麼個道理。。。總線那裡應該是隻有緩存一緻性協定!!!
其實在主記憶體和各個線程之間還存在着一條總線,volatile關鍵字之是以能保證線程間的可見性,有三點至關重要:
- (1)線程2修改了本工作記憶體中共享變量的副本後,在寫回主記憶體之前對
進行了lock操作總線
- (2)各個線程會通過
對總線進行監控,一旦發現有lock操作,且lock的變量涉及到本線程工作記憶體中的資料副本,就将這些資料清空,并重新從主記憶體中讀取總線嗅探機制
當然如果隻滿足了前面兩點還不行,因為有可能線程1剛一監測到共享變量變了,就立刻去主記憶體中拉取資料,而此時線程2還沒将修改後的變量同步到主記憶體,那線程1拿到的共享變量将還是之前的值,這相當于線程2對共享變量的修改,線程1還是沒監測到,也就無法保證線程間的可見性了。是以這裡還有至關重要的一點:
- (3) 線上程2将修改後的變量寫回到主記憶體之前,其他線程從主記憶體中讀取該共享變量會被阻塞,線程2将變量寫回主記憶體後,
,然後其他線程才能讀取到。 — 當然這個時間是很快的。會進行unlock
正是基于以上三點,volatile才可以保證線程間的可見性。
順便提一下,上訴隻是理論,
其具體實作是利用彙編語言的Lock字首指令
。
2 volatile為什麼不能保證原子性問題
例子可參照上文《【并發程式設計】— 并發程式設計中的可見性、原子性、有序性問題》中的第二個例子,這裡我以兩個線程來說明原因。
volatile不能保證原子性問題的原因可用下圖進行解釋:
如圖所示,假如線程1和線程2都循環執行num++操作,當線程1執行了一次循環進行了num++操作後,會進行assign —> store—> 并嘗試進行lock,但是此時突然
嗅探
到線程2已經對共享變量num進行了lock操作,那線程1就不得不從主記憶體中拉取num的最新值,
再進行新的循環
,也就是說
相當于線程1浪費了一次循環
。 是以即使加上volatile也不能保證多線程程式的原子性。
3 volatile可以保證有序性的原因
上篇文章《【并發程式設計】— 并發程式設計中的可見性、原子性、有序性問題》重制了因代碼重排序引發的多線程間的有序性問題,并叙述了重排序的好處和類型。
但是試想如果各個線程的代碼都可以肆無忌憚,任意的進行重排序,那我們程式員要想寫出符合自己意願的代碼,那得考慮多少種情況 —>
這将嚴重增加程式猿的負擔!!!
—> 是以
規定某些情況下可以重排序,而有些情況下絕對不能重排序 就成了勢在必行的事
在了解多線程情況下volatile可以保證有序性的原理之前,先來了解一下單線程情況下什麼時候不能進行重排序。
單線程情況下之是以我們寫的代碼可以按照我們自己的意願進行執行,是因為在單線程情況下有一個as-if-serial規則 — as-if-serial翻譯成中文就是看起來貌似是有序執行的,即看似我們的代碼是從上到下一行一行進行執行的,沒有進行過重排序的。
as-if-serial語義的意思是:不管編譯器和CPU如何重排序,必須保證在單線程情況下程式的結果是正确的。
以下資料有依賴關系,不能重排序。
- 寫後讀:
int a = 1;
int b = a;
- 寫後寫
int a = 1;
int a = 2;
- 讀後寫
int a = 1;
int b = a;
int a = 2;
編譯器和處理器不能對存在資料依賴關系的操作進行重排序
— 其實這就是as-if-serial的具體規則。因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關系,這些操作就可能被編譯器和處理器重排序。舉例如下:
int a = 1;
int b = 2;
int c = a + b;
a和c之間存在資料依賴關系,同時b和c之間也存在資料依賴關系。是以在最終執行的指令序列中,c不能被重排序到a和b的前面。但a和b之間沒有資料依賴關系,編譯器和處理器可以重排序a和b之間的執行順序。下面是該程式的兩種可能的執行順序:
可以這樣:
int a = 1;
int b = 2;
int c = a + b;
也可以重排序成這樣:
int b = 2;
int a = 1;
int c = a + b;
通過上面的例子可以看到as-if-serial規則把單線程程式保護了起來,遵守as-if-serial語義的編譯器、runtime和處理器可以讓我們感覺到:單線程程式看起來是按程式書寫的順序來一行一行進行執行的。—》 是以我們不必擔心單線程情況下程式的重排序問題。
但是
不同處理器之間和不同線程之間的資料依賴性編譯器和處理器不會考慮
— 》 這就是為什麼多線程程式會出現因程式重排序問題引發的有序性問題的原因。
happens-before規則其實是多個操作之間的記憶體可見性規則,其定義如下:
happens-before用來闡述多個操作之間的記憶體可見性規則。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關系 。
但是兩個操作之間具有happens-before關系,并不意味着前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)
進一步了解:
上面的定義看起來很沖突,其實它是站在不同的角度來說的。
- (1) 站在Java程式員的角度來說:JMM保證,如果一個操作happens-before另一個操作,那麼第一個操作的執行結果将對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
- (2) 站在編譯器和處理器的角度來說:JMM允許,兩個操作之間存在happens-before關系,不要求Java平台的具體實作必須要按照happens-before關系指定的順序來執行。如果重排序之後的執行結果,與按happens-before關系來執行的結果一緻,那麼這種重排序是允許的。
讀完3.1.1,我猜很多人還是懵逼的。。。 都啥玩意啊。。。
我想了很久覺得還是從禁止重排序的角度去了解比較好了解。
接下來我們來看一下 happens-before的具體規則,并從禁止重排序的角度對這些規則進行解讀一下:
- (1)程式順序規則(單線程規則):一個線程中的每個操作,happens-before于該線程中的任意後續操作。
其實就是對as-if-serial規則的另一種說法,即單線程情況下編譯器和處理器不能對存在資料依賴關系的操作做重排序
- (2)螢幕鎖規則:對一個鎖的解鎖,happens-before于随後對這個鎖的加鎖。
其實就是說一個線程必須等到另一個線程把鎖釋放了,它才能搶到這把鎖 —> 這兩個操作之間不能重排序
- (3)volatile變量規則:對一個volatile域的寫,happens-before于任意後續對這個volatile域的讀。
請看3.1.2.1
- (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中的任意操作。
其實就是說線程B中執行的操作,肯定發生線上程B開啟之後 —> 兩者不能颠倒,即不能重排序
- (6)join()規則:如果線程A執行操作ThreadB.join()并成功傳回,那麼線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功傳回。
其實就是說ThreadB.join()這句代碼之後的操作,必須發生線上程B執行完之後 —> 兩者不能重排序,這裡可以參看我的另一篇文章《【并發程式設計】— Thread類中的join方法》
- (7)線程中斷規則:對線程interrupt方法的調用happens-before于被中斷線程的代碼檢測到中斷事件的發生。
其實就是說隻有對線程發起了interrupt操作,該線程才能感覺到有中斷線程的請求 —> 兩者不能重排序,這裡可以參看我的另一篇文章《【并發程式設計】— interrupt、interrupted和isInterrupted使用詳解》
相信按照藍色字型來看 happens-before規則你肯定會更容易了解它,當然或許你會覺得裡面好多規則都像廢話一樣,尤其是(2)、(4)、(5)、(6)、(7)這幾條規則。 —> 但是你要知道我們所謂的因果關系,計算機可并不懂 —>正是有了這些規則才保證了我們寫的代碼,能按照我們的意願來執行 。
我覺得光看3.1.2中描述的volatile變量規則,你是看不出個四五六的。。。
是以這裡直接從volatile變量的重排序規則說起。
volatile變量的重排序規則可以參看下表:
總結起來就是:
- (1)當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確定volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
- (2)當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確定volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
- (3)當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。
上面的規則其實可以用下圖進行表示:
通過該圖,我覺得還可以得到一個結論: 就是隻要你前面對這個volatile共享變量寫了,我後面無論寫還是讀,都可以感覺到你前面寫的内容 —> 其實從這個角度也可以解釋為什麼volatile可以保證線程的可見性
★★★
在這裡從純理論方面分析一下volatile可以保證有序性的原因:
首先從上文的兩個例子來看,之是以會出現有序性問題,就是因為其他變量的修改(或者說寫)與
目标共享變量
的修改發生了重排序,而對目标共享變量加上volatile關鍵字之後, 其他變量的修改,就不能與
加上volatile關鍵字的目标共享變量
的修改進行重排序了,是以也就不會出現
由于重排序導緻的我們寫的代碼和實際運作生成的結果不一緻的問題了。
分析到這裡以後其實可以對我上面畫的圖做如下注釋了:★★★
3.1.2.2 volatile實作禁止重排序的底層原理(或者說理論) — 記憶體屏障
在Java中對于volatile修飾的變量,編譯器在生成位元組碼時,會在指令序列中插入
記憶體屏障
來禁止特定類型的處理器重排序問題。
記憶體屏障有如下四種類型:
到底記憶體屏障是個啥,這裡以LoadLoad記憶體屏障為例畫個圖來了解一下:
在讀1和讀2之間如果加了LoadLoad記憶體屏障,則讀1和讀2就不能再進行重排序了—》 這就是所謂的記憶體屏障。
對于volatile關鍵字,按照規範會有下面的操作(貌似每個資料都這麼說):
在每個volatile寫入之前,插入一個StoreStore,寫入之後,插入一個StoreLoad
在每個volatile讀取之後,插入LoadLoad和LoadStore
其實這裡我有一個疑問
: 既然前面3.1.2.1中說 :
當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確定volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
那在volatile寫之前插入StoreStore,是不是隻保證了volatile本次寫不能與前面的寫進行重排序???而保證不了本次volatile寫與前面的讀不能重排序??? —> 畫圖如下:
歡迎并期待您的留言!!!
順便多說一句
: 我們平常用處理器一般為X86,它其實沒那麼多指令,隻有StoreLoad。
3.1.2.3 volatile的具體實作方式 — lock字首指令
下面的内容來自于某公開課視訊:
通過對OpenJDK中的unsafe.cpp源碼的分析,會發現被volatile關鍵字修飾的變量會存在一個“lock:”的字首。
Lock字首指令并不是一種記憶體屏障,但是它能完成類似記憶體屏障的功能
。Lock會對CPU總線和高速緩存加鎖,可
以了解為CPU指令級的一種鎖。—>
也就是說真正實作記憶體屏障功能的其實是Lock指令!!!
同時該指令會将目前處理器緩存行的資料直接寫會到系統記憶體中,且這個寫回記憶體的操作會使在其他CPU裡緩存了該位址的資料無效。
在具體的執行上,它先對總線和緩存加鎖,然後執行後面的指令,最後釋放鎖後會把高速緩存中的髒資料全部重新整理回主記憶體。在Lock鎖住總線的時候,其他CPU的讀寫請求都會被阻塞,直到鎖釋放。
上面兩段的内容其實和我在本文第1小節分析的一緻。
順便多說一句:
今天看到另一個公開課,他說在win系統上volatile的底層實作是lock字首指令
而在linux系統的底層實作是通過下面三條系統級指令來完成的:
具體如何歡迎并期待您的留言!!!
上文例子1:
@Outcome(id = {"0, 1", "1, 0", "1, 1"}, expect = ACCEPTABLE, desc = "ok")
@Outcome(id = "0, 0", expect = ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class OrderProblem2 {
int x, y;
/****
* 線程1 執行的代碼
* @param r
*/
@Actor
public void actor1(II_Result r) {
x = 1;
r.r2 = y;
}
/****
* 線程2 執行的代碼
* @param r
*/
@Actor
public void actor2(II_Result r) {
y = 1;
r.r1 = x;
}
}
r.r1和r.r2之是以出現全為0的情況,就是因為可能線程1和線程2都進行了重排序,然後剛好,線程1将執行為r.r2 = y ,然後線程2又執行了r.r1 = x ;這時候由于x 和 y均未指派,是以他們的值都為0,是以也就出現了r.r1和r.r2全為0的情況。
而假設r.r1和r.r2都被volatile進行修飾的話,則由于
r.r1 = x;
和
r.r2 = y;
都為寫操作,則其前面的
x =1;
y=1;
操作都不能與之進行重排序,也就保證了r.r1和r.r2不可能出現都為0的情況 —> 由此便解決了該種情況下
由于重排序導緻的我們寫的代碼和實際運作生成的結果不一緻的問題。
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class OrderProblem1 {
int num = 0;
boolean ready = false;
/***
* 線程1 執行的代碼
* @param r
*/
@Actor
public void actor1(I_Result r) {
if (ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
/***
* 線程2 執行的代碼
* @param r
*/
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}