Java 多線程 深入理解volatile語義

1、解決可見性問題

CPU爲了避免頻繁讀內存導致的性能降低,所以CPU內部設計了寄存器和高速緩存來提供數據訪問速度。

1、線程重複讀取一個變量時,會使用緩存中的值,而不會讀內存,所以存在讀提前。

2、線程首次從內存讀取某個變量的同時會緩存附近的數據,所以存在讀提前。

3、線程寫變量時,會先寫入CPU緩存,然後異步刷新到內存,所以存在寫延遲。

因爲讀提前,所以當線程讀取某個變量時,可能並不是從內存中讀取,而是來自CPU緩存,那麼不同的線程中見到的變量可能因爲加載到緩存中的時間不同而不同;

因爲寫延遲,多線程中先寫的不一定能被後讀的線程見到,導致讀到的仍然是舊值。

(線程切換和可見性沒關係,因爲線程切換前保存上下文,重新執行前會加載d,所以單線程在多核CPU上也不會有可見性問題)。

爲解決可見性問題,java從1.0便提供了volatile關鍵字,對於volatile變量禁用CPU緩存,這樣線程每次讀寫volatile變量時都會直接讀寫內存中的值。

2、解決重排序問題

重排序舉例

我們編寫的代碼和CPU實際運行的指令的順序可能是不同的,這是因爲編譯器會重排序來提高性能,默認情況下編譯器保證重排序之後對於單線程無影響,但是不保證多線程情況下不出錯,比如以下程序:

一個線程依次更新a b c 三個變量,而另一個線程檢測3個變量是否按順序設置的

public static void main(String[] args) {
    	//重複多次,因爲很可能不會重排序又或者重排序未被發現
        for (int i = 0; i < 100_000; i++) {
            final State state = new State();
            // Write values
            new Thread(() -> {
                state.a = 1; //step1
                state.b = 1; //step2
                state.c = 1; //step3
            }).start();
            // Read values
            new Thread(() -> {
                //倒序讀,降低因讀的先後順序影響測試結果的概率,當然這裏也有可能重排序
                //但是無論是寫語句重排序還是讀操作重排序,只要打印ERROR都可驗證重排序
                int tmpC = state.c; //step4
                int tmpB = state.b; //step5
                int tmpA = state.a; //step6
                if (tmpB == 1 && tmpA == 0) {
                    System.out.println("ERROR!! b == 1 && a == 0");
                    System.exit(-1);
                }
                if (tmpC == 1 && tmpB == 0) {
                    System.out.println("ERROR!! c == 1 && b == 0");
                    System.exit(-1);
                }
                if (tmpC == 1 && tmpA == 0) {
                    System.out.println("ERROR!! c == 1 && a == 0");
                    System.exit(-1);
                }
            }).start();
        }
        System.out.println("Done");
    }

    static class State {
        int a = 0;
        int b = 0;
        int c = 0;
    }
重複執行100次

#!/bin/bash

$(cat /dev/null > stdout.log)

for ((i=1; i<=100; i++))
do
  echo "exec no $i"
  java Test >> stdout.log
done
k8snode12@/tmp/test>cat /proc/cpuinfo | grep 'physical id'| wc -l
16

在16核Linux操作系統中執行該程序100次,根據stdout統計結果如下:

測試結果 出現次數 含義
全部執行完成,打印Done 32 未發現重排序
打印 ERROR!! b == 1 && a == 0 0 可能step2先於step1
打印 ERROR!! c == 1 && b == 0 68 可能step3先於step2
打印 ERROR!! c == 1 && a == 0 0 可能step3先於step1

說明一定d發生了重排序,在java1.5以前即使給變量c增加volatile標記,仍可能發生類似的重排序,但是1.5及其之後便不會有該問題,給State類的變量c增加volatile標記後,基於java1.8執行結果如下:

測試結果 出現次數 含義
全部執行完成,打印Done 91 未發現重排序
打印 ERROR!! b == 1 && a == 0 9 可能step2先於step1
打印 ERROR!! c == 1 && b == 0 0 可能step3先於step2
打印 ERROR!! c == 1 && a == 0 0 可能step3先於step1

通過以上測試說明volatile標記成功禁止了step2和step3重排序,但是沒有禁止step1和step2的重排序,繼續給State類的變量b增加volatile標記後測試結果如下:

測試結果 出現次數 含義
全部執行完成,打印Done 100 未發現重排序
打印 ERROR!! b == 1 && a == 0 0 可能step2先於step1
打印 ERROR!! c == 1 && b == 0 0 可能step3先於step2
打印 ERROR!! c == 1 && a == 0 0 可能step3先於step1

通過以上測試說明volatile標記成功禁止了step1和step2重排序。

禁止重排序的三條規則

