Java內(nèi)存模型(JMM)

前言


在類似于電商、大數(shù)據(jù)分析平臺等,往往都要面臨極高的并發(fā)量,而這些情況下,數(shù)據(jù)往往會錯亂,不一致,但在這些場景下,往往不需要完全滿足ACID規(guī)范,因為這樣會嚴(yán)重影響業(yè)務(wù)的并發(fā)量。為此,這類場景只需要保證最終數(shù)據(jù)一致性即可。而類似于金融等,這種對數(shù)據(jù)的一致性要求極高,為此會選擇犧牲一定的并發(fā)量來保證數(shù)據(jù)的一致性。那么在Java中,是如何保證數(shù)據(jù)的一致性呢?那就是Java內(nèi)存模型。

1.JMM試圖解決什么問題?


在沒有內(nèi)存模型之前,程序運行依賴于處理器的內(nèi)存一致性模型,而不同處理器之間又有很大差異,導(dǎo)致同一個程序運行在不同機器上表現(xiàn)不一致。而JMM就是為了解決這種不一致,同時保證多線程程序運行時的正確性。接下來我們開始進入正題,介紹JMM相關(guān)的原理。

2.Java內(nèi)存模型(JMM)


Java 內(nèi)存模型是抽象的概念,描述的是程序間變量的訪問規(guī)則(多線程程序允許表現(xiàn)出的行為),Java線程內(nèi)存模型與CPU緩存模型類似,它是標(biāo)準(zhǔn)化的,用于屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異。

舉個例子,如我們多個線程在訪問內(nèi)存中某個共享變量的時候,往往不是直接訪問內(nèi)存中的共享變量,而是將共享變量拷貝到線程工作內(nèi)存中,這個變量即為共享變量的副本。而這個行為即是內(nèi)存模型的一個抽象出來的規(guī)范。

image.jpg

2.CPU多級緩存


CPU每次從主存中讀取數(shù)據(jù)太慢,現(xiàn)代CPU通常被設(shè)計為多級緩存,CPU讀主存按照空間局部性加載原則,load局部區(qū)塊的數(shù)據(jù)到緩存。

多級緩存

3.CPU緩存一致性原理詳解


我們來看下如下簡單例子,我們測試線程A是否可以嗅探到線程B對initFlag的修改。

public class VolatileVisibilitySample {

    private static  boolean initFlag = false;
    //private static volatile boolean initFlag = false;

    public static void refresh(){
        System.out.println("refresh data--------");
        initFlag = true ;
        System.out.println("refresh data success---------");
    }

    public static void main (String[] args){
        Thread threadA = new Thread( ()->{
            while (!initFlag){

            }
            System.out.println("線程:" + Thread.currentThread().getName() + "當(dāng)前線程嗅探到initFlag的狀態(tài)改變");
        } ,"threadA");
        threadA.start();
        try{
            Thread.sleep(500);
        }catch(InterruptedException e){
            e.printStackTrace();
        }

        Thread threadB = new Thread( ()->{
            refresh();
        },"threadB");
        threadB.start();
    }
}

其運行結(jié)果如下圖所示:


image.png

從運行結(jié)果我們發(fā)現(xiàn),線程A一直在while循環(huán)中,程序一直沒有結(jié)束,這是為什么呢?在解釋原理之前我們先來認(rèn)識一下JMM中的8大數(shù)據(jù)原子操作。

3.1 JMM八大數(shù)據(jù)原子操作

  • lock (鎖定) :作用于主內(nèi)存變量,把一個變量標(biāo)記為一條線程獨占狀態(tài);
  • unlock (解鎖) :作用于主內(nèi)存的變量,把一個處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定;
  • read (讀取) :把一個變量值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動作使用;
  • load(載入) :它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存變量的副本中;
  • use(使用) :把工作內(nèi)存中的一個變量值傳遞給執(zhí)行引擎;
  • assign (賦值) :將計算好的值重新賦值到工作內(nèi)存中;
  • store(存儲) :把工作內(nèi)存中的一個變量的值傳送到主內(nèi)存中,以便隨后的write操作;
  • write(寫入):把store操作從工作內(nèi)存中的一個變量的值傳送到主內(nèi)存的變量中。
3.2 代碼詳解

從示例代碼中可以看到,線程A先于線程B啟動。

線程A在啟動時,先通過read(讀取)原子操作將initFlag這個共享變量從主內(nèi)存中讀取,再通過load(載入)原子操作將read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中。當(dāng)線程A在執(zhí)行到while時,會去工作內(nèi)存中查找initflag的變量副本。

