Java內存模型JMM與volatile

1.Java內存模型

JAVA定義瞭一套在多線程讀寫共享數據時時,對數據的可見性、有序性和原子性的規則和保障。屏蔽掉不同操作系統間的微小差異。

Java內存模型(Java Memory Model)是一種抽象的概念,並不真實存在,它描述的是一組規則或規范(定義瞭程序中各個變量的訪問方式)。 JVM運行程序的實體是線程,而每個線程創建時 JVM 都會為其創建一個工作內存(棧空間),用於存儲線程私有的數據,而Java 內存模型中規定所有變量都存儲在主內存主內存是共享內存區域,所有線程都可以訪問, 但線程對變量的操作(讀取賦值等)必須在工作內存中進行。所以首先要將變量從主內存拷貝的自己的工作內存空間,然後對變量進行操作,操作完成後再將變量寫回主內存,不能直接操作主內存中的變量。工作內存是每個線程的私有數據區域,因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成。

基於JMM規范的線程,工作內存,主內存工作交互圖 :

  • 主內存: 線程的共享數據區域,主要存儲的是Java實例對象,所有線程創建的實例對象都存放在主內存中(包括局部變量、類信息、常量、靜態變量)。
  • 工作內存: 線程私有,主要存儲當前方法的所有本地變量信息(主內存中的變量副本拷貝) , 每個線程隻能訪問自己的工作內存,即線程中的本地變量對其它線程是不可見的,即使訪問的是同一個共享變量。

對於一個實例對象中的成員方法: 如果方法中包含本地變量是基本數據類型,將直接存儲在工作內存的幀棧結構中,如果是引用類型,那麼該變量的引用會存儲在功能內存的幀棧中,而對象實例將存儲在主內存(共享數據區域,堆)中。

需要註意的是,在主內存中的實例對象可以被多線程共享,倘若兩個線程同時調用瞭同一個對象的同一個方法,那麼兩條線程會將要操作的數據拷貝一份到自己的工作內存中,執行完成操作後才刷新到主內存

下面是線程讀取共享變量count執行count + 1 操作的過程:

數據同步八大原子操作:

  • (1)lock(鎖定): 作用於主內存的變量,把一個變量標記為一條線程獨占狀態
  • (2)unlock(解鎖): 作用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放後 的變量才可以被其他線程鎖定
  • (3)read(讀取): 作用於主內存的變量,把一個變量值從主內存傳輸到線程的工作內存 中,以便隨後的load動作使用
  • (4)load(載入): 作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工 作內存的變量副本中
  • (5)use(使用): 作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎
  • (6)assign(賦值): 作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內 存的變量
  • (7)store(存儲): 作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存 中,以便隨後的write的操作
  • (8)write(寫入): 作用於工作內存的變量,它把store操作從工作內存中的一個變量的值 傳送到主內存的變量中

2.並發三大特性

2.1.原子性

定義: 一個操作在CPU中不可以中途暫停再調度,要麼全部執行完成,要麼全部都不執行

問題: 兩個線程對初始值的靜態變量一個做自增,一個做自減同樣做10000次的結果很可能不是 0

解決關鍵字: synchronized、ReentrantLock 建議:

  • 用sychronized對對象加鎖的力度建議大一點(減少加解鎖次數)
  • 鎖住同一個對象

2.2.可見性

定義: 當多個線程訪問同一個變量時,一個線程修改瞭這個變量的值,其他線程立即看得到修改的值。(即時性)

問題: 兩個線程在不同的 CPU ,若線程1改變瞭變量 i 的值,還未刷新到主存,線程2又使用瞭 i,那麼線程2看到的這個值肯定還是之前的

//線程1
boolean stop = false;
while(stop){
    ....
}
//線程2
stop = true;
//並未退出循環

解決關鍵字: synchronized、volatile

volatile 關鍵字,它可以用來修飾成員變量和靜態成員變量,避免線程從自己的工作內存中查找變量值,必須到主存中獲取它的值,線程操作volatile變量都是直接操作主內存,還可以禁止指令重排。 synchronized語句塊既可以保證代碼的原子性,也可以保證代碼塊內部的可見性,但是呢synchronized屬於重量級操作,性能相對更低

註意: 對於上述循環代碼塊,加入System.out.println(); 會退出循環,因為 println 被 synchronized 修飾,所有,不要隨便在代碼中使用這種打印語句,會極度影響程序性能。

2.3.有序性

定義: 虛擬機在進行代碼編譯時,對改變順序後不會對最終結果造成影響的代碼,虛擬機不一定會按我們寫的代碼順序運行,有可能進行重排序。實際上雖然重排後不會對變量值有影響,但會造成線程安全問題。

解決關鍵字: synchronized、ReentrantLock  volatile關鍵字,可以禁止指令重排

指令重排: JIT 編譯器在運行時的一些優化,可以提升 CPU 的執行效率,不讓 CPU 空閑下來。對改變順序後不會對最終結果造成影響的代碼,虛擬機不一定會按我們寫的代碼順序運行,有可能進行重排序。比如說,我兩行代碼 X 和 Y,虛擬機認為它們倆的執行順序不影響程序結果,但 Y 已經在 CacheLine 中存在瞭,就會優先執行 Y。

分析下面偽代碼的運行情況(r.r1的值):

int num = 0;
boolean ready = false;
// 線程1 執行此方法
public void action1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}
// 線程2 執行此方法
public void action2(I_Result r) {
    num = 2;
    ready = true;
}
情況1:線程1 先執行,此時 ready = false,所有進入else ,結果為1
情況2:線程2 先執行 num = 2,但還沒來得及執行 ready = true,線程1 開始執行,還是進入else ,結果為1
情況3:線程2 先執行到ready = true,線程1 執行,進入else ,結果為4
情況4:指令重排導致,線程2執行 ready = true,切換到線程1,進入 if 分支,相加為0,再切回線程2,執行 num = 2,結果為0

double-checked locking 單例模式: 也存在指令重排問題(不使用volatile,對象實例化是原子操作,但分為幾步,每一步又不是原子操作),因此需要在對象前加上 volatile 關鍵字防止指令重排,這也是個非常經典的禁止指令重排的例子。

public class SingleLazy {
    private SingleLazy() {}
    private volatile static SingleLazy INSTANCE;
    // 獲取實體
    public static SingleLazy getInstance() {
        // 實例未被創建,開啟同步代碼塊準備創建
        if (INSTANCE == null) {
            synchronized (SingleLazy.class) {
                // 也許其他線程在判斷完後已經創建,再次判斷
                if (INSTANCE == null) {
                    INSTANCE = new SingleLazy();
                }
            }
        }
        return INSTANCE;
    }
}

創建對象可以大致分為三步,其中第一步和第二步可能會發生指令重排導致安全性問題:

memory = allocate();//1.分配對象內存空間
instance(memory);//2.初始化對象
instance = memory;//3.設置instance指向剛分配的內存地址,此時instance e != null

註意: JDK1.5前的 volatile 關鍵字不保證指令重排問題

3.兩個規則

as-if-serial 語義保證單線程內程序的執行結果不被改變,happens-before關系保證正確同步的多線程程序的執行結構不被改變

3.1.happens-before規則

定義: 如果一個操作 happens-before 另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前

兩個操作之間存在 happens-before 關系,並不意味 java 平臺的具體實現必須按照 happens-before 關系指定的順序來執行。如果重排序後的執行結構,與按 happens-before 關系來執行的結果一致,那麼這種重排序並不非法(JMM允許這種重排序),happens-before 原則內容如下:

程序順序原則 即在一個線程內必須保證語義串行性,也就是說按照代碼順序執行,(時間上)先執行的操作happen-before(時間上後執行的操作)

鎖規則 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說,如果對於一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。

volatile規則 volatile變量的寫,先發生於讀,這保證瞭volatile變量的可見性,簡 單的理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的 值,而當該變量發生變化時,又會強迫將最新的值刷新到主內存,任何時刻,不同的線程總是能夠看到該變量的最新值。

線程啟動規則 線程的start()方法先於它的每一個動作,即如果線程A在執行線程B 的start方法之前修改瞭共享變量的值,那麼當線程B執行start方法時,線程A對共享 變量的修改對線程B可見

傳遞性 A先於B ,B先於C 那麼A必然先於C

線程終止規則 線程的所有操作先於線程的終結,Thread.join()方法的作用是等待 當前執行的線程終止。假設在線程B終止之前,修改瞭共享變量,線程A從線程B的 join方法成功返回後,線程B對共享變量的修改將對線程A可見。

線程中斷規則 對線程 interrupt()方法的調用先行發生於被中斷線程的代碼檢測到 中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否中斷。

對象終結規則 對象的構造函數執行,結束先於finalize()方法

3.2.as-if-serial

不管怎麼重排序(編譯器和處理器為瞭提高並行度),(單線程)程序的執行結構不能被改變。為瞭遵守as-if-serial 語義,編譯器和處理器不會存在數據依賴關系的操作做重排序,但是如果操作之前不存在數據依賴關系,這些操作就可能被編譯器和處理器重排序。

4.volatile

volatile是Java虛擬機提供的輕量級的同步機制,可以保證可見性,但無法保證原子性。  作用:

  • 保證可見性,也就是當一個線程修改瞭一個被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。即時可見通過緩存一致性協議保證
  • 禁止指令重排優化。通過內存屏障實現。
//示例
//並發場景下,count++操作不具備原子性,分為兩步先讀取值,再寫回,會出現線程安全問題
public class VolatileVisibility {
    public static volatile int count = 0;
    public static void increase(){
        count++;
    }
}

4.1.volatile 禁止重排優化的實現

volatile 變量通過內存屏障實現其可見性和禁止重排優化。