1.前面的任意操作不能重排爲volatile寫之後執行。
 2.後面的任意操作不能重排爲volatile讀之前執行。
 3.當第一個操作是volatile寫時,第二個操作是volatile讀時,不能進行重排序。

(volatile寫後面的無數據依賴操作可能提前執行,volatile讀前面的無數據依賴操作可能延後執行。)

禁重排序的原因

因爲對於共享變量來說,常常在真正執行寫指令之前需要一些初始化工作,而且這些初始化指令和寫指令之間可能並沒有數據依賴關係(如果有,則任意變量都不能被重排序),因此可能發生重排序,導致寫指令提前執行了,其他程序因此可能讀到錯誤的值。與此相對的,寫操作之後的無數據依賴的操作可提前執行,因爲按照程序定義一般不會把初始化操作故意放後邊,即使真的放後面了,提前初始化也不會造成錯誤。

典型場景就是雙重檢查實現懶漢式單例模式時,未對單例變量聲明volatile,代碼如下:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假設有兩個線程 A、B 同時調用 getInstance() 方法,他們會同時發現 instance == null ,於是同時對 Singleton.class 加鎖,此時 JVM 保證只有一個線程能夠加鎖成功(假設是線程 A),另外一個線程則會處於等待狀態(假設是線程 B);線程 A 會創建一個 Singleton 實例,之後釋放鎖,鎖釋放後,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功後,線程 B 檢查 instance == null 時會發現,已經創建過 Singleton 實例了,所以線程 B 不會再創建一個 Singleton 實例。這看上去一切都很完美,無懈可擊,但實際上這個 getInstance() 方法並不完美。問題出在哪裏呢?出在 new 操作上,我們以爲的 new 操作應該是:

1、分配一塊內存 M,內存地址爲address;

2、在地址爲address的內存上初始化 Singleton 對象;

3、然後address賦值給 instance 變量。

但是實際上優化後的執行路徑卻是這樣的(因爲23步之間沒有數據依賴性,所以可能被重排序,但是都依賴第1步,因此第1步始終先執行):

1、分配一塊內存 M,內存地址爲address;

2、然後address賦值給 instance 變量;

3、在地址爲address的內存上初始化 Singleton 對象。

優化後會導致什麼問題呢?我們假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了線程切換,切換到了線程 B 上,但是此時instance已經被複制即不等於null了;如果此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null ,所以會直接返回 instance,而此時的 instance 是沒有初始化過的,如果線程B繼續訪問 instance 的成員變量就可能觸發空指針異常。

volatile禁止重排序採用悲觀策略。

禁止重排序的直觀作用

在java1.5以前,有如下兩個線程:

在這裏插入圖片描述

假設在step3晚於step4的前提下來分析,雖然tempC一定等於2,但是tempB和tempA都可能等於0,因爲step5和step6可能在step4之前執行,step3可能在step2之前執行,即java允許這些重排序行爲,換句話說java並不保證:

  • step1 happens before step6
  • step2 happens before step5

爲了能夠讓多線程按指定順序執行,爲了禁止重排序帶來的順序問題,java在1.5對volatile關鍵字進行了增強,即增加了一條happens before規則:volatile寫 happens before volatile讀,單獨看這條規則其實沒啥意義,因爲對volitile變量的寫對的volatile的讀操作必然是可見的,這就是volatile的原始語義,但是java1.5之後這條規則不再是單一規則,而是一條happens before規則了!那麼上升到happens before規則有什麼作用呢?

  1. happens before 具有傳遞性!
  2. 單線程中前面的操作happens-before後續的操作。
  3. volatile寫 happens before volatile讀

因爲:

  • step 2 happens before step 3 (規則2)
  • step 3 happens before step 4 (規則3)
  • step 3 happens before step 5 (規則2)

所以根據規則1可知 step 2 happens before step 5 ,同理可得 step 1 happens before step 6

讓程序看起來如下圖一樣按順序執行:

在這裏插入圖片描述

happens before規則保證了可見性和有序性,使得多線程可以像單線程一樣順序執行!

禁止重排序實現原理

// 通過插入內存屏障

3、不能解決原子性問題

實際上happens before 規則和原子性也無關,原子性的操作在執行過程中不會發生線程切換,java不保證對volatile的整個操作過程中不發生線程切換,所以volatile無法解決i++的問題,原子性問題只能通過互斥鎖或者CAS來解決。

public class Test {
    static volatile int counter = 0;
    public static void main(String[] args) throws Exception {
        List<Thread> threads = new LinkedList<>();
       for (int i = 0; i< 10000;i++){
           Thread thread = new MyThread();
           thread.setName("thread " + i);
           threads.add(thread);
       }
       threads.forEach(t-> t.start());
        System.out.println(counter);
    }
    private static class MyThread extends Thread{
        @Override
        public void run() {
            counter +=1;
        }
    }
}

public class Test {
    static volatile int counter = 0;
    public static void main(String[] args) throws Exception {
        List<Thread> threads = new LinkedList<>();
       for (int i = 0; i< 10000;i++){
           Thread thread = new MyThread();
           thread.setName("thread " + i);
           threads.add(thread);
       }
       threads.forEach(t-> t.start());
        System.out.println(counter);
    }
    private static class MyThread extends Thread{
        @Override
        public void run() {
            counter +=1;
        }
    }
}

public class Test {
    static volatile int counter = 0;
    public static void main(String[] args) throws Exception {
        List<Thread> threads = new LinkedList<>();
       for (int i = 0; i< 10000;i++){
           Thread thread = new MyThread();
           thread.setName("thread " + i);
           threads.add(thread);
       }
       threads.forEach(t-> t.start());
       threads.forEach(t-> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
       System.out.println(counter);
    }
    private static class MyThread extends Thread{
        @Override
        public void run() {
            counter +=1;
        }
    }
}

4、提煉volatile的本質作用

前文分析了volatile可以解決多線程的可見性和有序性問題,篇幅很長,這裏我們高屋建瓴的總結一下volatile的使用場景!

volatile 關鍵字並不是 Java 語言的特產,古老的 C 語言裏也有,它最原始的意義就是禁用 CPU 緩存。

例如,我們聲明一個 volatile 變量 volatile int x = 0,它表達的是:告訴編譯器,對這個變量的讀寫,不能使用 CPU 緩存,必須從內存中讀取或者寫入。這個語義看上去相當明確,但是在實際使用的時候卻會帶來困惑。

例如下面的示例代碼,假設線程 A 執行 writer() 方法,按照 volatile 語義,會把變量 “v=true” 寫入內存;假設線程 B 執行 reader() 方法,同樣按照 volatile 語義,線程 B 會從內存中讀取變量 v,如果線程 B 看到 “v == true” 時,那麼線程 B 看到的變量 x 是多少呢?直覺上看,應該是 42,那實際應該是多少呢?這個要看 Java 的版本,如果在低於 1.5 版本上運行,x 可能是 42,也有可能是 0;如果在 1.5 以上的版本上運行,x 就是等於 42。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;  //step1
    v = true; //step2
  }
  public void reader() {
    if (v == true) { //step3
      // 這裏x會是多少呢?
    }
  }
}

分析一下,爲什麼 1.5 以前的版本會出現 x = 0 的情況呢?

我相信你一定想到了,變量 x 可能被 CPU 緩存而導致可見性問題,另外也可能因爲step1和step2被重排序了

這個問題在 1.5 版本已經被圓滿解決了。Java 內存模型在 1.5 版本對 volatile 語義進行了增強。怎麼增強的呢?答案是新增了一項 Happens-Before 規則。volatile就是解決以上這種不確定性的方法之一,x一定是42

5、Happens-Before規則(只列和volatile有關的)

  • 程序的順序性規則

    這條規則是指在一個線程中,按照程序順序,前面的操作 Happens-Before 於後續的任意操作。這還是比較容易理解的,比如剛纔那段示例代碼,按照程序的順序,第 6 行代碼 “x = 42;” Happens-Before 於第 7 行代碼 “v = true;”,這就是規則 1 的內容,也比較符合單線程裏面的思維:程序前面對某個變量的修改一定是對後續操作可見的。

  • volatile 變量規則

    這條規則是指對一個 volatile 變量的寫操作, Happens-Before 於後續對這個 volatile 變量的讀操作。這個就有點費解了,對一個 volatile 變量的寫操作相對於後續對這個 volatile 變量的讀操作可見,這怎麼看都是禁用緩存的意思啊,貌似和 1.5 版本以前的語義沒有變化啊?如果單看這個規則,的確是這樣,但是如果我們關聯一下規則 3,就有點不一樣的感覺了。

  • 傳遞性

    這條規則是指如果 A Happens-Before B,且 B Happens-Before C,那麼 A Happens-Before C。我們將規則 3 的傳遞性應用到我們的例子中,會發生什麼呢?可以看下面這幅圖:

在這裏插入圖片描述
從圖中,我們可以看到:

1、“x=42” Happens-Before 寫變量 “v=true” ,這是規則 1 的內容;

2、寫變量“v=true” Happens-Before 讀變量 “v=true”,這是規則 2 的內容 。

再根據這個傳遞性規則,我們得到結果:“x=42” Happens-Before 讀變量“v=true”。這意味着什麼呢?

如果線程 B 讀到了“v=true”,那麼線程 A 設置的“x=42”對線程 B 是可見的。也就是說,線程 B 能看到 “x == 42” ,有沒有一種恍然大悟的感覺?這就是 1.5 版本對 volatile 語義的增強.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章