關於volatile與指令重排序的探討

JavaBuild發表於2024-03-20

寫在開頭

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

那何為有序性呢?我們可以通俗理解為:程式執行的順序要按照程式碼的先後順序。 當然,之前我們還說過發生有序性問題時,我們可以透過給變數新增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

相關文章