JUC之Volatile

weixin_44533129發表於2020-12-25

1、談談對Volatile的理解

  • Volatile是Java虛擬機器提供的輕量級的同步機制(三大特性)
    • 可見性
    • 不保證原子性
    • 禁止指令重排

1.1 JMM是什麼?

JMM是Java記憶體模型,也就是Java Memory Model,簡稱JMM,本身是一種抽象的概念,實際上並不存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構成陣列物件的元素)的訪問方式。
JMM關於同步的規定:

  • 執行緒解鎖前,必須把共享變數的值重新整理回主記憶體
  • 執行緒解鎖前,必須讀取主記憶體的最新值,到自己的工作記憶體
  • 加鎖和解鎖是同一把鎖

由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方成為棧空間),工作記憶體是每個執行緒的私有資料區域,而Java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝到自己的工作空間,然後對變數進行操作,操作完成再將變數寫回主記憶體,不能直接操作主記憶體中的變數,各個執行緒中的工作記憶體儲存著主記憶體中的變數副本拷貝,因此不同的執行緒無法訪問對方的工作記憶體,此案成間的通訊(傳值) 必須通過主記憶體來完成,其簡要訪問過程如下圖:
在這裡插入圖片描述

1.2 快取一致性

為什麼這裡主執行緒中某個值被更改後,其它執行緒能馬上知曉呢?其實這裡是用到了匯流排嗅探技術。
在說嗅探技術之前,首先談談快取一致性的問題,就是當多個處理器運算任務都涉及到同一塊主記憶體區域的時候,將可能導致各自的快取資料不一。為了解決快取一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議進行操作,這類協議主要有MSI、MESI等等。
1)MESI
當CPU寫資料時,如果發現操作的變數是共享變數,即在其它CPU中也存在該變數的副本,會發出訊號通知其它CPU將該記憶體變數的快取行設定為無效,因此當其它CPU讀取這個變數的時,發現自己快取該變數的快取行是無效的,那麼它就會從記憶體中重新讀取。
2)匯流排嗅探
那麼是如何發現資料是否失效呢?
這裡是用到了匯流排嗅探技術,就是每個處理器通過嗅探在匯流排上傳播的資料來檢查自己快取值是否過期了,當處理器發現自己的快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效狀態,當處理器對這個資料進行修改操作的時候,會重新從記憶體中把資料讀取到處理器快取中。
3)匯流排風暴
匯流排嗅探技術有哪些缺點?
由於Volatile的MESI快取一致性協議,需要不斷的從主記憶體嗅探和CAS迴圈,無效的互動會導致匯流排頻寬達到峰值。因此不要大量使用volatile關鍵字,至於什麼時候使用volatile、什麼時候用鎖以及Syschonized都是需要根據實際場景的。

1.3 Volatile之可見性

我們知道各個執行緒對主記憶體中共享變數的操作都是各個執行緒各自拷貝到自己的工作記憶體操作後再寫回主記憶體中的.這就可能存在一個執行緒AAA修改了共享變數X的值還未寫回主記憶體中時 ,另外一個執行緒BBB又對記憶體中的一個共享變數X進行操作,但此時A執行緒工作記憶體中的共享比那裡X對執行緒B來說並不不可見.這種工作記憶體與主記憶體同步延遲現象就造成了可見性問題.
程式碼驗證:

class Data {
    volatile int number = 0;
    public void addTo60() {
        this.number = 60;
    }
    // 注意number已用voliate修飾,不保證原子性
    public void addPlusPlus() {
        number++;
    }
}

/**
 * voliate保證原子性,及時通知其他執行緒,主記憶體的資料已經修改
 */
private static void seeOkByVoliate() {
    Data myData = new Data();
    new Thread(() -> {
        System.out.println(Thread.currentThread().getName() + "\t come in");
        // 暫停一會執行緒
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        myData.addTo60();
        System.out.println(Thread.currentThread().getName() + "\t updated number value is " +myData.number);
    }, "AAA").start();

    // 第二個執行緒是main執行緒
    while (myData.number == 0){
        // main執行緒一直在這裡等待,直至number不等於0
    }
    System.out.println(Thread.currentThread().getName() + "\t mission is over. number value is:" + myData.number);
}

