JVM內存模型(JMM)

目錄

一、概念

二、JMM數據的原子操作

2.1JMM數據的原子操作

2.2volatile可見性原理

2.3 lock和unlock

三、volatile

3.1可見性

3.2原子性

3.3有序性


一、概念

JMM——Java Memeory Model,即Java內存模型,也可以說是JVM內存模型。它的主要作用就是用於定義變量的訪問規則。這裏的變量是指所有線程可以共享的變量,比如成員變量,不能是局部變量。因爲局部變量(在方法內)是放在棧中的,而每個線程都有屬於自己的棧,所以局部變量不是線程共享的。

JMM將內存劃分爲兩個區:主內存區和工作內存區。可能這裏你會問內存空間不是分爲堆、棧、方法區等這些區域嗎?其實都沒有錯,只是從不同的角度進行的劃分而已。就比如說人從性別的角度可以劃分爲男人和女人,但從年齡的角度劃分又可以劃分爲老人、中年人、年輕人、小孩等。下面具體說一下這兩個區域:

  • 主內存區:存放真實的變量
  • 工作內存區:存放的是主內存中變量的副本,供各個線程使用。工作內存是各個線程私有的,每個線程有屬於自己的工作內存。

                     

這裏需要注意兩點

  1. 各個線程只能訪問自己私有的工作內存,不能訪問其他線程的工作內存,也不能訪問主內存;
  2. 不同線程之間,可以通過主內存間接的訪問其他線程的工作內存

明確了上面的概念後我們看一下下面這段代碼,用下面這段代碼證明一下內存模型的真實存在:

public class JMMTest {
    private static  boolean initFlag = false;
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("waiting date...");
                while (!initFlag){

                }
                System.out.println("==========success");
            }
        }).start();
        //爲了保證線程1先執行完再執行線程2,所以在這裏讓主線程睡兩秒。
        Thread.sleep(2000);

        new Thread(new Runnable() {
            @Override
            public void run() {
                prepareData();
            }
        }).start();
    }

    private static void prepareData() {
        System.out.println("prapareing data...");
        initFlag = true;
        System.out.println("prepare date end...");
    }
}

首先我們在JMMTest這個類中創建了一個共享的成員變量initFlag,接着我們創建了兩個線程來模擬工作中遇到的業務場景:線程2用來準備數據,當initFlag被線程2置爲ture時說明數據已經準備好了,線程2運行完畢;線程1用來處理數據,它會通過while (!initFlag)不斷的去判斷數據是否已經就緒,如果沒有就緒就一致執行while循環,但當數據被線程2準備好後(initFlag=true),它就會跳出循環,通過輸出====success模擬對數據處理成功。所以我們預想的輸出結果應該是:

waiting date...
prapareing data...
prepare date end...
==========success

那麼實際情況如何呢?你可以拷貝一下代碼運行一下,我這裏先給出輸出結果:

waiting date...
prapareing data...
prepare date end...

可以看到==========success始終沒有打印出來,並且程序一直處於運行狀態。這和我們預想的結果不一樣啊?怎麼回事?這其實就是java內存模型的效果。initFlag這個共享變量除了在主內存中有一份外,在兩個線程的工作內存中都分別會有一份副本,當線程2修改了initFlag=true時,它只是修改了自己工作內存中副本值(修改後會更新主內存的值爲最新值),而線程1工作內存中的initFlag的值依舊是false,也就是說線程1沒有感知到線程2對共享變量initFlag的修改(沒有從主內存中拉取最新值)。那麼怎樣才能讓某一個線程對共享變量的修改立馬讓其他線程感知到呢?就是給共享變量加一個volatile的關鍵字,這就是我們經常聽說的volatile“可見性”原理,具體的原理細節我會在下面詳細展開,在這裏你先不放試一下給initFlag加個volatile看一看是不是和預想的結果一致。

二、JMM數據的原子操作

2.1JMM數據的原子操作

上面說了這麼多,那麼一個變量是如何從主內存拷貝到工作內存、又如何從工作內存同步回主內存的呢?Java內存模型定義了以下8種操作來實現,下前面的每一個操作都是原子的、不可分割的:

  1. read:作用於主內存變量,將主內存中的變量讀取(拷貝)到工作內存(大範圍內,因爲整個工作內存空間很大,這個階段只是將其放在了工作內存中的某處,還沒有給到變量i的副本)
  2. load:作用於工作內存的變量,將2中讀取的變量拷貝到變量副本中(小範圍,精準定位到某個內存地址/變量上)
  3. use:作用於工作內存的變量,將工作內存中的變量副本傳遞給線程去使用
  4. assign:作用於工作內存的變量,將線程正在使用的變量(線程正在使用的變量也就是此刻CPU正在參與運算的變量),傳遞給工作內存中的變量副本
  5. store:作用於工作內存的變量,將工作內存中變量副本的值傳遞到主內存中(大範圍內,因爲整個主內存空間很大,這個階段只是將其放在了主內存中的某處,還沒有把值給到真正的變量i)
  6. write:作用於主內存的變量,將6中傳遞過來的變量副本作爲一個主內存中的變量進行存儲(小範圍,精準定位到真正的變量i上)
  7. lock:作用於主內存的變量,將主內存中的變量標識爲一條線程的獨佔狀態。
  8. unlock:作用於主內存變量,解除線程的獨佔狀態,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定。

