併發編寫學習筆記之四—重排序

       在寫代碼時,你是否有過這樣的認識,代碼執行的執行過程就是代碼編寫的過程,畢竟debug的時候,F6(idea是F7)一直都是這樣工作的。但是實際的運行的過程可能會打破你的認知。運行環境會根據自己的意願來優化代碼的執行順序,但是不會影響程序員的開發意圖,也就是說不會影響代碼執行結果。總結來說重排序其實是指編譯器和處理器爲了優化程序性能而對指令序列進行重新排序的一種手段。舉個例子說:

a=1;
b=3;
c=a+b;

從慣性角度來看,代碼的書寫順序就是執行順序,但是真正的執行順序可能會

b=3;
a=1;
c=a+b;

這樣的執行結果並不影響程序的計算結果。

上面已經提到重排序是編譯器和處理器優化代碼的手段,所以我們也可以將重排序分爲三類

1、編譯器優化的重排序。

編譯器在不改變單線程程序的語義的前提下,可以重新安排語句的執行順序。這可能是我們唯一可見的重排序,在比對class文件的時候相信你會看見,當然只是場景之一。

2、指令級並行的重排序。

代碼最終被執行的並不是我們所編寫的代碼,而是被計算機才能熟知的計算機指令。Java作爲高級編程語言,是更容易被開發人員理解和熟悉的一種語言規範,同時也是一種高封裝的一種語言。一行java語句不一定就會轉換成一條計算機指令。

比如:

j+=j+1

這行代碼最終會被轉換成三條計算機指令在計算機上執行。現代處理器採用了指令級並行技術(Instruction-Level-Parallelism,ILP)來將多條指令重疊執行。如果不存在數據依懶性,處理器可以改變語句對應機器指令的執行順序。 

3、內存系統的重排序。

由於處理器使用緩存和讀/ 寫緩衝區,這使得加載和存儲操作看上去可能是亂執行。這一點也是比較隱蔽的,也是真正的看不見和摸不着的。

我們歸納出來從java源碼到最終實際執行的指令序列,會分別經歷下面三種重排序。後面兩種屬於處理器重排序優化。

重排序後的先甜後苦

     上面說重排序是爲了爲了優化程序性能。其實再你還沒有細細品味性能優化的甜頭的時候,重排序的苦果就端到了你的面前。無論是編譯器重排序還是處理器重排序,都會導致多線程程序出現內存可見性的問題。 來看個例子:

Class Reordering {
  int x = 0, y = 0;
  public void writer() {
    x = 1;
    y = 2;
  }

  public void reader() {
    int r1 = y;
    int r2 = x;
  }
}

 假設線程A和線程B同時執行,線程A調用writer方法,線程B調用read方法,如果b讀到y=2,那麼x讀到一定是1嗎?當然不一定!!!在writer方法中,x和y的賦值可能發生重排序,y在x之前就已經寫入了,x可能仍然是初始值0。

再看下流程圖:

實線表示線程的順序運行情況

虛線表示發生重排序的線程運行情況

在虛線的執行中,al線程xy發生了重排序,在執行了y的寫入後,發生了線程切換,A線程放棄了CPU的佔有權,B線程獲取了CPU的佔有權,當執讀到y時,顯示值爲2,而x並未發生寫入操作,所以此時的值仍爲1。(併發場景比較多,案例並未考慮緩存一致性等問題,只是爲了說明重排序的場景)。

再來看一個更經典的例子:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

        從代碼層看這段代碼確實看不出什麼問題,但是到了多線程的場景下就不一樣了。而問題就發生在 instance = new Singleton();假設有兩個線程 A、B 同時調用 getInstance() 方法,他們會同時發現 instance == null ,於是同時對 Singleton.class 加鎖,此時 JVM 保證只有一個線程能夠加鎖成功(假設是線程 A),另外一個線程則會處於等待狀態(假設是線程 B);線程 A 會創建一個 Singleton 實例,之後釋放鎖,鎖釋放後,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功後,線程 B 檢查 instance == null 時會發現,已經創建過 Singleton 實例了,所以線程 B 不會再創建一個 Singleton 實例。

        這看上去一切都很完美,無懈可擊,但實際上這個 getInstance() 方法並不完美。問題出在哪裏呢?出在 new 操作上,我們以爲的 new 操作應該是:

        分配一塊內存 M;

        在內存 M 上初始化 Singleton 對象;

        然後 M 的地址賦值給 instance 變量。

但是實際上優化後的執行路徑卻是這樣的:

       分配一塊內存 M;

       將 M 的地址賦值給 instance 變量;

       最後在內存 M 上初始化 Singleton 對象。

       優化後會導致什麼問題呢?我們假設線程 A 先執行 getInstance() 方法,當執行完指令 2 時恰好發生了線程切換,切換到了線程 B 上;如果此時線程 B 也執行 getInstance() 方法,那麼線程 B 在執行第一個判斷時會發現 instance != null ,所以直接返回 instance,而此時的 instance 是沒有初始化過的,如果我們這個時候訪問 instance 的成員變量就可能觸發空指針異常。

看下流程圖:

重排序真的可以這麼肆意妄爲?當然不會編譯器和處理器爲了保證結果的正確性,在重排序之前就會有一定的約束性:

  • 數據依賴性

當然並非所有情況都會產生重排序,編譯優化也需要遵循一定的規則那就是數據依賴性。如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。

編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序。(不過這只是在單處理器的指定操作和單線程的執行操作下, 不同處理器的不同線程不在編譯器和處理器的考慮範圍內。)

在遵守數據依賴性的同時也會遵守兩大原則:

  • as-if-serial規則

as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器爲了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

爲了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操作做重排序,因爲這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關係,這些操作就可能被編譯器和處理器重排序。

as-if-serial語義把單線程程序保護了起來,遵守as-if-serial語義的編譯器、runtime和處理器共同爲編寫單線程程序的程序員創建了一個幻覺:單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程序員無需擔心重排序會干擾他們,也無需擔心內存可見性問題。

  • 程序順序原則

根據happens- before的程序順序規則,上面計算圓的面積的示例代碼存在3個happens-before關係。

     1)A happens-before B。

     2)B happens-before C。

     3)A happens-before C。

這裏的第3個happens-before關係,是根據happens-before的傳遞性推導出來的。

這裏A happens- before B,但實際執行時B卻可以排在A之前執行(看上面的重排序後的執行順序)。如果A happens-before B,JMM並不要求A一定要在B之前執行。JMM僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。這裏操作A的執行結果不需要對操作B可見;而且重排序操作A和操作B後的執行結果,與操作A和操作B按happens- before順序執行的結果一致。在這種情況下,JMM會認爲這種重排序並不非法(not illegal),JMM允許這種重排序。

JMM針對重拍還會有其他對限制:

對於編譯器重排序,JMM(Java內存模型)編譯器重排序規則會禁止特定類型的編譯器重排序。 

對於處理器重排序,JMM處理器重排序規則,會要java編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序。

發佈了56 篇原創文章 · 獲贊 13 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章