同樣,線程B加載共享變量initFlag的過程與線程A類似。但我們在線程B中,對initFlag進行了賦值操作,線程B要將該值寫到主內(nèi)存中。我們來看下這個寫回主內(nèi)存的過程:首先,通過assign(賦值)原子操作,將修改后的值寫入到線程的工作內(nèi)存中,再通過store(存儲)將工作內(nèi)存中的變量值傳送到主內(nèi)存中(預(yù)傳送),最后通過write(寫入)原子操作,將變量值最終寫入到主內(nèi)存中。

整個過程示例圖如下:

數(shù)據(jù)交互示例.jpg

然而,雖然主內(nèi)存中initFlag的值雖然已經(jīng)被修改了,但是線程A卻無法知道該值已經(jīng)被修改,仍然使用的是工作內(nèi)存中的initFlag=false的值。

我們做如下修改,給initFlag前加上關(guān)鍵字volatile,如下所示:

private static volatile boolean initFlag = false;

再次運行的結(jié)果如下圖所示:


image.png

volatile關(guān)鍵字可以幫助我們解決這個問題,這是為什么呢?接下來我們來詳細了解這個實現(xiàn)原理。

4.volatile可見性底層實現(xiàn)原理


volatile的可見性實現(xiàn)原理:

  • 底層實現(xiàn):通過匯編lock前綴指令觸發(fā)底層緩存鎖定機制(如緩存一致性協(xié)議(MESI)、總線鎖)。
image.jpg

例如觸發(fā)MESI協(xié)議,lock指令會觸發(fā)鎖定變量緩存行區(qū)域并寫回主內(nèi)存,這個操作被稱為"緩存鎖定":

  • 緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內(nèi)存區(qū)域數(shù)據(jù)(MESI協(xié)議)
  • 一個處理器的緩存回寫到內(nèi)存會導(dǎo)致其它處理器的緩存無效(MESI協(xié)議)IA-32架構(gòu)
4.1總線鎖

總線鎖會在CPU與內(nèi)存條之間的總線加入鎖,當(dāng)CPU某一核心成功在總線上加鎖后可以無障礙的去讀寫主內(nèi)存中存儲的數(shù)據(jù),但其余核心是無法訪問主內(nèi)存的任何數(shù)據(jù)的(類似于synchronized關(guān)鍵字、悲觀鎖)。

總線鎖缺點效率極低,但其也作為緩存一致性協(xié)議的輔助方式,當(dāng)緩存一致性協(xié)議無效時,底層依然會使用總線鎖。

4.2緩存一致性協(xié)議(MESI)

緩存一致性協(xié)議(MESI):

  • M:修改;
  • E:獨占;
  • S:共享;
  • I:invalid 無效

在該協(xié)議下,雖然L3級緩存在CPU中是各核心共享的,但是各核心在讀取主內(nèi)存數(shù)據(jù)時,在L3級緩存上都擁有各自的副本。我們通過下圖來解釋加入volatile關(guān)鍵字后,底層是如何實現(xiàn)數(shù)據(jù)可見的。

總線鎖與緩存一致性協(xié)議.jpg

總線嗅探機制(每個核心會監(jiān)聽總線上的數(shù)據(jù)交互,消息)、消息發(fā)布機制

假設(shè)核心0中的線程0先于核心1的線程1讀取主內(nèi)存中的數(shù)據(jù)initFlag。

1)核心0一開始讀取主內(nèi)存中的initFlag時,需要發(fā)送總線讀消息到總線上,沿著總線傳輸,若無其他核嗅探該總線讀消息,那么核心0將initFlag從主內(nèi)存中復(fù)制到L3緩存,數(shù)據(jù)initFlag被標(biāo)記為E(獨占狀態(tài));

2)若此時核心1也需要讀取該數(shù)據(jù),那么它將往總線上發(fā)送總線讀消息,嗅探到有其它核心已經(jīng)讀取過該數(shù)據(jù),此時核心1將復(fù)制一份主內(nèi)存數(shù)據(jù)x到L3緩存中,其它所有在L3緩存中的initFlag數(shù)據(jù)副本的狀態(tài)都被標(biāo)記為共享狀態(tài);

3)兩個核心得到副本后,都會逐級將副本往上復(fù)制(L3->L2->L1);

4)假設(shè)此時,核心0要修改該數(shù)據(jù)initFlag。核心0會往總線上發(fā)送總線本地寫消息進行加鎖(緩存行鎖:CPU緩存的最小存儲單元)鎖定變量,擁有該變量并在使用的核心1嗅探到有其他核心在給該變量加鎖,則認(rèn)為被加鎖變量很有可能無效了,為此會將該變量數(shù)據(jù)標(biāo)記為I(失效狀態(tài))。而核心0中的數(shù)據(jù)狀態(tài)被標(biāo)記為M(修改狀態(tài))。

