Volatile:JVM 我警告你,我的人你別亂動

語言: CN / TW / HK

Volatile 算是一個面試中的高頻問題了。我們都知道 Volatile 有兩個作用:

  1. 禁止指令重排
  2. 保證記憶體可見

指令重排序

指令重排序的問題,基本上都是通過 DCL 問題來考察。

DCL,Double Check Look

面試中通常會是下面這種情景:

面試官:用過單例嗎?

你:用過。

面試官:如何實現一個執行緒安全的懶漢式單例

你:DCL。

面試官:DCL 可以保證執行緒絕對安全嗎?

你:加 Volatile。

面試官滿意的點點頭。通常情況下,面試中這個問題聊到這裡也就結束了。

但這個問題,還有一些可挖掘的內容。我們順著單例的程式碼繼續往下挖:

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

如果不加 Volatile,會有什麼問題呢?問題就出現在下面這行程式碼:

instance = new Singleton();

上面這行程式碼看起來也平平無奇呀,就是一個賦值操作,還能整什麼么蛾子呢?我們只寫了一行程式碼,但 JVM 則需要做好幾步操作。那 JVM 究竟幹了啥呢?大概也許可能差不多就是把大象給放冰箱裡了。

Java 程式碼中的一條賦值語句,到了 JVM 指令層面大概分三步:

  1. 分配一塊記憶體空間
  2. 初始化
  3. 返回記憶體地址

下面通過位元組碼來一探究竟,為了簡化問題,我們替換成下面的程式碼:

Object o = new Object();

編譯以後,通過 javap -v 命令,或者 IDEA 中的 JClassLib 外掛可以看到如下圖所示的內容:

通過上面的位元組碼資訊,可以更加清楚的看到上面提到的那三個步驟:

  1. new 用來分配一塊記憶體空間
  2. invokspecial 呼叫了 Object 的 init() 方法,做了初始化
  3. astore_1 就是將 o 指向了 Object 例項物件的記憶體地址,完成賦值

dup 指令會做一些入棧操作,跟我們要討論的問題關係不大,這裡可以先忽略。

到這裡,問題就比較明瞭了。重排的問題會發生在第 2 和 3 步。因為先初始化還是先把物件的記憶體地址賦值給 o,並沒有必然的前後制約關係。因此,這類的指令在某些情況下會被重排序。

單執行緒下,這種重排序完全沒有問題。但是多執行緒的場景下,就有可能出問題:A 執行緒進入到 instance = new Singleton(); 後,由於指令重排,在 init 之前,將地址給了 o。此時 B 執行緒來了,發現 instance 不為 null,於是直接拿去用了,然而此時 instance 並沒有初始化,只是個半成品。所以,當 B 拿到 instance 進行操作的時候就會出現問題了。

因此,instance 需要使用 volatile 來修飾,從而禁止進行指令重排。

到這裡,你可能要說了,我用單例不加 volatile,這麼長時間了也沒遇到你說的重排序問題。你怎麼證明「重排序」的存在呢?好問題,下面咱們通過一個小例子來驗證一下重排序是否真的存在。

private static int x = 0;private static int y = 0;private static int a = 0;private static int b = 0;public static void main(String[] args) throws InterruptedException {    int i = 0;    while (true) {        i++;        x = 0; y = 0;        a = 0; b = 0;                Thread one = new Thread(() -> {            a = 1;            x = b;        });        Thread two = new Thread(() -> {            b = 1;            y = a;        });                one.start();        two.start();        one.join();        two.join();        if(x == 0 && y == 0) {            log.info("第 {} 次,x = {}, y = {}", i, x, y);            break;        }    }}

程式碼很簡單,就是幾個賦值操作,但卻很巧妙。x、y、a、b 初始都為 0,兩個執行緒分別給 a、x 和 b、y 賦值,執行緒 one 先讓 a = 1,然後再讓 x = b;two 執行緒先讓 b = 1,然後再讓 y = a。

假如不發生重排序,那麼以上程式只會有下面六種可能:

每一列,從上到下代表程式碼執行的順序。

也就是說,在沒有重排序的情況下,不可能出現 x、y 同時為 0 的情況。而如果 x、y 同時為 0 了,那麼一定是出現了下面六種情況中的一種,既發生了重排。

每一列,從上到下代表程式碼執行的順序。

執行程式,經過漫長的等待,得到了如下的輸出:

可以看到,在執行了五十多萬次以後,我們終於捕捉到了一次重排序。發生這種情況的機率很低,所以你就算沒有用 volatile 大概率不會有問題,但我們在今後還是要合理的使用 volatile。

記憶體可見性

聊完指令重排,接下來聊聊記憶體可見。這次我們直接上程式碼:

private static boolean flag = true;private static void justRun() {    System.out.println("Thread One Start");    while (flag) {}    System.out.println("Thread One End");}public static void main(String[] args) throws InterruptedException {    new Thread(() -> justRun(), "Thread One").start();    TimeUnit.SECONDS.sleep(1);    flag = false;}

程式碼很簡單,主執行緒內開啟一個子執行緒,子執行緒中一個 while 迴圈,當 flag 為 false 時,結束迴圈。flag 初始值為 true,一秒鐘後,被主執行緒設定為 false。

按照上面這個邏輯,子執行緒應該會在程式啟動一秒後停止。然而,當你執行程式後會發現,這個程式就像吃了炫邁一樣,根本停不下來。

這說明主執行緒對 flag 的修改,子執行緒並沒有感知到。我們修改一下程式:

private static volatile boolean flag = true;

為 flag 加上 volatile 修飾符,再次執行,你會發現程式執行後,很快(大概一秒鐘)就停止了。這是為啥?是炫邁的藥勁兒過了嗎?

哈哈,當然不是。為了更好的效能,執行緒都有自己的快取(CPU 中的快取記憶體),我們稱之為工作記憶體或者本地記憶體。還有一塊公共記憶體,我們叫它主從吧。它們的結構大致如下圖所示:

主存中定義了一個 flag 變數,每個執行緒讀取它的時候,為了更好的效能會線上程本地快取一份它的副本。讀取的時候也是優先讀取本地副本的值。當 flag 被 volatile 修飾後,每次被修改,都會讓其他執行緒中的副本失效,從而必須去主存中讀取最新的值。所以,在使用了 volatile 後,子執行緒能夠立即感知到 flag 的變化,從而停止。

上圖簡化了執行緒(CPU)的快取結構,其完整結構如下圖所示:

現代 CPU 共有三級快取,分別為:L1、L2 和 L3。CPU 中的每個核心都有自己的 L1 和 L2,而一顆 CPU 中的多個核心會共享 L3。

總結

Volatile 的意思是,易變的,動盪不定的,反覆無常的。volatile 的作用就是告訴 JVM,被我修飾的變數它非常善變,你要給我盯好了,一旦有風吹草動要立馬通知大家;另外,你不要自作聰明的調整它的位置(為了效能重排序),它可是說翻臉就翻臉的主兒。

最後,留一個小問題:記憶體可見性的那個程式中,就算 flag 沒有被 volatile 修飾,執行緒頂多不是第一時間讀到 flag 的修改,但也不應該一直讀不到呀,這是為啥?這太反直覺了!