瞭解什麼是可見性錯誤,爲何會發生,以及如何在併發Java應用程序中查找難以捉摸的可見性錯誤。這些問題你可能也遇到過,當在優銳課學習了一段時間後,我對這些問題有了必定看法,寫下這篇文章和你們分享。java
檢測可見性錯誤的機會各不相同。在最佳狀況下,能夠在全部狀況的90%中檢測到如下可見性錯誤。在最壞的狀況下,檢測錯誤的機會低於百萬分之一。git
可是首先,什麼是可見性錯誤?github
當線程讀取陳舊值時,會發生可見性錯誤。在如下示例中,一個線程向另外一個線程發出信號以中止其while循環的處理:緩存
1 public class Termination { 2 private int v; 3 public void runTest() throws InterruptedException { 4 Thread workerThread = new Thread( () -> { 5 while(v == 0) { 6 // spin 7 } 8 }); 9 workerThread.start(); 10 v = 1; 11 workerThread.join(); // test might hang up here 12 } 13 public static void main(String[] args) throws InterruptedException { 14 for(int i = 0 ; i < 1000 ; i++) { 15 new Termination().runTest(); 16 } 17 } 18 }
錯誤是工做線程可能永遠不會看到變量v的更新,所以將永遠運行。併發
讀取過期的值的緣由之一是CPU內核的緩存。現代CPU的每一個內核都有本身的緩存。所以,若是讀取和寫入線程在不一樣的內核上運行,則讀取線程將看到緩存的值,而不是寫入線程寫入的值。 下面顯示了超級用戶答案給出的Intel Pentium 4 CPU內部的內核和緩存:工具
Intel Pentium 4 CPU的每一個核心都有本身的1級和2級緩存。全部內核共享一個大的3級緩存。這些緩存的緣由是性能。下列數字顯示了訪問內存所需的時間,摘自《計算機體系結構,一種定量方法》,JL Hennessy,DA Patterson,第5版,第72頁:性能
讀取和寫入普通字段不會使高速緩存無效,所以,若是不一樣內核上的兩個線程讀取和寫入同一變量,則它們將看到陳舊的值。讓咱們看看是否能夠重現此錯誤。學習
若是你運行了上面的示例,則頗有可能該測試沒法掛斷。該測試只須要不多的CPU週期,所以兩個線程一般都在同一內核上運行,而且當兩個線程在同一內核上運行時,它們將讀取和寫入同一緩存。幸運的是,OpenJDK提供了jcstress工具,能夠幫助進行這種類型的測試。jcstress使用多種技巧,以便測試的線程在不一樣的內核上運行。這裏,上面的示例被重寫爲jcstress測試:測試
1 @JCStressTest(Mode.Termination) 2 @Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE, desc = "Gracefully finished.") 3 @Outcome(id = "STALE", expect = Expect.ACCEPTABLE_INTERESTING, desc = "Test hung up.") 4 @State 5 public class APISample_03_Termination { 6 int v; 7 @Actor 8 public void actor1() { 9 while (v == 0) { 10 // spin 11 } 12 } 13 @Signal 14 public void signal() { 15 v = 1; 16 } 17 }
此測試來自jcstress示例。經過使用註解@JCStressTest
對該類進行註解,咱們告訴jcstress此類是jcstress測試。jcstress在單獨的線程中運行以@Actor
和@Signal
註釋的方法。jcstress首先啓動actor線程,而後運行信號線程。若是測試在合理的時間內退出,則jcstress記錄"TERMINATED"結果;不然,結果爲"STALE."google
jcstress使用不一樣的JVM參數屢次運行測試用例。這是在個人開發機器(使用測試模式壓力的Intel i5 4核CPU)上進行此測試的結果。
對於JVM參數-XX:-TieredCompilation,在全部狀況下90%都掛起線程,可是對於JVM flags -XX:TieredStopAtLevel=1 and -Xint,該線程在全部運行中終止。
在確認咱們的示例確實包含一個錯誤以後,咱們如何解決它?
Java有專門的指令,可確保線程始終看到最新的寫入值。易失性字段修飾符就是這樣的一條指令。讀取易失性字段時,能夠確保線程看到最後寫入的值。該保證不只適用於字段的值,並且適用於在寫入volatile
變量以前由寫入線程寫入的全部值。從以上示例中,將字段修飾符volatile添加到字段v中,能夠確保while循環始終終止,即便在使用jcstress的測試中運行也是如此。
1 public class Termination { 2 volatile int v; 3 // methods omitted 4 }
volatile
字段修飾符不是給出此類可見性保證的惟一指令。例如,包java.util.concurrent中的synced語句和類提供相同的保證。Brian Goetz等人撰寫的《Java Concurrency in Practice》一書很好地瞭解了避免可見性錯誤的技術。
在瞭解了可見性錯誤發生的緣由以及如何重現和避免它們以後,讓咱們看一下如何查找它們。
Java語言規範第17章。線程和鎖正式定義了Java指令的可見性保證。該規範定義了所謂的「先發生」關係來定義可見性保證:
「兩個動做能夠經過在發生以前的關係進行排序。若是一個動做在另外一個發生以前,則第一個對第二個可見而且在第二個以前進行排序。」
讀取和寫入易失性字段會建立這樣的事前關聯:
「在每次對該字段進行後續讀取以前,都會對易失字段(第8.3.1.4節)進行寫操做。」
使用此規範,咱們能夠檢查程序是否包含可見性錯誤,在規範中稱爲「數據爭用」。
「當程序包含兩個衝突訪問(第17.4.1節)時,它們之間沒有按事前發生的關係排序,則該程序被稱爲包含數據競爭。對同一變量的兩次訪問(讀或寫)被稱爲:若是至少有一個訪問是寫操做,則衝突。」
在咱們的示例中,咱們看到對共享變量v的讀取和寫入之間沒有「先發生後」關係,所以該示例包含根據規範的數據競爭。
固然,這種推理能夠自動化。如下兩個工具使用此規則自動檢測可見性錯誤: