關於java內存訪問重排序的思考

前言

且看一段測試代碼, 在不借助外界工具的條件下得出你本身的答案。java

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也會對指令進行重排序。app

何時不進行指令重排序

那麼何時不由止指令重排序或者怎麼禁止指令重排序呢?否則一切都亂套了。函數

數據依賴性

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

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的前面。咱們看一下上面上條指令編譯成字節碼文件以後:this

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

對應的字節碼spa

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操做和二、4可能會重排序, 2操做和一、3ch重排序, 操做3可能和二、4重排序, 操做4可能和一、3重排序。對應上面的賦值x和賦值y有可能會進行重排序, 對, 這並不難以理解, 由於寫x和寫y並無明確的數據依賴關係。可是操做1和3和5並不能重排序, 由於3依賴1, 5依賴3, 同理操做二、四、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的寫入對全部處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中, 這個屏障是個萬能屏障, 兼具其它三種內存屏障的功能。

原文連接

相關文章
相關標籤/搜索