深入理解 volatile 關鍵字

2022-06-29 12:00:53

volatile 關鍵字是 Java 語言的高階特性,但要弄清楚其工作原理,需要先弄懂 Java 記憶體模型。如果你之前沒了解過 Java 記憶體模型,那可以先看看之前我寫過的一篇「深入理解 Java 記憶體模型」一文。

初學 volatile 關鍵字,我們需要弄清楚它到底意味著什麼。總的來說,它有兩個含義,分別是:

  • 保證可見性
  • 禁止指令重排序

保證可見性

保證可見性指的是:當一個執行緒修改了某個變數時,其他所有執行緒都知道該變數被修改了。 由於 volatile 可以保證可見性,因此 Java 能夠保證現在在讀取 volatile 變數時,執行緒讀取到的值是準確的。但是這並不意味著對 volatile 變數的操作是執行緒安全的,因為有可能在讀取到變數之後,又有其他執行緒對變數進行修改了。

為了說明這個問題,我們可以舉個簡單地例子。下面程式碼發起了 20 個執行緒,每個執行緒對 race 變數進行 1 萬次自增操作。如果這段程式碼能夠正確並行執行,那麼最後輸出的結果應該是 20 萬。但實際上,每次輸出的結果都不一樣,都是一個小於 20 萬的數位,為什麼呢?

這是因為當執行緒在獲取到 race 變數的值,然後對其進行自增這中間,有可能其他執行緒對 race 變數做了自增操作,然後寫回了主記憶體。而當前執行緒再將資料寫回主記憶體時,就發生了資料覆蓋。因此,就發生了資料不一致的問題。

要使得 volatile 變數不發生並行安全問題,只需要遵守如下兩條規則即可:

  1. 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
  2. 變數不需要與其他的狀態變數共同參與不變約束。

第一條規則比較好理解,例如上面例子的 race 變數,其運算結果就依賴於變數的當前值,所以其並不符合第一條規則,因此就會有執行緒安全問題。但如果 race++ 變成了 race=1; 這樣的情況,那麼 race 的值就不依賴變數當前值,因此就不會有執行緒安全問題。

第二條規則有點晦澀難懂。其意思是說,變數不能和其他變數一起參與判斷,無論其他變數是否是 volatile 型別的變數。例如 if(a && b) 這個判斷就無法滿足 volatile 的第二條規則,會發生執行緒安全問題,即使這兩個變數都是 volatile 型別的變數。

關於第二條規則的描述,為啥與其他變數一起,就沒法保證執行緒安全呢?

要解答這個問題,我們不妨假設一下各種可能的場景。

我們假設變數 a b 的初始值都是 true,並且兩者都是 volatile 型別變數。

場景一:執行緒 A 執行 if(a && b) 判斷,先判斷變數 a,發現是 true,於是繼續判斷變數 b。發現變數 b 也是 true,於是整個表示式為 true。

場景二:執行緒 A 執行 if(a && b) 判斷,先判斷變數 a,發現是 true。此時執行緒 B 修改了變數 b 的值為 false。接著執行緒 A 繼續判斷變數 b 的值,發現變數 b 的值為 false。於是整體表示式的值為 false。

通過上面的例子,我們發現同樣的表示式在不同的並行場景下會有不同的結果,這很明顯就是執行緒不安全的。因為執行緒安全的程式碼,在單執行緒和多執行緒下,其結果應該是一樣的。

禁止指令重排序

指令重排序,指的是硬體層面為了加快執行速度,可能會調整指令的執行順序,從而會出現並不按程式碼順序的執行情況出現。例如下面的程式碼裡,我們初始化了 flag 變數為 false,然後再將 flag 變數置為 true。但這樣的程式碼在並行執行的時候,有可能先將 flag 職位 true,再將 flag 變為 false,從而發生執行緒安全問題。

boolean flag = false;
flag = true;

我們說 volatile 變數禁止指令重排序,其實就是指被 volatile 修飾的變數,其執行順序不能被重排序。 禁止重排序的實現,是使用了一個叫「記憶體屏障」的東西。簡單地說,記憶體屏障的作用就是指令重排序時,不能把後面的指令重排序到記憶體屏障之前的位置。

可見性的來源

我們前面說過:volatile 修飾的變數,當其被修改之後,其他變數就能立即獲取到其變化。但這個可見性的來源是哪裡呢?為什麼其能夠實現這樣的可見性呢?其實 volatile 的這些功能來源於 Java 記憶體模型中對 volatile 變數定義的特殊規則。

假定 T 表示一個執行緒,V 和 W 分別表示兩個 volatile 型變數。在 Java 記憶體模型中規定在進行 read、load、use、assign、store 和 write 操作時需要滿足如下規則:

  1. 只有當執行緒 T 對變數 V 執行的前一個動作是 load 的時候,執行緒 T 才能對變數 V 執行 use 動作。並且,只有當執行緒 T 對變數 V 執行的後一個動作是 use 的時候,執行緒 T 才能對變數 V 執行 load 動作。
  2. 只有當執行緒 T 對變數 V 執行的前一個動作是 assign 的時候,執行緒 T 才能對變數 V 執行 store 動作;並且,只有當執行緒 T 對變數V執行的後一個動作是 store 的時候,執行緒 T 才能對變數 V 執行 assign 動作。
  3. 假定動作 A 是執行緒 T 對變數 V 實施的 use 或 assign 動作,假定動作 F 是和動作 A 相關聯的 load 或 store 動作,假定動作 P 是和動作 F 相應的對變數 V 的 read 或 write 動作。類似的,假定動作 B 是執行緒 T 對變數 W 實施的 use 或 assign 動作,假定動作 G 是和動作 B 相關聯的 load 或 store 動作,假定動作 Q 是和動作 G 相應的對變數 W 的 read 或 write 動作。如果 A 先於 B,那麼 P 先於 Q。

