為什麼我們需要volatile關鍵字?

banq發表於2019-05-25

volatile欄位以確保多個執行緒始終看到最新值,即使快取系統或編譯器最佳化正在起作用。從volatile變數讀取始終返回此變數的最新寫入值。java.uti.concurrent包中的大多數類的方法也具有此屬性。通常在內部使用volatile欄位。

關於volatile關鍵字讓我著迷的是它是必須的,因為我的軟體仍然在矽晶片上執行。即使我的應用程式在Java虛擬機器中的虛擬機器上執行在雲中。但是,儘管所有這些軟體層都抽象掉底層硬體,但由於我的軟體執行的處理器快取,仍然需要volatile關鍵字。

處理器會在每個核心快取中快取主記憶體值,這樣提高記憶體訪問效能。雖然從CPU暫存器讀取大約300皮秒,但從主儲存器讀取需要50-100納秒。透過使用快取記憶體,可以減少到大約1納秒。

現在問題是核心應該何時檢查快取的值是否在另一個核的快取中被修改了,這是由volatile欄位註釋完成的。透過將欄位宣告為volatile,我們告訴JVM,當執行緒讀取volatile欄位時,我們希望看到最新的寫入值。JVM使用特殊指令告訴CPU它應該同步其快取。對於x86處理器系列,這些指令稱為記憶體屏障,如此處所述

處理器不僅可以同步volatile欄位的值,還可以同步整個快取。因此,如果我們從volatile欄位讀取,我們會看到其他核心上的所有寫入此變數以及寫入volatile變數之前寫入這些核心的值。

測試
現在讓我們看看它在實踐中是如何運作的。讓我們看看當我們使用沒有volatile註釋的欄位時我們是否讀取過時的值:

public class Termination {
   private int v;
   public void runTest() throws InterruptedException   {
       Thread workerThread = new Thread( () -> { 
           while(v == 0) {
               // spin
           }
       });
       workerThread.start();
       v = 1;
       workerThread.join();  // test might hang up here 
   }
 public static void main(String[] args)  throws InterruptedException {
       for(int i = 0 ; i < 1000 ; i++) {
           new Termination().runTest();
       }
   }    
}

當在一個核中寫入執行緒實現更新欄位v,同時讀取執行緒在另一個執行緒中讀取欄位v時,測試時應該掛起並永遠執行。但至少當我在我的機器上執行測試時,測試永遠不會掛起。原因是測試需要很少的CPU週期,兩個執行緒通常在同一個核心上執行。當兩個執行緒在同一個核心上執行時,它們會讀取並寫入同一個快取。

幸運的是,OpenJDK提供了一個工具jcstress,它可以幫助進行這類測試。jcstress使用多個技巧,測試的執行緒在不同的核心上執行。這裡上面的例子被重寫為jcstress測試:

@JCStressTest(Mode.Termination)
@Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE, desc = "Gracefully finished.")
@Outcome(id = "STALE", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Test hung up.")
@State
public class APISample_03_Termination {
    int v;
    @Actor
    public void actor1() {
        while (v == 0) {
            // spin
        }
    }
    @Signal
    public void signal() {
        v = 1;
    }
}

此測試來自jcstress示例。透過使用註釋@JCStressTest註釋類,我們告訴jcstress這個類是一個jcstress測試。jcstress在一個單獨的執行緒中執行用@Actor和@Signal註釋的方法。jcstress首先啟動actor執行緒,然後執行訊號執行緒。如果測試在合理的時間內退出,jcstress會記錄“TERMINATED”結果,否則結果為“STALE”。

我已經在我的開發機器上執行了這個測試,一次使用普通測試,一次使用volatile欄位v。對於volatile欄位的測試看起來像這樣:

public class APISample_03_Termination {
   volatile int v;
   // methods omitted
}

jcstress使用不同的JVM引數多次執行測試用例。

測試結果表明:使用沒有volatile註釋的欄位確實會掛起執行緒。掛起執行緒的百分比取決於JVM標誌和環境,JDK版本等。

何時使用volatile欄位
volatile欄位的另一種用法是使用volatile欄位進行讀取和鎖定以進行寫入。或者您可以將它們與JDK 9 VarHandle一起使用以實現原子操作。這裡描述了如何實現這些技術。

與happens-before相關
一般情況下人們不直接使用volatile欄位。我寧願使用java.util.concurrent包中的資料結構進行併發程式設計。其中內部使用volatile欄位。

在這些類的文件中,我們經常閱讀關於記憶體一致性效果的事情,與happens-before有關:

記憶體一致性效果:happen-before非同步計算所採取的操作發生在另一個執行緒中相應的Future.get()之後。

現在,憑藉我們對volatile欄位的瞭解,我們可以解碼此文件。如果我們從volatile欄位讀取,我們會看到其他核心上的所有寫入此變數。用java.util.concurrent文件的話來說,我們會說對volatile變數的讀取會建立happen-before關係到此變數的寫入。

所以上面的語句意味著呼叫Future.get()的執行緒總是會讀取在另外一個執行緒呼叫Future介面方法的寫入的最新寫入值。

我們使用FutureTask類在兩個執行緒之間傳輸資料作為示例。FutureTask實現介面Future,因此呼叫方法FutureTask.get()總是能看到透過另一個方法(例如FutureTask.set())寫入的最新值。

用於檢測缺少的volatile註釋的工具
如果您忘記將欄位宣告為volatile,則執行緒可能會讀取過時的值。但是在測試期間看到這個的機會相當低。由於讀取和寫入必須幾乎在同一時間並且在不同的核心上發生以讀取過時值,因此這僅在重負載和長時間執行之後發生,例如在生產中。
因此,在測試執行中存在檢測此類問題的工具並不奇怪:

  • ThreadSanitizer可以檢測C ++程式中缺少的volatile註釋。有一個Java增強提議草案JEP草案:Java Thread Sanitizer將ThreadSanitizer包含在OpenJDK JVM中。這將允許我們在JVM中以及在JVM執行的Java應用程式中找到缺少的volatile註釋。
  • vmlens是我編寫的用於測試併發java的工具,它可以檢測Java測試執行中缺少的volatile註釋。

相關文章