內存屏障: 又稱內存柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變量的內存可見性。編譯器和處理器都能執行指令重排優化。Intel 硬件提供瞭一系列的內存屏障,主要有:Ifence(讀屏障)、sfence(寫屏障)、mfence(全能屏障,包括讀寫)、Lock前綴等。不同的硬件實現內存屏障的方式不同,Java 內存模型屏蔽瞭這種底層硬件平臺的差異,由 JVM 來為不同的平臺生成相應的機器碼。 JVM 中提供瞭四類內存屏障指令:

屏障類型 指令 說明
LoadLoad Load1; LoadLoad; Load2 保證load1的讀取操作在load2及後續讀取操作之前執行
StoreStore Load1; LoadLoad; Load2 在store2及其後的寫操作執行前,保證store1的寫操作
StoreStore Store1; StoreStore; Store2 在stroe2及其後的寫操作執行前,保證load1的讀操作
StoreLoad Store1; StoreLoad; Load2 保證store1的寫操作已刷新到主內存之後,load2及其作

volatile內存語義的實現: JMM 針對編譯器制定的 volatile 重排序規則表

操作 普通讀寫 volatile讀 volatile寫
普通讀寫 可以重排 可以重排 不可以重排
volatile讀 不可以重排 不可以重排 不可以重排
volatile寫 可以重排 不可以重排 不可以重排

比如第二行最後一個單元格的意思是:在程序中,當第一個操作為普通變量的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。 

編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障
  • 在每個volatile寫操作的後面插入一個StoreLoad屏障
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障
  • 在每個volatile讀操作的後面插入一個LoadStore屏障

class VolatileBarrierExample {
        int a;
        volatile int v1 = 1;
        volatile int v2 = 2;
        void readAndWrite() {
        int i = v1; // 第一個volatile讀、普通寫
        int j = v2; // 第二個volatile讀、普通寫
        a = i + j; // 普通寫 
        v1 = i + 1; // 第一個volatile寫
        v2 = j * 2; // 第二個 volatile寫
    }
}

4.2.MESI緩存一致性協議

鏈接: 認識Java底層操作系統與並發基礎.  多核CPU的情況下,如何保證緩存內部數據的一致性?JAVA引入瞭MESI緩存一致性協議。 

Java代碼的執行流程:

volatile 修飾的變量(鎖也是)翻譯的匯編指令前會加 Lock 前綴,OS調度時會 觸發硬件緩存鎖定機制(總線鎖 或 緩存一致性協議) ,CPU 通過總線橋訪問內存條,多個 CPU 訪問同一內存,首先需要拿到總線權。早期,計算機不發達,性能低,總線鎖采用直接占有,其他 CPU 無法繼續通過總線橋訪問。無法發揮 CPU 的多核能力。現代 CPU 采用采用緩存一致性協議進行保證(跨緩存行CacheLine(緩存存儲數據的數據單元) 時會升級為總線鎖)。

MESI 是指4種狀態的首字母。每個 Cache line 有4個狀態,可用2個bit表示:

狀態 描述 監聽任務
M 修改(Modified) 該CacheLine有效,數據被修改瞭,和內存中的數據不一致,數據隻存在於本Cache中 緩存行必須時刻監聽所有試圖讀該緩存行相對就主存的操作,這種操作必須在緩存將該緩存行寫回主存並將狀態變成S(共享)狀態之前被延遲執行
E 獨享、互斥(Exclusive) 該CacheLine有效,數據和內存中的數據一致,數據隻存在於本Cache中 緩存行也必須監聽其它緩存讀主存中該緩存行的操作,一旦有這種操作,該緩存行需要變成S(共享)狀態
S 共享 (Shared) 該CacheLine有效,數據和內存中的數據一致,數據存在於很多Cache中 緩存行也必須監聽其它緩存使該緩存行無效或者獨享該緩存行的請求,並將該緩存行變成無效(Invalid)
I 無效 (Invalid) 該CacheLine無效

MESI 協議狀態切換過程分析:

舉例:

註意:一個 CacheLine 裝不下變量,會升級為總線鎖。

MESI優化和他們引入的問題:  緩存的一致性消息傳遞是要時間的,這就使其切換時會產生延遲。當一個緩存被切換狀態時其他緩存收到消息完成各自的切換並且發出回應消息這麼一長串的時間中CPU都會等待所有緩存響應完成。可能出現的阻塞都會導致各種各樣的性能問題穩定性問題

 為瞭避免這種CPU運算能力的浪費,Store Bufferes 被引入使用。處理器把它想要寫入到主存的值寫到緩存,然後繼續去處理其他事情。當所有失效確認(Invalidate Acknowledge)都接收到時,數據才會最 終被提交。

但它也會帶來一定的風險:

  • 處理器會嘗試從存儲緩存(Store buffer)中讀取值,但它還沒有進行提交。這個的解決方案稱為Store Forwarding,它使得加載的時候,如果存儲緩存中存在,則進行返回
  • 保存什麼時候會完成,這個並沒有任何保證,可能會發生重排序(非指令重排)。CPU會讀到跟程序中寫入的順序不一樣的結果。

到此這篇關於Java內存模型JMM與volatile的文章就介紹到這瞭,更多相關Java內存模型內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: