走進volatile的世界,探索它與可見性,有序性,原子性之間的愛恨情仇!

JavaBuild發表於2024-03-18

寫在開頭

在之前的幾篇博文中,我們都提到了 volatile 關鍵字,這個單詞中文釋義為:不穩定的,易揮發的,在Java中代表變數修飾符,用來修飾會被不同執行緒訪問和修改的變數,對於方法,程式碼塊,方法引數,區域性變數以及例項常量,類常量多不能進行修飾。

自JDK1.5之後,官網對volatile進行了語義增強,這讓它在Java多執行緒領域越發重要!因此,我們今天就抽一晚上時間,來學一學這個關鍵字,首先,我們從標題入手,思考這樣的一個問題:

volatile是如何保證可見性的?又是如何禁止指令重排的,它為什麼不能實現原子性呢?

帶著疑問,我們一起走進volatile的世界,探索它與可見性,有序性,原子性之間的愛恨情仇!

volatile如何保證可見性?

volatile保證了不同執行緒對共享變數進行操作時的可見性,即一個執行緒修改了共享變數的值,共享變數修改後的值對其他執行緒立即可見。

我們先透過之前寫的一個小案例來感受一下什麼是可見性問題:

【程式碼示例1】

public class Test {
    //是否停止 變數
    private static boolean stop = false;
    public static void main(String[] args) throws InterruptedException {
        //啟動執行緒 1,當 stop 為 true,結束迴圈
        new Thread(() -> {
            System.out.println("執行緒 1 正在執行...");
            while (!stop) ;
            System.out.println("執行緒 1 終止");
        }).start();
        //休眠 1 秒
        Thread.sleep(1000);
        //啟動執行緒 2, 設定 stop = true
        new Thread(() -> {
            System.out.println("執行緒 2 正在執行...");
            stop = true;
            System.out.println("設定 stop 變數為 true.");
        }).start();
    }
}

輸出:

執行緒 1 正在執行...
執行緒 2 正在執行...
設定 stop 變數為 true.

原因:
我們會發現,執行緒1執行起來後,休眠1秒,啟動執行緒2,可即便執行緒2把stop設定為true了,執行緒1仍然沒有停止,這個就是因為 CPU 快取導致的可見性導致的問題。執行緒 2 設定 stop 變數為 true,執行緒 1 在 CPU 1上執行,讀取的 CPU 1 快取中的 stop 變數仍然為 false,執行緒 1 一直在迴圈執行。
image

那這個問題怎麼解決呢?很好解決!我們排volatile上場可以秒搞定,只需要給stop變數加上volatile修飾符即可!

【程式碼示例2】

//給stop變數增加volatile修飾符
private static volatile boolean stop = false;

輸出:

執行緒 1 正在執行...
執行緒 2 正在執行...
設定 stop 變數為 true.
執行緒 1 終止

從結果中看,執行緒1成功的讀取到了執行緒而設定為true的stop變數值,解決了可見性問題。那volatile到底是什麼讓變數在多個執行緒之間保持可見性的呢?請看下圖!
image

如果我們將變數宣告為 volatile ,這就指示 JVM,這個變數是共享且不穩定的,每次使用它都到主存中進行讀取,具體實現可總結為5步。

  • 1️⃣在生成最低成彙編指令時,對volatile修飾的共享變數寫操作增加Lock字首指令,Lock 字首的指令會引起 CPU 快取寫回記憶體;
  • 2️⃣CPU 的快取回寫到記憶體會導致其他 CPU 快取了該記憶體地址的資料無效;
  • 3️⃣volatile 變數透過快取一致性協議保證每個執行緒獲得最新值;
  • 4️⃣快取一致性協議保證每個 CPU 透過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是修改;
  • 5️⃣當 CPU 發現自己快取行對應的記憶體地址被修改,會將當前 CPU 的快取行設定成無效狀態,重新從記憶體中把資料讀到 CPU 快取。

volatile如何保證有序性?

在之前的學習我們瞭解到,為了充分利用快取,提高程式的執行速度,編譯器在底層執行的時候,會進行指令重排序的最佳化操作,但這種最佳化,在有些時候會帶來 有序性 的問題。

那何為有序性呢?我們可以通俗理解為:程式執行的順序要按照程式碼的先後順序。 當然,之前我們還說過發生有序性問題時,我們可以透過給變數新增volatile修飾符進行解決。

首先,我們來回顧一下之前寫的一個關於有序性問題的測試類。
【程式碼示例1】

int a = 1;(1)
int b = 2;(2)
int c = a + b;(3)

上面的這段程式碼中,c變數依賴a,b的值,因此,在編譯器最佳化重排時,c肯定會在a,b賦值以後執行,但a,b之間沒有依賴關係,可能會發生重排序,但這種重排序即便到了多執行緒中依舊不會存在問題,因為即便重排對執行結果也無影響。

但有些時候,指令重排序可以保證序列語義一致,但是沒有義務保證多執行緒間的語義也一致,我們繼續看下面這段程式碼:

【程式碼示例2】

public class Test {

    private static int num = 0;
    private static boolean ready = false;
    //禁止指令重排,解決順序性問題
    //private static volatile boolean ready = false;

    public static class ReadThread extends Thread {

        @Override
        public void run() {

            while (!Thread.currentThread().isInterrupted()) {
                if (ready) {//(1)
                    System.out.println(num + num);//(2)
                }
                System.out.println("讀取執行緒...");
            }
        }
    }

    public static class WriteRead extends Thread {

        @Override
        public void run() {
            num = 2;//(3)
            ready = true;//(4)
            System.out.println("賦值執行緒...");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ReadThread rt = new ReadThread();
        rt.start();

        WriteRead wr = new WriteRead();
        wr.start();

        Thread.sleep(10);
        rt.interrupt();
        System.out.println("rt stop...");
    }
}

我們定義了2個執行緒,一個用來求和操作,一個用來賦值操作,因為定義的是成員變數,所以程式碼(1)(2)(3)(4)之間不存在依賴關係,在執行時極可能發生指令重排序,如將(4)在(3)前執行,順序為(4)(1)(3)(2),這時輸出的就是0而不是4,但在很多效能比較好的電腦上,這種重排序情況不易復現。
這時,我們給ready 變數新增一個volatile關鍵字,就成功的解決問題了。

volatile關鍵字可以禁止指令重排的原因主要有兩個!

一、3 個 happens-before 規則的實現

  1. 對一個 volatile 變數的寫 happens-before 任意後續對這個 volatile 變數的讀;
  2. 一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
  3. happens-before 傳遞性,A happens-before B,B happens-before C,則 A happens-before C。

二、記憶體屏障
變數宣告為 volatile 後,在對這個變數進行讀寫操作的時候,會透過插入特定的 記憶體屏障 的方式來禁止指令重排序。

記憶體屏障(Memory Barrier 又稱記憶體柵欄,是一個 CPU 指令),為了實現volatile 記憶體語義,volatile 變數的寫操作,在變數的前面和後面分別插入記憶體屏障;volatile 變數的讀操作是在後面插入兩個記憶體屏障。

具體屏障規則:

  1. 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障;
  2. 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障;
  3. 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障;
  4. 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。

屏障說明:

  1. StoreStore:禁止之前的普通寫和之後的 volatile 寫重排序;
  2. StoreLoad:禁止之前的 volatile 寫與之後的 volatile 讀/寫重排序;
  3. LoadLoad:禁止之後所有的普通讀操作和之前的 volatile 讀重排序;
  4. LoadStore:禁止之後所有的普通寫操作和之前的 volatile 讀重排序。

OK,知道了這些內容之後,我們再回頭看程式碼示例2中,增加了volatile關鍵字後的執行順序,在賦值執行緒啟動後,執行順序會變成(3)(4)(1)(2),這時列印的結果就為4啦!

volatile為什麼不能保證原子性?

我們講完了volatile修飾符保證可見性與有序性的內容,接下來我們思考另外一個問題,它能夠保證原子性嗎?為什麼?我們依舊透過一段程式碼去證明一下!

【程式碼示例3】

public class Test {
    //計數變數
    static volatile int count = 0;
    public static void main(String[] args) throws InterruptedException {
        //執行緒 1 給 count 加 10000
        Thread t1 = new Thread(() -> {
            for (int j = 0; j <10000; j++) {
                count++;
            }
            System.out.println("thread t1 count 加 10000 結束");
        });
        //執行緒 2 給 count 加 10000
        Thread t2 = new Thread(() -> {
            for (int j = 0; j <10000; j++) {
                count++;
            }
            System.out.println("thread t2 count 加 10000 結束");
        });
        //啟動執行緒 1
        t1.start();
        //啟動執行緒 2
        t2.start();
        //等待執行緒 1 執行完成
        t1.join();
        //等待執行緒 2 執行完成
        t2.join();
        //列印 count 變數
        System.out.println(count);
    }
}

我們建立了2個執行緒,分別對count進行加10000操作,理論上最終輸出的結果應該是20000萬對吧,但實際並不是,我們看一下真實輸出。

輸出:

thread t1 count 加 10000 結束
thread t2 count 加 10000 結束
14281

原因:
Java 程式碼中 的 count++並非原子的,而是一個複合性操作,至少需要三條CPU指令:

  • 指令 1:把變數 count 從記憶體載入到CPU的暫存器
  • 指令 2:在暫存器中執行 count + 1 操作
  • 指令 3:+1 後的結果寫入CPU快取或記憶體

即使是單核的 CPU,當執行緒 1 執行到指令 1 時發生執行緒切換,執行緒 2 從記憶體中讀取 count 變數,此時執行緒 1 和執行緒 2 中的 count 變數值是相等,都執行完指令 2 和指令 3,寫入的 count 的值是相同的。從結果上看,兩個執行緒都進行了 count++,但是 count 的值只增加了 1。這種情況多發生在cpu佔用時間較長的執行緒中,若單執行緒對count僅增加100,那我們就很難遇到執行緒的切換,得出的結果也就是200啦。

要想解決也很簡單,利用 synchronized、Lock或者AtomicInteger都可以,我們在後面的文章中會聊到的,請繼續保持關注哦!

結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!

image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!

image

相關文章