在併發Java應用程序中檢測可見性錯誤

瞭解什麼是可見性錯誤,爲何會發生,以及如何在併發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寄存器〜300皮秒
  • 1級緩存〜1納秒
  • 主內存〜50-100納秒

讀取和寫入普通字段不會使高速緩存無效,所以,若是不一樣內核上的兩個線程讀取和寫入同一變量,則它們將看到陳舊的值。讓咱們看看是否能夠重現此錯誤。學習

 

如何重現可見性錯誤

若是你運行了上面的示例,則頗有可能該測試沒法掛斷。該測試只須要不多的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的讀取和寫入之間沒有「先發生後」關係,所以該示例包含根據規範的數據競爭。

固然,這種推理能夠自動化。如下兩個工具使用此規則自動檢測可見性錯誤:

  • ThreadSanitizer使用C ++內存模型的規則來查找C ++應用程序中的可見性錯誤。C ++內存模型由正式規則組成,用於指定C ++指令的可見性保證,相似於Java語言規範對Java指令所作的保證。有一個Java加強建議的草案,即JEP草案:Java Thread Sanitizer,將ThreadSanitizer包含在OpenJDK JVM中。 應該經過命令行標誌啓用ThreadSanitizer的使用。
  • vmlens, 是我編寫的用於測試併發Java的工具,它使用Java語言規範自動檢查Java測試運行是否包含可見性錯誤。
相關文章
相關標籤/搜索