上面三條規則有點複雜,我們來一條條講解下。

首先,我們來看看第一條規則。

只有當執行緒 T 對變數 V 執行的前一個動作是 load 的時候,執行緒 T 才能對變數 V 執行 use 動作。

load 動作,指的是把從主記憶體得到的變數值,放入到工作記憶體的變數副本。use 動作,指的是將工作記憶體的一個變數值,傳遞給執行引擎。那麼這句話合起來的意思可以理解為:要使用變數 V 之前,必須去主記憶體讀取變數 V。

並且,只有當執行緒 T 對變數 V 執行的後一個動作是 use 的時候,執行緒 T 才能對變數 V 執行 load 動作。

這句的意思可以理解為:要去讀取主記憶體的變數值放入工作記憶體的變數副本,那就必須使用它。

總的來說,這條規則的意思是:執行緒對變數 V 的 use 動作,必須與 read、load 動作連在一起,即 read -> load -> use 必須一起出現。這條規則要求在工作記憶體中,每次使用 V 前都必須先從主記憶體重新整理最新的值,用於保證能看見其他執行緒對變數V所做的修改後的值。

我們繼續看第二條規則。

只有當執行緒 T 對變數 V 執行的前一個動作是 assign 的時候,執行緒 T 才能對變數 V 執行 store 動作。

assign 動作,指的是將執行引擎的值賦值給工作記憶體的變數。store 動作,指的是將工作記憶體的一個變數傳送到主記憶體,方便後續寫回主記憶體。那麼這句話合起來的意思可以理解為:要講工作記憶體的變數寫回主記憶體,那麼必須是工作記憶體的變數收到執行引擎的賦值。

並且,只有當執行緒 T 對變數 V 執行的後一個動作是 store 的時候,執行緒 T 才能對變數 V 執行 assign 動作。

這句話的意思可以理解為:要將執行引擎接收到的值賦給工作記憶體的變數,就必須把工作記憶體變數的值寫回主記憶體。

總的來說,這條規則的意思是:執行緒對變數 V 的 assign 動作,必須與 store、write 連在一起,即:assign -> store -> write 必須一起出現。這條規則要求在工作記憶體中,每次修改 V 後都必須立刻同步回主記憶體中,用於保證其他執行緒可以看到自己對變數 V 所做的修改。

我們繼續看第三條規則。

假定動作 A 是執行緒 T 對變數 V 實施的 use 或 assign 動作,假定動作 F 是和動作 A 相關聯的 load 或 store 動作,假定動作 P 是和動作 F 相應的對變數 V 的 read 或 write 動作。

這句話意思比較簡單,use 和 assign 動作分別是從工作記憶體傳遞變數給執行引擎,以及從執行引擎傳遞變數給工作記憶體。load 和 store 動作分別是從主記憶體載入資料到工作記憶體,以及從工作記憶體寫資料到主記憶體。read 和 write 動作分別是將資料讀取到工作記憶體,以及將資料寫回主記憶體。

我們假設是一個寫入到主記憶體動作,如果這幾個組合起來,那麼就是:A -> F -> P(assign -> store -> write)。

類似的,假定動作 B 是執行緒 T 對變數 W 實施的 use 或 assign 動作,假定動作 G 是和動作 B 相關聯的 load 或 store 動作,假定動作 Q 是和動作 G 相應的對變數 W 的 read 或 write 動作。

與上面類似,如果是一個寫入到主記憶體動作,如果這幾個組合起來,那麼就是:B -> G -> Q(assign -> store -> write)。

如果 A 先於 B,那麼 P 先於 Q。

這個的意思是,如果 A 動作早於 B 動作發生,那麼 A 動作對應的 P 動作(write 動作)就要早於 Q 動作(write 動作)。

這條規則要求 volatile 修飾的變數不會被指令重排序優化,保證程式碼的執行順序與程式的順序相同。

所以說 volatile 變數的可見性以及禁止重排序的語意,其實都來源於 Java 記憶體模型裡對於 volatile 變數的定義。

總結

這篇文章,我們介紹了 volatile 的兩個語意:

  1. 可見性
  2. 禁止重排序

可見性指的是 volatile 型別的變數,其變數值一旦被修改,其他執行緒就能夠立刻感知到。而禁止重排序指的是被 volatile 修飾的變數,其執行順序不能被重排序。我們在日常使用中,如果要使 volatile 變數不發生執行緒安全問題,只需要遵守下面兩個規則即可。

  1. 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
  2. 變數不需要與其他的狀態變數共同參與不變約束。

最後,我們進一步探究了 volatile 可見性以及禁止重排序的來源,其實就是 Java 記憶體模型裡對於 volatile 變數的定義。

參考資料