1. 程式人生 > 程式設計 >你真的瞭解JMM嗎?

你真的瞭解JMM嗎?

引言

在現代計算機中,cpu的指令速度遠超記憶體的存取速度,由於計算機的儲存裝置與處理器的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:將運算需要使用到的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體的速度矛盾,但是也為計算機系統帶來更高的複雜度,因為它引入了一個新的問題:快取一致性(Cache Coherence)。在多處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(MainMemory)。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致,舉例說明變數在多個CPU之間的共享。如果真的發生這種情況,那同步回到主記憶體時以誰的快取資料為準呢?為瞭解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

一、JMM(Java Memory Model)

java虛擬機器器規範定義java記憶體模型遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓java程式在各種平臺下都能達到一致的併發效果。

java記憶體模型規定了一個執行緒如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在必須時如何同步的訪問共享變數

注意:我們這裡強調的是共享變數,不是私有變數。

java記憶體模型規定了所有的變數都儲存在主記憶體中(JVM記憶體的一部分)。每條執行緒都有自己的工作記憶體,工作記憶體中儲存了該執行緒使用的主記憶體中共享變數的副本,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數;工作記憶體線上程間是隔離的,不能直接訪問對方工作記憶體中的變數。所以在多執行緒操作共享變數時,就通過JMM來進行控制。

我們來看一看執行緒,工作記憶體、主記憶體三者的互動關係圖。

二、JMM的8種記憶體互動操作

9龍就疑問,JMM是如何保證併發下資料的一致性呢?

