關於java記憶體訪問重排序的思考

程式設計師一北發表於2018-03-30

前言

且看一段測試程式碼, 在不借助外界工具的條件下得出你自己的答案。

import java.util.*;
import java.util.concurrent.CountDownLatch;

public class Reordering {
    static int a = 0;
    static int b = 0;
    static int x = 0;
    static int y = 0;
    static final Set<Map<Integer, Integer>> ans = new HashSet<>(4);
    public void help() throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(2);
        Thread threadOne = new Thread(() -> {
            a = 1;
            x = b;
            latch.countDown();
        });

        Thread threadTwo = new Thread(() -> {
           b = 1;
           y = a;
           latch.countDown();
        });
        threadOne.start();
        threadTwo.start();
        latch.await();
        Map<Integer, Integer> map = new HashMap<>();
        map.put(x, y);
        if (!ans.contains(map)) {
            ans.add(map);
        }
    }

    @Test
    public void testReordering() throws InterruptedException {
      for (int i = 0; i < 20000 && ans.size() != 4; i++) {
          help();
          a = x = b = y = 0;
      }
      help();
      System.out.println(ans);
    }
}
複製程式碼

你的結果ans可能是[{0=>1}, {1=>1}, {1=>0}], 因為執行緒排程是隨機的, 有可能一個執行緒執行了, 另外一個執行緒才獲得cpu的執行權, 又或者是兩個執行緒交疊執行, 這種情況下ans的答案無疑是上面三種結果, 至於上面三種結果對應的執行緒執行順序, 我這裡就不模擬了, 這不是重點。但是其實ans除了上面的三種結果之外, 還有另外一種結果{0=>0}, 這是為什麼呢? 要想出現{0=>0}這種結果無非就是:

  1. threadOne先執行x = b = > x = 0;
  2. threadTwo執行b = 1, y = a => y = 0
  3. threadOne執行a = 1。 或者把threadOne和two的角色互換一下。 你或許很疑問為啥會出現x = b happens before a = 1呢? 這其實就是指令重排序。

指令重排序

大多數現代微處理器都會採用將指令亂序執行的方法, 在條件允許的情況下, 直接執行當前有能力立即執行的後續指令, 避開獲取下一條指令所需資料時造成的等待。通過亂序執行的技術, 處理器可以大大提高執行效率。除了cpu會對指令重排序來優化效能之外, java JIT也會對指令進行重排序。

什麼時候不進行指令重排序

那麼什麼時候不禁止指令重排序或者怎麼禁止指令重排序呢?不然一切都亂套了。

資料依賴性

其一, 有資料依賴關係的指令不會進行指令重排序! 什麼意思呢?

a = 1;
x = a;
複製程式碼

就像上面兩條指令, x依賴於a, 所以x = a這條指令不會重排序到a = 1這條指令的前面。

有資料依賴關係分為以下三種:

  1. 寫後讀, 就像上面我們舉的那個例子a = 1x = a, 這就是典型的寫後讀, 這種不會進行指令重排序。
  2. 寫後寫, 如a = 1a = 2, 這種也不會進行重排序。
  3. 還有最後一種資料依賴關係, 就是讀後寫, 如x = aa = 1

as-if-serial語義

什麼是as-if-serial? as-if-serial語義就是: 不管怎麼重排序(編譯器和處理器為了提高並行度), 單執行緒程式的執行結果不能被改變。所以編譯器和cpu進行指令重排序時候回遵守as-if-serial語義。舉個栗子:

x = 1;   //1
y = 1;   //2
ans = x + y;  //3
複製程式碼

上面三條指令, 指令1和指令2沒有資料依賴關係, 指令3依賴指令1和指令2。根據上面我們講的重排序不會改變我們的資料依賴關係, 依據這個結論, 我們可以確信指令3是不會重排序於指令1和指令2的前面。我們看一下上面上條指令編譯成位元組碼檔案之後:

public int add() {
  int x = 1;
  int y = 1;
  int ans = x + y;
  return ans
}
複製程式碼

對應的位元組碼

public int add();
    Code:
       0: iconst_1     // 將int型數值1入運算元棧
       1: istore_1     // 將運算元棧頂數值寫到區域性變數表的第2個變數(因為非靜態方法會傳入this, this就是第一個變數)
       2: iconst_1     // 將int型數值1入運算元棧
       3: istore_2     // 將將運算元棧頂數值寫到區域性變數表的第3個變數
       4: iload_1      // 將第2個變數的值入運算元棧
       5: iload_2      // 將第三個變數的值入運算元棧
       6: iadd         // 運算元棧頂元素和棧頂下一個元素做int型add操作, 並將結果壓入棧
       7: istore_3     // 將棧頂的數值存入第四個變數
       8: iload_3      // 將第四個變數入棧
       9: ireturn      // 返回
複製程式碼

以上的位元組碼我們只關心0->7行, 以上8行指令我們可以分為:

  1. 寫x
  2. 寫y
  3. 讀x
  4. 讀y
  5. 加法操作寫回ans

上面的5個操作, 1操作和2、4可能會重排序, 2操作和1、3ch重排序, 操作3可能和2、4重排序, 操作4可能和1、3重排序。對應上面的賦值x和賦值y有可能會進行重排序, 對, 這並不難以理解, 因為寫x和寫y並沒有明確的資料依賴關係。但是操作1和3和5並不能重排序, 因為3依賴1, 5依賴3, 同理操作2、4、5也不能進行重排序。

所以為了保證資料依賴性不被破壞, 重排序要遵守as-if-serial語義。

@Test
    public void testReordering2() {
        int x = 1;
        try {
            x = 2;     //A
            y = 2 / 0;  //B
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println(x);
        }
    }
複製程式碼

上面這段程式碼A和B是有可能重排序的, 因為x和y並沒有資料依賴關係, 並且也沒有特殊的語義做限制。但是如果發生B happens-before A的話, 此時是不是就列印了錯誤的x的值, 其實不然: 為了保證as-if-serial語義, Java異常處理機制對重排序做了一種特殊的處理: JIT在重排序時會在catch語句中插入錯誤代償程式碼(即重排序到B後面的A), 這樣做雖然會導致catch裡面的邏輯變得複雜, 但是JIT優化原則是: 儘可能地優化程式正常執行下的邏輯, 哪怕以catch塊邏輯變得複雜為代價。

程式順序原則

  1. 如果A happens-before B
  2. 如果B happens-before C 那麼
  3. A happens-before C

這就是happens-before傳遞性

重排序與JMM

Java記憶體模型(Java Memory Model簡稱JMM)總結了以下8條規則, 保證符合以下8條規則, happens-before前後兩個操作, 不會被重排序且後者對前者的記憶體可見。

  1. 程式次序法則: 執行緒中的每個動作A都happens-before於該執行緒中的每一個動作B, 其中, 在程式中, 所有的動作B都能出現在A之後。
  2. 監視器鎖法則: 對一個監視器鎖的解鎖happens-before於每一個後續對同一監視器鎖的加鎖。
  3. volatile變數法則: 對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。
  4. 執行緒啟動法則: 在一個執行緒裡, 對Thread.start的呼叫會happens-before於每個啟動執行緒的動作。
  5. 執行緒終結法則: 執行緒中的任何動作都happens-before於其他執行緒檢測到這個執行緒已經終結、或者從Thread.join呼叫中成功返回, 或Thread.isAlive返回false。
  6. 中斷法則: 一個執行緒呼叫另一個執行緒的interrupt happens-before於被中斷的執行緒發現中斷。
  7. 終結法則: 一個物件的建構函式的結束happens-before於這個物件finalizer的開始。
  8. 傳遞性: 如果A happens-before於B, 且B happens-before於C, 則A happens-before於C。

指令重排序導致錯誤的double-check單例模式

有人肯定寫過下面的double-check單例模式

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
複製程式碼

但是這種double-check加鎖的單例是正常的嗎? No. 因為建立一個例項物件並不是一個原子性的操作, 而且還可能發生重排序, 具體如下: 假定建立一個物件需要:

  1. 申請記憶體
  2. 初始化
  3. instance指向分配的那塊記憶體

上面的2和3操作是有可能重排序的, 如果3重排序到2的前面, 這時候2操作還沒有執行, instance已經不是null了, 當然不是安全的。

那麼怎麼防止這種指令重排序? 修改如下:

public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
複製程式碼

volatile關鍵字有兩個語義: 其一保證記憶體可見性, 這個語義我們下次部落格會講到(其實就是一個執行緒修改會對另一個執行緒可見, 如果不是volatile, 執行緒操作都是在TLAB有副本的, 修改了副本的值之後不即時重新整理到主存, 其他執行緒是不可見的) 其二, 禁止指令重排序, 如果上面new的時候, 禁止了指令重排序, 所以能得到期望的情況。

題外話, 關於執行緒安全的單例, 往往可以採用靜態內部類的形式來實現, 這種無疑是最合適的了。

public class Singleton {
    public static Singleton getInstance() {
        return Helper.instance;
    }

    static class Helper {
        private static final Singleton instance = new Singleton();
    }
}
複製程式碼

怎麼禁止指令重排序

我們之前一會允許重排序, 一會禁止重排序, 但是重排序禁止是怎麼實現的呢? 是用記憶體屏障cpu指令來實現的, 顧名思義, 就是加個障礙, 不讓你重排序。

記憶體屏障可以被分為以下幾種型別:

  1. LoadLoad屏障: 對於這樣的語句Load1; LoadLoad; Load2, 在Load2及後續讀取操作要讀取的資料被訪問前, 保證Load1要讀取的資料被讀取完畢。
  2. StoreStore屏障: 對於這樣的語句Store1; StoreStore; Store2, 在Store2及後續寫入操作執行前, 保證Store1的寫入操作對其它處理器可見。
  3. LoadStore屏障: 對於這樣的語句Load1; LoadStore; Store2, 在Store2及後續寫入操作被刷出前, 保證Load1要讀取的資料被讀取完畢。
  4. StoreLoad屏障: 對於這樣的語句Store1; StoreLoad; Load2, 在Load2及後續所有讀取操作執行前, 保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中, 這個屏障是個萬能屏障, 兼具其它三種記憶體屏障的功能。

原文連結

相關文章