天天看點

Java volatile關鍵字

Java記憶體模型

Java 記憶體模型(JMM)是一種抽象的概念,并不真實存在,它描述了一組規則或規範,通過這組規範定義了程式中各個變量(包括執行個體字段、靜态字段和構成數組對象的元素)的通路方式。試圖屏蔽各種硬體和作業系統的記憶體通路差異,以實作讓 Java 程式在各種平台下都能達到一緻的記憶體通路效果。

注意JMM與JVM記憶體區域劃分的差別:

JMM描述的是一組規則,圍繞原子性、有序性和可見性展開;

相似點:存在共享區域和私有區域

主記憶體與工作記憶體

處理器上的寄存器的讀寫的速度比記憶體快幾個數量級,為了解決這種速度沖突,在它們之間加入了高速緩存。

加入高速緩存帶來了一個新的問題:緩存一緻性。如果多個緩存共享同一塊主記憶體區域,那麼多個緩存的資料可能會不一緻,需要一些協定來解決這個問題。

Java volatile關鍵字

所有的變量都存儲在主記憶體中,每個線程還有自己的工作記憶體,工作記憶體存儲在高速緩存或者寄存器中,儲存了該線程使用的變量的主記憶體副本拷貝。

線程隻能直接操作工作記憶體中的變量,不同線程之間的變量值傳遞需要通過主記憶體來完成。

Java volatile關鍵字

資料存儲類型以及操作方式

方法中的基本類型本地變量将直接存儲在工作記憶體的棧幀結構中;

引用類型的本地變量:引用存儲在工作記憶體,實際存儲在主記憶體;

成員變量、靜态變量、類資訊均會被存儲在主記憶體中;

主記憶體共享的方式是線程各拷貝一份資料到工作記憶體中,操作完成後就重新整理到主記憶體中。

記憶體間互動操作

Java 記憶體模型定義了 8 個操作來完成主記憶體和工作記憶體的互動操作。

Java volatile關鍵字
  • read:把一個變量的值從主記憶體傳輸到工作記憶體(CPU級别的緩存)中
  • load:在 read 之後執行,把 read 得到的值放入工作記憶體的變量副本中
  • use:把工作記憶體中一個變量的值傳遞給執行引擎
  • assign:把一個從執行引擎接收到的值賦給工作記憶體的變量
  • store:把工作記憶體的一個變量的值傳送到主記憶體中
  • write:在 store 之後執行,把 store 得到的值放入主記憶體的變量中
  • lock:作用于主記憶體的變量
  • unlock

記憶體模型三大特性

原子性

// 先看一下代碼:
public class Main {
    private static int cnt = 0;
    public static void main(String[] args) {
        Runnable runnable = () -> {
            for (int j = 0; j < 100; j++) {
                cnt++;
                try {
                    Thread.sleep(30);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(cnt);
        };
        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}

// 發現上面代碼輸出并沒有等于200;           

Java 記憶體模型保證了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如對一個 int 類型的變量執行 assign 指派操作,這個操作就是原子性的。但是 Java 記憶體模型允許虛拟機将沒有被 volatile 修飾的 64 位資料(long,double)的讀寫操作劃分為兩次 32 位的操作來進行,即 load、store、read 和 write 操作可以不具備原子性。

有一個錯誤認識就是,int 等原子性的類型在多線程環境中不會出現線程安全問題。前面的線程不安全示例代碼中,cnt 屬于 int 類型變量,2 個線程對它進行自增操作之後,得到的值為 1 而不是 2。

為了友善讨論,将記憶體間的互動操作簡化為 3 個:load、assign、store。

下圖示範了兩個線程同時對 cnt 進行操作,load、assign、store 這一系列操作整體上看不具備原子性,那麼在 T1 修改 cnt 并且還沒有将修改後的值寫入主記憶體,T2 依然可以讀入舊值。可以看出,這兩個線程雖然執行了兩次自增運算,但是主記憶體中 cnt 的值最後為 1 而不是 2。是以對 int 類型讀寫操作滿足原子性隻是說明 load、assign、store 這些單個操作具備原子性。

Java volatile關鍵字

AtomicInteger 能保證多個線程修改的原子性。

Java volatile關鍵字

使用 AtomicInteger 重寫之前線程不安全的代碼之後得到以下線程安全實作:

import java.util.concurrent.atomic.AtomicInteger;
public class Main {
    private static AtomicInteger cnt = new AtomicInteger();
    public static void main(String[] args) {
        Runnable runnable = () -> {
            for (int j = 0; j < 100; j++) {
                cnt.getAndIncrement();
                try {
                    Thread.sleep(30);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(cnt.get());
        };
        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}
           

除了使用原子類之外還可以使用 synchronized 互斥鎖來保證操作的原子性。它對應的記憶體間互動操作為:lock 和 unlock,在虛拟機實作上對應的位元組碼指令為 monitorenter 和 monitorexit。

public class Main1 {
    private static int cnt = 0;
    public synchronized void add() {
        cnt++;
    }
    public static void main(String[] args) {
        Main1 main1 =new Main1();
        Runnable runnable = () -> {
            for (int j = 0; j < 100; j++) {
                main1.add();
                try {
                    Thread.sleep(30);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(cnt);
        };
        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}
           

可見性

可見性指當一個線程修改了共享變量的值,其它線程能夠立即得知這個修改。Java 記憶體模型是通過在變量修改後将新值同步回主記憶體,在變量讀取前從主記憶體重新整理變量值來實作可見性的。

主要有有三種實作可見性的方式:

volatile,會強制将該變量自己和當時其他變量的狀态都刷出緩存。

synchronized,對一個變量執行 unlock 操作之前,必須把變量值同步回主記憶體。

final,被 final 關鍵字修飾的字段在構造器中一旦初始化完成,并且沒有發生 this 逃逸(其它線程通過 this 引用通路到初始化了一半的對象),那麼其它線程就能看見 final 字段的值。

對前面的線程不安全示例中的 cnt 變量使用 volatile 修飾,不能解決線程不安全問題,因為 volatile 并不能保證操作的原子性。

有序性

有序性是指:在本線程内觀察,所有操作都是有序的。在一個線程觀察另一個線程,所有操作都是無序的,無序是因為發生了指令重排序。在 Java 記憶體模型中,允許編譯器和處理器對指令進行重排序,重排序過程不會影響到單線程程式的執行,卻會影響到多線程并發執行的正确性。

簡單來說:對于代碼有個問題就是指令重排序,編譯器和指令器有時候為了提高代碼執行效率會将指令重新排序。

flag = false;

//線程1:
//準備資源
prepare() 
flag = true;

//線程2:
while(!flag){
   Thread.sleep(1000)
}
 //基于準備好的資源執行操作
execute()           

指令重排序後,讓flag=true 先執行了,會導緻線程2 直接跳過while等待,執行某段代碼,結果prepare()方法還沒有執行,資源沒有準備好,此時就會導緻代碼邏輯出現異常!

JMM 内部的實作通常是依賴于所謂的記憶體屏障,通過禁止某些重排序的方式,提供記憶體可見性保證,也就是實作了各種 happen-before 規則。與此同時,更多複雜度在于,需要盡量確定各種編譯器、各種體系結構的處理器,都能夠提供一緻的行為。

先行發生原則(Happen-Before)

JSR-133記憶體模型使用先行發生原則在Java記憶體模型中保證多線程操作可見性的機制,也是對早期語言規範中含糊的可見性概念的一個精确定義。上面提到了可以用 volatile 和 synchronized 來保證有序性。除此之外,JVM 還規定了先行發生原則,讓一個操作無需控制就能先于另一個操作完成。

由于指令重排序的存在,兩個操作之間有happen-before關系,并不意味着前一個操作必須要在後一個操作之前執行。 僅僅要求前一個操作的執行結果對于後一個操作是可見的,并且前一個操作 按順序 排在第二個操作之前。

  • 單一線程原則(程式員順序規則)Single Thread rule

在一個線程内,按照代碼順序,書寫在程式前面的操作先行發生于書寫後面的操作。

  • 管程鎖定規則(螢幕鎖規則)Monitor Lock Rule

一個 unlock(解鎖) 操作先行發生于後面對同一個鎖的 lock(加鎖)操作。比如代碼裡面先對一個lock.lock()然後lock.unlock(),然後lock.lock()

  • volatile 變量規則 Volatile Variable Rule

對一個 volatile 變量的寫操作先行發生于後面對這個變量的讀操作。

  • 線程啟動規則Thread Start Rule

Thread 對象的 start() 方法調用先行發生于此線程的每一個動作。比如Thread.start() interrupt()

  • 線程加入規則 Thread Join Rule

Thread 對象的結束先行發生于 join() 方法傳回。

  • 線程中斷規則 Thread Interruption Rule

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

  • 對象終結規則 Finalizer Rule

一個對象的初始化完成(構造函數執行結束)先行發生于它的 finalize() 方法的開始。

  • 傳遞性 Transitivity

如果操作 A 先行發生于操作 B,操作 B 先行發生于操作 C,那麼操作 A 先行發生于操作 C。

總結:這些規則制定了在一些特殊情況下,不允許編譯機,指令器對你寫的代碼進行指令重排,必須保證你的代碼的有序性

指令重排序的條件

  • 在單線程環境下不能改變程式的運作結果;
  • 存在資料依賴關系的不允許重排序;
  • 無法通過Happens-before原則推到出來的,才能進行指令的重排序。

volatile

public class Main {
    private  volatile static int i = 0;
    public static void main(String[] args)  {
        new Thread(()->{
             i++ ;
        }).start();
        new Thread(()->{
            // volatile 修飾後 當線程1 操作i++ 重新整理到主記憶體中後,會讓線程2工作記憶體的緩存失效
            // 此時會讀取到 i=1
            while (i ==0){ Thread.sleep(1000); }
            i++ ;
        }).start();
    }
}           

常用場景:一個系統 中間連接配接各種中間件系統,不能直接結束主程序,在結束主程序的時候要把裡面的各個中間件系統關閉後,在結束主程序,不然可能會消息丢失,或者資料不一緻等現象.

public class Kafka{
    private volatile boolean running =true ;
    
    
    // 這是一個接口
    public void shutdown(){
    //關閉這個系統了,shutdown.sh腳本,來調用這個shutdown接口
   
    // 最後運作狀态置為false 
       running =false ;
    }
    
    public static void main(){
         //啟動kafka , rocketmq ,會運作一大堆代碼,中間件系統不能直接停掉
         Kafka kafka= new Kafka();
         
         // 監控kafka 是否關閉 ,未關閉則需要等待!
         while(kafka.running){
             Thread.sleep(1000);
         }
         
    }
}           
如果不加volatile修飾 ,則有可能一直不會關閉,拿到的running狀态 一直是true

前面的案例使用volatile 優化後

volatile boolean flag = false;

//線程1:
//準備資源
prepare() 
flag = true;

//線程2:
while(!flag){
   Thread.sleep(1000)
}
 //基于準備好的資源執行操作
execute()           

比如這個例子,如果使用 volatile來修飾flag變量,一定可以讓prepare() 在flag = true;之前先執行,這就禁止指令重排,因為volatile變量規則要求的是,volatile前面的代碼一定不能指令重排到volatile變量操作後面,volatile後面的代碼也不能指令重排到volatile前面

也可以通過 synchronized 來保證有序性,它保證每個時刻隻有一個線程執行同步代碼,相當于是讓線程順序執行同步代碼。

volatile 不能保證原子性,在有些情況下,可以有限的保證原子性,它主要不是用來保證原子性的! 比如oracle 64位的long 數字進行操作的時候

保證原子性還是需要synchronized,lock 進行加鎖

原理

volatile 關鍵字通過添加記憶體屏障的方式來禁止指令重排,即重排序時不能把後面的指令放到記憶體屏障之前。

  • lock指令(保證可見性)

對 volatile修飾的變量,執行寫操作的話,JVM會發送一條lock字首指令給CPU,CPU在計算完之後會立即将這個值寫回主記憶體,同時因為有MESI緩存一緻性協定,是以各個CPU都會對總線進行嗅探,自己本地緩存中的資料是否被别人修改如果發現别人修改了某個緩存的資料,那麼CPU就會将自己本地緩存的資料過期掉,然後這個CPU上執行的線程在讀取那個變量的時候,就會從主記憶體重新加載最新的資料了

  • 記憶體屏障:禁止重排序(保證有序性)
Load1:
int localVar = this.variable;
Load2:
int localVar = this.variable2;           

Loadload屏障:Load1; LoadLoad;Load2,確定Load1資料的裝載先于Load2後所有裝載指令,他的意思,Load1對應的代碼和Load2對應的代碼,是不能指令重排的

Store1:
this.variable=1;
StoreStore屏障
Store2:
this.variable2=2;           

StoreStore屏障: Store1; StoreStore; Store2,確定 Store1的資料一定刷回主存,對其他cpu

可見,先于 Store2以及後續指令

LoadStore屏障:Load1; LoadStore; Store2,確定Load1指令的資料裝載,先于 Store2以及

後續指令

Storeload屏障: Store1; Storeload;Load2,確定 Store1指令的資料一定刷回主存,對其他

cpu可見,先于Load2以及後續指令的資料裝載

作用

volatile variable =1
this variable=2=> store操作 
int localvariable= this variable=>load操作           

對于 volatile修改變量的讀寫操作,都會加入記憶體屏障,每個 volatile寫操作前面,加 Store Store屏障,禁止上面的普通寫和他重排;每個 volatile寫操作後面,加 Storeload屏障,禁止跟下面的 volatile讀/寫重排←