輸出結果:

AAA	 come in
AAA	 updated number value is 60
main	 mission is over. number value is:60

1.4 Volatile之原子性

不可分割,完整性,也就是說某個執行緒正在做某個具體業務時,中間不可以被加塞或者被分割,需要具體完成,要麼同時成功,要麼同時失敗。

1.4.1 Volatile不保證原子性

程式碼驗證(20個執行緒連續加2000次):

class Data {
    volatile int number = 0;
    public void addTo60() {
        this.number = 60;
    }
    // 注意number已用voliate修飾,不保證原子性
    public void addPlusPlus() {
        number++;
    }
}

private static void verifyAtomic() {
        Data myData = new Data();
        for(int i=1; i<=20; i++) {
            new Thread(() -> {
                for (int j=1; j<=1000; j++) {
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }
        // 需要等待上面20個執行緒全部都計算完成後,再用main執行緒取得最終的結果值看是多少
        // 預設後臺兩個執行緒,垃圾回收執行緒和main執行緒
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t int type,finally number value:" + myData.number);
    }

輸出結果:

main	 int type,finally number value:19371

期待結果應為20000,但是實際是19371。

1.4.2 為什麼Volatile不滿足原子性(資料丟失問題)?

看下圖:
在這裡插入圖片描述下面我們將一個簡單的number++操作,轉換為位元組碼檔案一探究竟:

public class T1 {
    volatile int n = 0;
    public void add() {
        n++;
    }
}

轉換後的位元組碼檔案(轉碼方法):

public class com.moxi.interview.study.thread.T1 {
  volatile int n;

  public com.moxi.interview.study.thread.T1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #2                  // Field n:I
       9: return

  public void add();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field n:I
      10: return
}

我們能夠發現 n++這條命令,被拆分成了3個指令:

  • 執行getfield 從主記憶體拿到原始n
  • 執行iadd 進行加1操作
  • 執行putfileld 把累加後的值寫回主記憶體
    假設我們沒有加 synchronized那麼第一步就可能存在著,三個執行緒同時通過getfield命令,拿到主存中的 n值,然後三個執行緒,各自在自己的工作記憶體中進行加1操作,但他們併發進行 iadd 命令的時候,因為只能一個進行寫,所以其它操作會被掛起,假設1執行緒,先進行了寫操作,在寫完後,volatile的可見性,應該需要告訴其它兩個執行緒,主記憶體的值已經被修改了,但是因為太快了,其它兩個執行緒,陸續執行 iadd命令,進行寫入操作,這就造成了其他執行緒沒有接受到主記憶體n的改變,從而覆蓋了原來的值,出現寫丟失,這樣也就讓最終的結果少於20000.

1.4.3 如何Volatile解決不滿足原子性(資料丟失問題)?

1) 在方法上加入 synchronized

class Data {
    volatile int number = 0;
    public void addTo60() {
        this.number = 60;
    }
    // 注意number已用voliate修飾,不保證原子性
    public synchronized void addPlusPlus() {
        number++;
    }
}

private static void verifyAtomic() {
        Data myData = new Data();
        for(int i=1; i<=20; i++) {
            new Thread(() -> {
                for (int j=1; j<=1000; j++) {
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }
        // 需要等待上面20個執行緒全部都計算完成後,再用main執行緒取得最終的結果值看是多少
        // 預設後臺兩個執行緒,垃圾回收執行緒和main執行緒
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t int type,finally number value:" + myData.number);
    }

輸出結果:

main	 int type,finally number value:20000

2) 使用AomicInteger原子類:

class Data {
    volatile int number = 0;
    public void addTo60() {
        this.number = 60;
    }
    // 注意number已用voliate修飾,不保證原子性
    public void addPlusPlus() {
        number++;
    }
	// 使用原子類解決原子性問題
    AtomicInteger atomicInteger = new AtomicInteger();
    public void addAtomic() {
        atomicInteger.getAndIncrement();
    }
}

private static void solveByAtomic() {
        Data myData = new Data();
        for(int i=1; i<=20; i++) {
            new Thread(() -> {
                for (int j=1; j<=1000; j++) {
                    myData.addAtomic();
                }
            }, String.valueOf(i)).start();
        }
        // 需要等待上面20個執行緒全部都計算完成後,再用main執行緒取得最終的結果值看是多少
        // 預設後臺兩個執行緒,垃圾回收執行緒和main執行緒
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t atomic type,finally number value:" + myData.atomicInteger);
    }

輸出結果:

main	 atomic type,finally number value:20000

1.5 Volatile之禁止指令重排

計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令重排,一般分為以下三種:

原始碼 -> 編譯器優化的重排 -> 指令並行的重排 -> 記憶體系統的重排 -> 最終執行指令

單執行緒環境裡面確保最終執行結果和程式碼順序的結果一致
處理器在進行重排序時,必須要考慮指令之間的資料依賴性
多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。

  1. 指令重排example 1
public void mySort() {
	int x = 11;
	int y = 12;
	x = x + 5;
	y = x * x;
}

按照正常單執行緒環境,執行順序是 1 2 3 4
但是在多執行緒環境下,可能出現以下的順序:

  • 2 1 3 4
  • 1 3 2 4
    上述的過程就可以當做是指令的重排,即內部執行順序,和我們的程式碼順序不一樣。但是指令重排也是有限制的,即不會出現下面的順序:
  • 4 3 2 1
    因為處理器在進行重排時候,必須考慮到指令之間的資料依賴性。因為步驟 4:需要依賴於 y的申明,以及x的申明,故因為存在資料依賴,無法首先執行。
  1. 指令重排example 2
int a,b,x,y = 0
執行緒1執行緒2
x = a;y = b;
b = 1;a = 2;
x = 0; y = 0

因為上面的程式碼,不存在資料的依賴性,因此編譯器可能對資料進行重排:

執行緒1執行緒2
b = 1;a = 2;
x = a;y = b;
x = 0; y = 0

這樣造成的結果,和最開始的就不一致了,這就是導致重排後,結果和最開始的不一樣,因此為了防止這種結果出現,volatile就規定禁止指令重排,為了保證資料的一致性。

1.5.1 Volatile為什麼能解決指令重排問題?

olatile實現禁止指令重排優化,從而避免了多執行緒環境下程式出現亂序執行的現象。
首先了解一個概念,記憶體屏障(Memory Barrier)又稱記憶體柵欄,是一個CPU指令,它的作用有兩個:

  • 保證特定操作的順序
  • 保證某些變數的記憶體可見性(利用該特性實現volatile的記憶體可見性)
    由於編譯器和處理器都能執行指令重排的優化,如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什麼指令都不能和這條Memory Barrier指令重排序,也就是說 通過插入記憶體屏障禁止在記憶體屏障前後的指令執行重排序優化。 記憶體屏障另外一個作用是重新整理出各種CPU的快取數,因此任何CPU上的執行緒都能讀取到這些資料的最新版本。
    對volatile變數進行寫操作時,會在寫操作後加入一條store屏障指令,將工作記憶體中的共享變數值重新整理回主記憶體。
    在這裡插入圖片描述
    對volatile變數進行讀寫操作時,會在讀操作前加入一條load屏障指令,從主記憶體中讀取共享變數。
    在這裡插入圖片描述

1.6 Volatile在哪裡使用過?

1.6.1 單例模式

1)單例模式DCL的問題
單例模式程式碼:

public class SingletonDemo {

    private static SingletonDemo instance = null;

    private SingletonDemo () {
        System.out.println(Thread.currentThread().getName() + "\t 我是構造方法SingletonDemo");
    }

    public static SingletonDemo getInstance() {
        if(instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        // 這裡的 == 是比較記憶體地址
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
    }
}

輸出結果:

在這裡插入圖片描述
但是在多執行緒的環境下,我們的單例模式是否還是同一個物件了。

public class SingletonDemo {

    private static SingletonDemo instance = null;

    private SingletonDemo () {
        System.out.println(Thread.currentThread().getName() + "\t 我是構造方法SingletonDemo");
    }

    public static SingletonDemo getInstance() {
        if(instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

輸出結果:
在這裡插入圖片描述
從上面的結果我們可以看出,我們通過SingletonDemo.getInstance() 獲取到的物件,並不是同一個,而是被下面幾個執行緒都進行了建立,那麼在多執行緒環境下,單例模式如何保證呢?

2) 保證單例模式的例項只有一個(引入synchronized關鍵字)

public synchronized static SingletonDemo getInstance() {
    if(instance == null) {
        instance = new SingletonDemo();
    }
    return instance;
}

輸出結果:
在這裡插入圖片描述我們能夠發現,通過引入Synchronized關鍵字,能夠解決高併發環境下的單例模式問題。但是synchronized屬於重量級的同步機制,它只允許一個執行緒同時訪問獲取例項的方法,但是為了保證資料一致性,而減低了併發性,因此採用的比較少。
2) 保證單例模式的例項只有一個(DCL Double Check Lock 雙端檢鎖機制)

public static SingletonDemo getInstance() {
    if(instance == null) {
        // 同步程式碼段的時候,進行檢測
        synchronized (SingletonDemo.class) {
            if(instance == null) {
                instance = new SingletonDemo();
            }
        }
    }
    return instance;
}

輸出的結果:
在這裡插入圖片描述從輸出結果來看,確實能夠保證單例模式的正確性,但是上面的方法還是存在問題的.

DCL(雙端檢鎖)機制不一定是執行緒安全的,原因是有指令重排的存在,加入volatile可以禁止指令重排.

原因是在某一個執行緒執行到第一次檢測的時候,讀取到 instance 不為null,instance的引用物件可能沒有完成例項化。因為 instance = new SingletonDemo();可以分為以下三步進行完成:

  • memory = allocate(); // 1、分配物件記憶體空間
  • instance(memory); // 2、初始化物件
  • instance = memory; // 3、設定instance指向剛剛分配的記憶體地址,此時instance != null

但是我們通過上面的三個步驟,能夠發現,步驟2 和 步驟3之間不存在 資料依賴關係,而且無論重排前 還是重排後,程式的執行結果在單執行緒中並沒有改變,因此這種重排優化是允許的。

  • memory = allocate(); // 1、分配物件記憶體空間
  • instance = memory; // 3、設定instance指向剛剛分配的記憶體地址,此時instance != null,但是物件還沒有初始化完成
  • instance(memory); // 2、初始化物件
    這樣就會造成什麼問題呢?

也就是當我們執行到重排後的步驟2,試圖獲取instance的時候,會得到null,因為物件的初始化還沒有完成,而是在重排後的步驟3才完成,因此執行單例模式的程式碼時候,就會重新在建立一個instance例項.

指令重排只會保證序列語義的執行一致性(單執行緒),但並不會關係多執行緒間的語義一致性

所以當一條執行緒訪問instance不為null時,由於instance例項未必已初始化完成,這就造成了執行緒安全的問題。所以需要引入volatile,來保證出現指令重排的問題,從而保證單例模式的執行緒安全性。

private static volatile SingletonDemo instance = null;

雙端檢鎖機制完整版程式碼:

public class SingletonDemo {

    private static volatile SingletonDemo instance = null;

    private SingletonDemo () {
        System.out.println(Thread.currentThread().getName() + "\t 我是構造方法SingletonDemo");
    }

    public static SingletonDemo getInstance() {
        if(instance == null) {
            // a 雙重檢查加鎖多執行緒情況下會出現某個執行緒雖然這裡已經為空,但是另外一個執行緒已經執行到d處
            synchronized (SingletonDemo.class) //b
            { 
           //c不加volitale關鍵字的話有可能會出現尚未完全初始化就獲取到的情況。原因是記憶體模型允許無序寫入
                if(instance == null) { 
                	// d 此時才開始初始化
                    instance = new SingletonDemo();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
//        // 這裡的 == 是比較記憶體地址
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());

        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

參考 :
[1] https://gitee.com/moxi159753/LearningNotes/tree/master/%E6%A0%A1%E6%8B%9B%E9%9D%A2%E8%AF%95/JUC/1_%E8%B0%88%E8%B0%88Volatile

相關文章