1. 程式人生 > >執行緒基礎:多工處理(18)——MESI協議以及帶來的問題:volatile關鍵字

執行緒基礎:多工處理(18)——MESI協議以及帶來的問題:volatile關鍵字

=====================
(接上文《執行緒基礎:多工處理(18)——MESI協議以及帶來的問題:偽共享》)

4、volatile關鍵字及其使用

4.1、volatile關鍵字使用場景

volatile關鍵字有以下幾大場景:

  • 用於多執行緒狀態下只需要保證執行緒間可見性的場景,如果被多執行緒通知操作的資源需要具有原子性那麼還是要使用synchronized或者lock()相關方法來實現。在實際應用中,這樣的場景就是多個執行緒同時共享一個標記且這個標記不會實際參與多執行緒的業務處理過程——例如上文中的示例。

  • 用於避免指令重排的場景,指令重排的知識點比較複雜。在JVM虛擬機器層面上的指令重排可以較簡單的看作前者為了提高CPU執行效率,對已經編譯好的語句順序進行重新排序。當然重新排序的前提是,不會影響單執行緒情況下這些語句的執行結果。

4.2、主存一致性(執行緒可見性)場景

Java中的鎖機制(無論是悲觀鎖原理還是樂觀鎖原理)保證了執行過程的原子性,從表現上來說就是共享資源的執行緒安全性。但有的時候我們並不需要在多個執行緒間共享的資源具有執行緒安全性,請看如下程式碼塊:

// ......
private static boolean flag = false;
// ......
public static void mian(String[] args) {
  // ......
  for(int index = 0 ; index < 5 ; index++) {
    Thread MyThread =
new Thread(() -> { while(true) { if(flag) { return; } // do something } }); // 這5個執行緒就在做一些業務,等待flag資訊退出 MyThread.start(); } for(int index = 0 ; index < 5 ; index++) { Thread YourThread = new Thread(() ->{ long startTime = System.
currentTimeMillis(); long currentTime = startTime; while(true) { if(++currentTime - startTime > 1000000000l ) { flag = true; return; } if(flag) { return; } } }); // 這5個執行緒就一直在執行,各做各的計數,計算次數也不一定相同 // 只要有一個執行緒計算得出的條件成立,就變更flag標記 YourThread.start(); } // ...... } // ......

以上程式碼中的flag變數,只是標記當前程序滿足了某種條件,程序中的多個執行緒可以依據這個條件在第一時間進行退出。從以上程式碼片段可以看出,這個flag變數實際上並沒有參與執行緒中的任何計算過程——即使有多個執行緒同時搶佔到了flag的操作權,也沒有關係,因為flag無非就是一個標記。

但是另一個方面,我們又需要保持flag變數的多執行緒可見性。試想一下如果某一個執行緒將flag標記變更為true,但是其它執行緒在一個非常短的瞬間並沒有看到這個flag變數的變化,究其原因是因為執行緒更改的flag變數資訊只停留在CPU的快取中,還沒有被回寫到主存(詳細原因介紹請參見上文)。這個情況就會導致雖然flag已經被變更,但是某個執行緒會多做幾次迴圈才會退出。

以上情況顯然是我們不希望看見的,這個時候就需要使用volatile關鍵字,讓flag變數的更新被直反應到主存上,而其它執行緒也直接到主存上讀取flag變數的最新值。程式碼調整如下所示:

// ......這裡增加了volatile 
private volatile static boolean flag = false;
// ......
public static void mian(String[] args) {
  // ......
  // 其它程式碼一致
  // ......
} 
// ......

這時候只要flag變數的值發生變化,所有執行緒都會立即觀察到,並立即在正確的時間點退出迴圈,不會出現“多迴圈”幾次的情況。

4.3、避免指令重排場景

所謂指令重排是指,CPU和編譯器為了提升程式執行的效率,按照一定的規則允許進行指令優化。但是,在某些情況下這種優化會帶來一些執行的邏輯問題,主要的原因是程式碼邏輯之間是存在一定的先後順序。在併發執行情況下會發生二義性,即按照不同的執行邏輯會得到不同的結果資訊。

以上概念放到具體的Java環境下,Java編譯器會進行指令重排,但前提是不論怎麼重排序,編譯器都保證執行結果在單執行緒執行的情況下不被改變;同時也保證滿足as-if-serial語義規範,請看以下程式碼:

所謂as-if-serial語義規範可以簡單理解為,一段將要執行的程式其前後語義的依賴結構不會在指令重排後受到影響,但實際細節更復雜,這裡就不再擴充套件開講)

// ......
float a = 100;
float b = 1000;
float c = a * b * b;
// ......

以上程式碼片段中,變數c依賴於變數a和變數b,但是變數a和變數b之間並沒有依賴關係。也就是說在單執行緒執行環境下,無論編譯器按照原義將變數a放在變數b之前,還是編譯器按照優化後的順序將變數b的初始化放到變數a之前,其執行結果都不會對變數c的最終值產生異響。但是再多執行緒情況,就會出現一些意想不到的效果,請看以下簡單示例(例子是網上找的一個):

線上程A中:

// ......
context = loadContext();
inited = true;
// ......

線上程B中:

// ......
//根據執行緒A中對inited變數的修改決定是否使用context變數
while(!inited ){
   sleep(100);
}
doSomethingwithconfig(context);
// ......

如果執行緒A中發生了指令重排序(有一定機率),那麼B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發程式錯誤。這時,我們使用volatile關鍵字對inited變數進行修飾,就可以避免指令重排。