5)在將修改的數(shù)據(jù)同步寫回主內(nèi)存前,核心0會發(fā)送一個總線寫回消息,該消息沿著總線傳播,其它擁有該變量數(shù)據(jù)的線程在嗅探到該消息后,會去主內(nèi)存中拉取新的數(shù)據(jù)副本,并逐級復(fù)制到工作內(nèi)存中。

注:在核心1中變量副本失效后,執(zhí)行的while語句中的initFlag由于不存在,可能會發(fā)生上下文切換,并且有可能發(fā)生指令重排

在第4)步中,我們可以想象,由于CPU執(zhí)行速度很快,那么極有可能兩個線程同時要修改數(shù)據(jù)initFlag,那么這個時候是誰成功的給變量加鎖呢?這個時候就依靠于總線裁決了。

5 指令重排


在編程中,我們往往會想到的是程序按順序執(zhí)行(即從上往下執(zhí)行),但在高并發(fā)場景下,往往會發(fā)生指令重排。我們先來看下如下例子來驗證指令會發(fā)生重排:

public class VolatileReOrderSample {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException{
        int i = 0;
        for (;;){
            i++;
            x = 0; y  =0;
            a = 0; b = 0;
            Thread t1 = new Thread(new Runnable(){
                public void run(){
                    a = 1;
                    x = b;
                }
            });

            Thread t2 = new Thread (new Runnable(){
                public void run(){
                    b = 1;
                    y = a;
                }

            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0){
                System.err.println(result);
                break;
            }else{
                System.out.println(result);
            }
        }
    }
}

在main函數(shù)中,我們編寫了一個死循環(huán),循環(huán)中每次都會初始化x,y,a,b變量,且每次創(chuàng)建兩個線程,分別對x,y,a或b賦值,當(dāng)遇到x=0和y=0這種情況時,退出循環(huán)。假設(shè)沒有指令重排這種機制,我們先考慮下程序運行過程中可能出現(xiàn)的賦值情況:

線程執(zhí)行情況 x y a b
t1執(zhí)行完后t2執(zhí)行 0 1 1 1
t2執(zhí)行完后t1執(zhí)行 1 0 1 1
t1執(zhí)行a = 1后t2執(zhí)行b=1,后續(xù)執(zhí)行順序t1先或t2先執(zhí)行完剩余代碼 1 1 1 1

我們來看下代碼運行結(jié)果:


image.png

我們可以發(fā)現(xiàn),出現(xiàn)了在沒有指令重排假設(shè)時的其它情況。那么為什么會出現(xiàn)這種情況呢?我們看下下圖:

image.jpg
運行時可能出現(xiàn)的情況
  • 在線程t1的線程棧中,有a=1x=b的字節(jié)碼以及在線程t2中的b=1y=a的字節(jié)碼,它們經(jīng)過字節(jié)碼執(zhí)行引擎執(zhí)行,再通過JIT及時編譯器編譯成匯編指令,最后由CPU執(zhí)行;
  • 而JIT由于存在會根據(jù)線程上下文分析按何種順序執(zhí)行指令會達到更高效,所以會存在交換指令先后順序的情況;
  • 假設(shè)在到達cpu執(zhí)行階段前,t1線程中的a=1x=b的指令未發(fā)生順序交換,而t2線程的執(zhí)行順序同樣未變(即b=1先于y=a)。再假設(shè)t1執(zhí)行了a=1指令后,t2開始執(zhí)行。此時,a =1 已經(jīng)加載到cpu的緩存行中,而x = b尚未加載,當(dāng)cpu執(zhí)行b = 1指令時,由于指令y=a此時的a已經(jīng)在緩存中,而b這個變量的值需要再主內(nèi)存中獲取,由于cpu的快速運轉(zhuǎn)特性,這個過程會影響它的執(zhí)行效率,為此,可能會優(yōu)先執(zhí)行y =a的指令,同時在內(nèi)存中拉取變量b的值,達到提高效率的效果。

那我們可能會想,那么既然很有可能發(fā)生指令重排,那么我們寫的代碼是不是就不會按預(yù)期的執(zhí)行了,也就得不到我們想要的正確結(jié)果。答案很明顯,指令重排同樣需要遵循一定的原則,如happens-before、as-if-serial等。以此來保證程序運行的正確性。同樣,可以使用volatile關(guān)鍵字來實現(xiàn)禁止指令重排。

6.總結(jié)


以上只是對多線程并發(fā)編程中簡單概述了關(guān)于java內(nèi)存模型中的工作模型,以及相關(guān)的緩存一致性協(xié)議(MESI)和指令重排機制。初步認(rèn)識了并發(fā)編程的可見性、原子性與有序性。但值得注意的是volatile保證可見性與有序性,但是不保證原子性,保證原子性還需要其它相關(guān)的鎖機制,如重量級鎖synchronized。(如有錯,望指出)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容