我們還是以上面的代碼爲例子看一下共享變量initFlag是如何在工作內存和主內存中流轉的,先看在沒加volatile關鍵字之前的流轉圖(圖中省略了lock和unlock操作,不影響我們分流轉過程):

首先剛開始initFlag在主內存中的初始值是false,之後線程1會通過read——>load將讀到工作內存中,然後線程1不停的執行while循環(use)。線程2也是通過read——>load將初始值爲false的initFlag從主內存讀到屬於自己的工作內存中,之後cpu運轉將false修改爲true(use),修改完之後通過assign將修改後的值再賦給工作內存,此時工作內存中的initFlag=true;再之後通過store——>write將工作內存中的新值寫回給主內存,此時主內存中的initFlag變爲true。但是這個時候雖然主內存中的initFlag已經爲true了,但是屬於線程1的工作內存中initFlag已久爲false,並沒有什麼機制在主內存的變量變爲最新值後通知給線程1,這就導致了線程1一直在哪裏空轉佔用cpu資源。這也就解釋了我們上面程序中打印出的結果中始終沒有輸出success.

2.2volatile可見性原理

那麼我們再看一下給initFlag加了volatile關鍵字以後與上面的流程有什麼不同?爲什麼加了volatile以後就可以實現線程間的“可見性”呢?主要有以下兩點:

  1. 加了volatile後會將當前處理器緩存的的共享變量(工作內存中的數據)立即寫回主內存(即立刻執行store和write操作);
  2. 這個寫回主內存的操作會引起其他線程工作內存中的共享變量無效(MESI協議)。

我們對上面的兩點再做更進一步的分析:1.volatile的底層實現其實是通過彙編語言的lock前綴指令,只要我們給一個共享變量加了volatile關鍵字,那麼lock指令就會鎖定工作內存中的某個地址(這個地址正是存儲了共享變量的地址),每當工作內存中的這個變量的值發生變化的時候就會出發lock指令立馬寫回到主內存,這是lock指令的第一個作用;2.工作內存在寫回數據到主內存的時候會經過總線(總線的作用就是傳輸數據,兩塊內存不能憑空氣傳遞數據啊,肯定需要傳輸介質,總線就是這個介質的作用),lock指令會觸發MESI協議(這個協議的具體作用就是其他運行中線程會去監聽總線的狀態——即總線嗅探機制,當一個線程將工作內存中的數據經過總線寫回主內存時,那麼在總線上進行監聽的其他線程就會感知到數據的變化,進而將自己工作內存中的數據置爲失效),這就是lock指令的第二個作用。以上就volatile實現“可見性”的深層次原理。我們通過圖來直觀的感受一下:

2.3 lock和unlock

上面的8個原子操作中沒有具體說lock和unlock,這裏單拿出來聊一聊,因爲這兩個操作相比於其他操作涉及的東西多一些。在早些時候lock和unlock操作是通過總線加鎖方式實現的,這種實現方式是在read之前就進行了lock操作,如下圖所示,這種方式的弊端之一就是性能低下,因爲這樣的話一個cpu在從主內存讀取數據到工作內存回對整個過程加鎖,這樣的話其他cpu就沒有辦法從主內存讀取這個數據,直到這個cpu使用完數據後釋放掉鎖之後其他cpu才能繼續讀取數據。這樣多核cpu“並行”執行任務的優點就體現不出來,因爲這樣的話,雖然有多個cpu,但其實在執行任務的時候還是“串行”執行。

                             

而volatile方式的lock操作是在store之前進行的,這個鎖的粒度相比於上面的粒度要小很多,因爲上面的鎖它的作用範圍橫跨了主內存和工作內存交換數據的整個過程(read、load、use、assign、store、write),而這個鎖只涉及store和write兩個過程,這樣效率得到了很大的提升。

清楚了上面的概念後我們再思考一個問題:lock和unlock操作可不可以省略?其實我們在上面講的時候就沒有在流程圖中畫這兩個操作,感覺也很好的實現了共享變量的可見性,但仔細分析你就會發現沒有這兩個操作是不行的,主要原因還是“高併發”會引起的問題,思考下面兩個場景:

  • 假如線程1和線程2同時往主內存中回寫數據,如果沒有鎖機制是不是會導致數據出錯。
  • 假如線程2執行完了store操作(已經寫入主內存中,但是還沒有賦值給initFlag變量,此時的initFlag依舊是false),而正在此時,線程1進行了read操作,那麼它拿到的值依舊時initFlag=false的值。

所以綜上,lock和unlock是必須要有的操作。

三、volatile

其實volatile就是JVM提供的一個輕量級的同步機制,它的主要作用就是以下三個:

  1. 防止jvm對long/double等64位的非原子性協議進行誤操作(讀取半個類型);
  2. 可以使變量對所有的線程立即可見(某一個線程如果修改了工作內存中的變量副本,那麼加上volatile之後,該變量就會立刻同步到其他線程的工作內存中去);
  3. 禁止指令的“重排序”優化。

上面的第一點我就不展開細說了,博文的這部分內容我們主要就volatile對併發編程中的三大特性可見性、原子性、有序性具體展開講一講。上面的第二點和第三點分別就是對應的可見性和有序性,這兩點volatile都可以保證,但原子性volatile是保證不了的。

3.1可見性

關於volatile可以保證可見性的原理我們在上面的“2.2volatile可見性原理”中已經做了詳細的分析,這裏不在贅述。

3.2原子性

首先原子性指的就是不可分割的操作,比如:.num = 10;就是一個原子操作,因爲它不能在分割了,而int num = 10;就是一個非原子操作,因爲它可以進一步分割爲兩個操作——>int num;num = 10;我們通過下面的代碼來驗證volatile不可以保證原子性:

public class TestVolatile {
    static volatile int num = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            //每個線程將num累加30000次,在線程安全的前提下,最後num結果應該是300萬。
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j = 0; j < 30000; j++){
                        num++;//不是一個原子操作,可以進一步拆分成如下1、2操作
                        /**
                         * num = num + 1:
                         * 1.num + 1
                         * 2.num = 1的結果
                         *
                         * 2個線程同時執行num+1(假設此時num=10)
                         * 一個線程:10+1 ->11
                         * 另一個線程:10+1 ->11
                         * 結果:漏加
                         */
                    }
                }
            }).start();
        }
        //讓main線程先睡幾秒,以保證上面的3萬個線程都執行完畢再輸出結果
        Thread.sleep(5000);
        System.out.println("num=" + num);
    }
}

上面的代碼在執行完成後,一般情況下都會小於300萬,原因就是num++不是原子操作,他會進一步拆分成兩個原子操作,這樣在多線程中就可能會出現漏加的情況,導致最後結果小於預期值。程序裏面雖然num用volatile修飾了,但它依然保證不了num++是原子操作,這就是volatile不可保證原子性的具體體現。爲了在高併發場景下此類問題的發生我們可以把num++的這個操作放在一個方法內執行,然後給這個方法加上synchronized關鍵字。或則可以使用原子包java.util.concurrent.aotmic包中的AtomicInteger類,該類能夠保證原子性的核心是因爲提供了compareAndSet()方法,該方法提供了CAS算法(無鎖算法):

將static volatile int num = 0;
改寫爲static AtomicInteger num = new AtomicInteger(0);
將num++改爲num.incrementAndGet();

3.3有序性

在講有序性之前我們需要先搞懂重排序的概念:

重排序:CPU爲了提高執行效率,有時候會將某些語句拆分成原子操作,然後對這些原子操作做進一步排序(排序的對象就是原子操作)。

重排序原則:重排序後不會影響“單線程的執行結果”。

我們看一下大家都熟悉“雙重檢查機制的單例模式”代碼:

//雙重檢查式的懶漢式單例模式
public class Singleton {
    private static Singleton instance = null;
    private Singleton(){ }
    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    instance = new Singleton();//不是一個原子性操作
                }
            }
        }
        return instance;
    }
}

 

在單線程下以上代碼沒有任何問題,但是在多線程高併發的情況下以上代碼可能會出現問題,原因是instance = new Singleton()不是一個原子性操作,會在執行時拆分成以下幾個原子操作:

  1. JVM會分配內存地址和空間
  2. 使用構造方法實例化對象
  3. instance = 第一步中分配好的內存地址

根據重排序的規則可知,以上3個動作在真正執行時可能123,也可能是132,因爲這樣排序後在單線程下不會對結果造成任何影響,所以是可以進行重排序的。但是如果在多線程環境下,使用132可能出現如下問題:假設線程A剛剛執行完以下步驟(即剛執行1、3,但還沒有執行2)

    1.JVM會分配內存地址和空間0x123

    3.nstance = 第一步中分配好的內存地址:instance=0x123

此時線程B進入單例程序的if判斷,直接會得到instance對象(注意,此時的instance是剛纔線程A並沒有new出來的對象),就去使用該對象,例如調用對象的方法instance.xxx()必然報錯,因爲此時雖然給對象分配了內存地址,但是卻沒有把new出來的對象放到這個地址上。解決方案就是禁止此程序使用132的重排序,那麼在instance前面加上volatile關鍵字即可實現:

private volatile static Singleton instance = null;

以上就是volatile保證有序性的具體體現。我去,兩點半了,終於寫完了,趕緊睡覺取類......

最後作爲了解,volite其實是通過“內存屏障”來防止重排序問題的,有時間再詳細展開寫寫:

  • 1.在volatile寫操作前,插入StoreStore屏障
  • 2.在volatile寫操作後,插入StoreLoad屏障
  • 3.在volatile讀操作後,插入LoadLoad屏障
  • 4.在volatile讀操作前,插入LoadStore屏障

參考資料:

B站諸葛老師《Java併發編程深入理解Java內存模型JMM 》

周志明《深入理解Java虛擬機》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章