記憶體互動操作有8種,虛擬機器器實現必須保證每一個操作都是原子的,不可再分的(對於double和long型別的變數來說,load、store、read和write操作在某些平臺上允許例外)

  • lock (鎖定):作用於主記憶體的變數,把一個變數標識為執行緒獨佔狀態。
  • read (讀取):作用於主記憶體變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。
  • load (載入):作用於工作記憶體的變數,它把read操作從主存中得到變數放入工作記憶體的變數副本中。
  • use (使用):作用於工作記憶體中的變數,它把工作記憶體中的變數傳輸給執行引擎,每當虛擬機器器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。
  • assign (賦值):作用於工作記憶體中的變數,它把一個從執行引擎中接受到的值賦值給工作記憶體的變數副本中,每當虛擬機器器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store (儲存):作用於工作記憶體中的變數,它把一個從工作記憶體中一個變數的值傳送到主記憶體中,以便後續的write使用。
  • write  (寫入):作用於主記憶體中的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。
  • unlock (解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。

如果是將變數從主記憶體複製到工作記憶體,必須先執行read,後執行load操作;如果是將變數從工作記憶體同步到主記憶體,必須先執行store,後執行write。JMM要求read和load,store和write必須按順序執行,但不是必須連續執行,中間可以插入其他的操作。

2.1、JMM指令使用規則
  • 不允許read和load、store和write操作之一單獨出現。即使用了read必須load,使用了store必須write
  • 不允許執行緒丟棄他最近的assign操作,即工作變數的資料改變了之後,必須告知主存
  • 不允許一個執行緒將沒有assign的資料從工作記憶體同步回主記憶體
  • 一個新的變數必須在主記憶體中誕生,不允許工作記憶體直接使用一個未被初始化的變數。就是對變數實施use、store操作之前,必須經過assign和load操作
  • 一個變數同一時間只有一個執行緒能對其進行lock。多次lock後,必須執行相同次數的unlock才能解鎖
  • 如果對一個變數進行lock操作,會清空所有工作記憶體中此變數的值,在執行引擎使用這個變數前,必須重新load或assign操作初始化變數的值
  • 如果一個變數沒有被lock,就不能對其進行unlock操作。也不能unlock一個被其他執行緒鎖住的變數
  • 對一個變數進行unlock操作之前,必須把此變數同步回主記憶體

三、volatile

很多併發程式設計中都使用了volatile,你知道為什麼一個變數要使用volatile修飾嗎?

volatile有兩個語義:

  1. volatile可以保證執行緒間變數的可見性。
  2. volatile禁止CPU進行指令重排序。

volatile修飾的變數,如果某個執行緒更改了變數值,其他執行緒可以立即觀察到這個值。而普通變數不能做到這一點,變數值線上程間傳遞均需要主記憶體來完成。如果執行緒修改了普通變數值,則需要重新整理回主記憶體,另一個執行緒需要從主記憶體重新讀取才能知道最新值。

3.1、volatile只能保證可見性,不能保證原子性

雖然volatile只能保證可見性,但不能認為volatile修飾的變數可以在併發下是執行緒安全的。

public class VolatileTest {
    /**
     * 進行自增操作的變數
     * 使用volatile修飾
     */

    private static volatile int count;

    public void main(String[] args) {
        int threadNums = 2000;
        ExecutorService service = Executors.newCachedThreadPool();
        for (int i = 0; i < threadNums; i++) {
            service.execute(VolatileTest::addCount);
        }
        System.out.println(count);
        service.shutdown();
    }

    addCount() {
        count++;
    }
}
//輸出結果
//1994
複製程式碼

我們可以從例子中看出,共享變數使用了volatile修飾,啟動2000個執行緒對其進行自增操作,如果是執行緒安全的,結果應該是2000;但結果卻小於2000。證明volatile修飾的變數並不能保證原子性,如果想保證原子性,還需要額外加鎖。

3.2、volatile禁止指令重排序

雖然程式從表象上看到是按照我們書寫的順序進行執行,但由於CPU可能會由於效能原因,對執行指令進行重排序,以此提高效能。

比如我們有一個方法是關於“談戀愛”的方法。虛擬碼如下

{
    //執行緒A執行1,2,3
    //1、先認識某個女生,有好感
    //2、開展追求
    //3、追求成功

    //執行緒B,等待執行緒A追求成功後開始進入甜蜜的愛情
    while(!追求成功){
        sleep();
    }
    //一起看電影,吃飯,牽手,接吻,xxx
}
複製程式碼

我們看到執行緒A需要執行3步,由於cpu執行重排序優化,可能執行順序變為1、3、2,亂套了,剛認識別人就成功了,接著就牽手,接吻,然後可能再執行追求的過程。。。。。。。。不敢想象,我還只是個孩子啊。這就是指令重排序可能在多執行緒環境下出現的問題。

如果我們使用volatile修飾“追求成功”的變數,則可以禁止CPU進行指令重排序,讓談戀愛是一件輕鬆而快樂的事情。

volatile使用記憶體屏障來禁止指令重排序。
在每個volatile寫操作的前面插入一個StoreStore屏障,在每個volatile寫操作的後面插入一個StoreLoad屏障。

在每個volatile讀操作的後面插入一個LoadLoad屏障,在每個volatile讀操作的後面插入一個LoadStore屏障。

四、原子性、可見性、順序性

我們看到JMM圍繞這三個特徵來建立的。

4.1、原子性

JMM提供了read、load、use、assign、store、write六個指令直接提供原子操作,我們可以認為java的基本變數的讀寫操作是原子的(long,double除外,因為有些虛擬機器器可以將64位分為高32位,低32位分開運算)。對於lock、unlock,虛擬機器器沒有將操作直接開放給使用者使用,但提供了更高層次的位元組碼指令,monitorentermmonitorexit來隱式使用這兩個操作,對應於java的synchronized關鍵字,因此synchronized塊之間的操作也具有原子性

4.2、可見性

我們上面說了執行緒之間的變數是隔離的,執行緒拿到的是主存變數的副本,更改變數,需要重新整理回主存,其他執行緒需要從主存重新獲取才能拿到變更的值。所有變數都要經過這個過程,包括被volatile修飾的變數;但volatile修飾的變數,可以在修改後強制重新整理到主存,並在使用時從主存獲取重新整理,普通變數則不行。

除了volatile修飾的變數,synchronized和final。synchronized在執行完畢後,進行unlock之前,必須將共享變數同步回主記憶體中(執行store和write操作)。前面規則其中一條。

而final修飾的欄位,只要在建構函式中一旦初始化完成,並且沒有物件逃逸(指物件為初始化完成就可以被別的執行緒使用),那麼在其他執行緒中就可以看到final欄位的值。

4.3、有序性

有序性在volatile已經詳細說明瞭。可以總結為,在本執行緒觀察到的結果,所有操作都是有序的;如果多執行緒環境下,一個執行緒觀察到另一個執行緒的操作,就說雜亂無序的。

java提供了volatile和synchronized兩個關鍵字保證執行緒之間的有序性,volatile使用記憶體屏障,而synchronized基於lock之後,必須unlock後,其他執行緒才能重新lock的規則,讓同步塊在在多執行緒間序列執行。

五、Happends-Before原則

先行發生是java記憶體模型中定義的兩個操作的順序,如果說操作A先行發生於執行緒B,就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值,傳送了訊息,呼叫了方法等。

我們舉個例子說一下。

//執行緒A執行
i = 1
//執行緒B執行
j = i
//執行緒C執行
i = 2
複製程式碼

我們還是定義A執行緒執行 i = 1 先行發生於 執行緒B執行的 j = i;那麼我們可以確定,線上程B執行之後,j的值是1。因為根據先行發生原則,執行緒A執行之後,i的值為1,可以被B觀察到;並且執行緒A執行之後,執行緒B執行之前,沒有執行緒對i的值進行變更。

這時候我們考慮執行緒C,如果我們還是保證執行緒A先行發生於B,但執行緒C出現在A與B之間,那麼,你可以確定j的值是多少嗎?答案是否定的。因為執行緒C的結果也可能被B觀察到,這時候可能是1,也可能是2。這就存線上程安全問題。

在JMM下具有一些天然的先行發生關係,這些原則在無須任何同步協助下就已經存在,可以直接使用。如果兩個操作之間的關係不在此列,並且無法從以下先行發生原則推匯出來,它們就沒有順序性保證,虛擬機器器就會進行隨意的重排序。

  • 程式次序規則(Program Order Rule):在一個執行緒內,程式的執行規則跟程式的書寫規則是一致的,從上往下執行。

  • 鎖定規則(Monitor Lock Rule):一個Unlock的操作肯定先於下一次Lock的操作。這裡必須是同一個鎖。同理我們可以認為在synchronized同步同一個鎖的時候,鎖內先行執行的程式碼,對後續同步該鎖的執行緒來說是完全可見的。

  • volatile變數規則(volatile Variable Rule):對同一個volatile的變數,先行發生的寫操作,肯定早於後續發生的讀操作

  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作

  • 執行緒終止規則(Thread Termination Rule):Thread物件的中止檢測(如:Thread.join(),Thread.isAlive()等)操作,必晚於執行緒中所有操作

  • 執行緒中斷規則(Thread Interruption Rule):對執行緒的interruption()呼叫,先於被呼叫的執行緒檢測中斷事件(Thread.interrupted())的發生

  • 物件終止規則(Finalizer Rule):一個物件的初始化方法先於執行它的finalize()方法

  • 傳遞性(Transitivity):如果操作A先於操作B、操作B先於操作C,則操作A先於操作C

總結

本篇詳細總結了Java記憶體模型。再來品一品這句話。

java記憶體模型規定了一個執行緒如何同步的訪問共享變數

各位看官,如果覺得9龍的文章對你有幫助,求點贊,求關注。如果轉載請註明出處。

本篇主要總結於:

深入理解Java虛擬機器器++JVM高階特性與最佳實踐