Java架構之路(多線程)JMM和volatile關鍵字
說到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 |
執行順序: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操作從工作內存中的一個變量的值傳送 到主內存的變量中
流程圖大致是這樣的: