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操作從工作記憶體中的一個變數的值傳送 到主記憶體的變數中

流程圖大致是這樣的:

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