java架構之路(一)JMM和volatile關鍵字
- 2019 年 10 月 5 日
- 筆記
說到JMM大家一定很陌生,被我們所熟知的一定是jvm虛擬機,而我們今天講的JMM和JVM虛擬機沒有半毛錢關係,千萬不要把JMM的任何事情聯想到JVM,把JMM當做一個完全新的事物去理解和認識。
我們先看一下電腦的理論模型,也是馮諾依曼電腦模型,先來張圖。

其實我們更關注與電腦的內部CPU的計算和記憶體之間的關係。我們在來深入的看一下是如何計算的。

我們來看一下這個玩意的處理流程啊,當我們的數據和方法載入的記憶體區,需要處理時,記憶體將數據和方法傳遞到CPU的L3->L2->L1然後再進入到CPU進行計算,然後再由L1->L2->L3->再返回到主記憶體中,但是我們的科技反展的很快的,現在貌似沒有單核的CPU了吧,什麼8核16核的CPU隨處可見,我們這裡只的CPU只是CPU的一個核來計算這些玩意。假設我們的方法是f(x) = x + 1,我們入參是1,期望得到結果是2,1+1=2,我計算的沒錯吧。如果我們兩個核同時執行該方法呢?我們的CPU2反應稍微慢了一點呢?假如當我們的記憶體再向CPU2發送參數時,可能CPU1已經計算完成並且已經返回了。這時CPU2取得的參數就是2,這時再進行計算就是2+1了。結果並不是我們期望的結果。這其實就是數據未同步造成的。我們應該想盡我們的辦法去同步一下數據。

我們中間加了一層快取一致性協議。也就是我們的MESI,在多處理器系統中,每個處理器都有自己的高速快取,而它們的又共享同一個主記憶體
我來簡單說一下,我們的MESI是咋回事,是怎麼做到快取一致性的。英語不好,我就不誤解大家解釋MESI是什麼單詞的縮寫了(是不是縮寫我也不知道,但是我知道工作原理)。
我們還是從記憶體到CPU的這條線路,這時我們多了一個MESI,當變數X被共同讀取時,CPU1和CPU2是共享一個X變數,但是分別存在CPU1和2內,也就是我們X(S)的狀態。然後CPU1和2一起準備要計算了。
然後1和2一定會有一個厲害的。比如1得到了勝利,這時CPU1里的X(S)變為X(E)由共享狀態變為獨享狀態,並且告訴CPU2把X(S)變為X(I)的狀態,由共享狀態變為失效狀態。然後CPU1就可以計算了。計算完成,
又將X(S)變為X(M)的狀態,由獨享狀態變為了修改的狀態。
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)
說到這也就是是我們JMM的記憶體模型的工作機制了。所以說JMM是一個虛擬的,和JVM一點關係都沒有的。切記不要混淆。
這裡也有三個重要的知識點。
JVM 記憶體模型(JMM) 三大特性
原子性:指一個操作是不可中斷的,即使是多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒干擾
比如,對於一個靜態全局變數int i,兩個執行緒同時對它賦值,執行緒A 給他賦值 1,執行緒 B 給它賦值為 -1,。那麼不管這兩個執行緒以何種方式,何種步調工作,i的值要麼是1,要麼是-1,執行緒A和執行緒B之間是沒有干擾的。這就是原子性的一個特點,不可被中斷
可見性:指當一個執行緒修改了某一個共享變數的值,其他執行緒是否能夠立即知道這個修改。顯然,對於串列程式來說,可見性問題 是不存在。因為你在任何一個操作步驟中修改某個變數,那麼在後續的步驟中,讀取這個變數的值,一定是修改後的新值。但是這個問題在並行程式中就不見得了。如果一個執行緒修改了某一個全局變數,那麼其他執行緒未必可以馬上知道這個改動。
有序性:對於一個執行緒的執行程式碼而言,我們總是習慣地認為程式碼的執行時從先往後,依次執行的。這樣的理解也不能說完全錯誤,因為就一個執行緒而言,確實會這樣。但是在並發時,程式的執行可能就會出現亂序。給人直觀的感覺就是:寫在前面的程式碼,會在後面執行。有序性問題的原因是因為程式在執行時,可能會進行指令重排,重排後的指令與原指令的順序未必一致(指令重排後面會說)。
我們來看一下volatile關鍵字
先看一段程式碼吧,不上程式碼,總覺得是自己沒練習到位。
private static int counter = 0; public static void main(String[] args) { for (int i = 0; i < 10; i++) { Thread thread = new Thread(()->{ for (int j = 0; j < 1000; j++) { counter++; //不是一個原子操作,第一輪循環結果是沒有刷入主存,這一輪循環已經無效 } }); thread.start(); } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter); }
按照JMM的思想流程來解讀一下這段程式碼,我們先創建10個執行緒。我們這裡叫做T1,T2,T3…T100。然後分別去拿counter這個數字,然後疊加1,循環1000-counter次。當T1拿到counter,開始計算,假如,我們計算到第50次時,這時執行緒T2,也開始要拿counter這個數字,這時得到的counter數字為50,則T2就要循環950次,最後我們計算得到的counter就是9950。也就是說,內部是沒有記憶體一致性協議的。所以我們的輸出一定是<=10000的數字。
我們來嘗試改一下程式碼,使用一下我們的volatile關鍵字。
private static volatile int counter = 0; public static void main(String[] args) { for (int i = 0; i < 10; i++) { Thread thread = new Thread(()->{ for (int j = 0; j < 1000; j++) { counter++; //不是一個原子操作,第一輪循環結果是沒有刷入主存,這一輪循環已經無效 } }); thread.start(); } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter); }
這時我們加入了volatile關鍵字,我們經過多次運行會發現,每次結果都為10000,也就是說每次都是我們期待的結果,volatile可以保證執行緒可見性且提供了一定的有序性,但是無法保證原子性。在JVM底層volatile是採用「記憶體屏障」來實現的。
也就是我們加入了volatile關鍵字時,java程式碼運行過程中,會強制給予一層記憶體一致性的屏障,做到了,我們計算直接不會相互影響,得到我們預期的結果。
1、可見性實現:
在前文中已經提及過,執行緒本身並不直接與主記憶體進行數據的交互,而是通過執行緒的工作記憶體來完成相應的操作。這也是導致執行緒間數據不可見的本質原因。因此要實現volatile變數的可見性,直接從這方面入手即可。對volatile變數的寫操作與普通變數的主要區別有兩點:
(1)修改volatile變數時會強制將修改後的值刷新的主記憶體中。
(2)修改volatile變數後會導致其他執行緒工作記憶體中對應的變數值失效。因此,再讀取該變數值的時候就需要重新從讀取主記憶體中的值。相當於上文說到的從S->E,另一個執行緒從S->I的過程。
通過這兩個操作,就可以解決volatile變數的可見性問題。
2、記憶體屏障
為了實現volatile可見性和happen-befor的語義。JVM底層是通過一個叫做「記憶體屏障」的東西來完成。記憶體屏障,也叫做記憶體柵欄,是一組處理器指令,用於實現對記憶體操作的順序限制。下面是完成上述規則所要求的記憶體屏障:
Required barriers |
2nd operation |
|||
---|---|---|---|---|
1st operation |
Normal Load |
Normal Store |
Volatile Load |
Volatile Store |
Normal Load |
|
|
|
LoadStore |
Normal Store |
|
|
|
StoreStore |
Volatile Load |
LoadLoad |
LoadStore |
LoadLoad |
LoadStore |
Volatile Store |
|
|
StoreLoad |
StoreStore |
(1)LoadLoad 屏障 執行順序:Load1—>Loadload—>Load2 確保Load2及後續Load指令載入數據之前能訪問到Load1載入的數據。
(2)StoreStore 屏障 執行順序:Store1—>StoreStore—>Store2 確保Store2以及後續Store指令執行前,Store1操作的數據對其它處理器可見。
(3)LoadStore 屏障 執行順序: Load1—>LoadStore—>Store2 確保Store2和後續Store指令執行前,可以訪問到Load1載入的數據。
(4)StoreLoad 屏障 執行順序: Store1—> StoreLoad—>Load2 確保Load2和後續的Load指令讀取之前,Store1的數據對其他處理器是可見的。
總體上來說volatile的理解還是比較困難的,如果不是特別理解,也不用急,完全理解需要一個過程,在後續的文章中也還會多次看到volatile的使用場景。這裡暫且對volatile的基礎知識和原來有一個基本的了解。總體來說,volatile是並發編程中的一種優化,在某些場景下可以代替Synchronized。但是,volatile的不能完全取代Synchronized的位置,只有在一些特殊的場景下,才能適用volatile。總的來說,必須同時滿足下面兩個條件才能保證在並發環境的執行緒安全:
(1)對變數的寫操作不依賴於當前值。
(2)該變數沒有包含在具有其他變數的不變式中。
參考地址:https://www.cnblogs.com/paddix/p/5428507.html
JMM-同步八種操作介紹
(1)lock(鎖定):作用於主記憶體的變數,把一個變數標記為一條執行緒獨佔狀態
(2)unlock(解鎖):作用於主記憶體的變數,把一個處於鎖定狀態的變數釋放出來,釋放後的 變數才可以被其他執行緒鎖定
(3)read(讀取):作用於主記憶體的變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中, 以便隨後的load動作使用
(4)load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作 記憶體的變數副本中
(5)use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎
(6)assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體 的變數
(7)store(存儲):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中, 以便隨後的write的操作
(8)write(寫入):作用於工作記憶體的變數,它把store操作從工作記憶體中的一個變數的值傳送 到主記憶體的變數中
流程圖大致是這樣的:

後面會繼續談談並發的問題。也會仔細說一下指令重排這裡,這篇部落格只是暫時的說了一下而已,後續還有很多重要的知識點要說。