天天看點

Java中的指令重排

在執行程式時,為了提高性能,編譯器和處理器常常會對指令做重排序。重排序分3種類型:

  1. 編譯器優化的重排序。編譯器在不改變單線程程式語義的前提下,可以重新安排語句的執行順序。
  2. 指令級并行的重排序。現代處理器采用了指令級并行技術(Instruction-Level Parallelism,ILP)來将多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
  3. 記憶體系統的重排序。由于處理器使用緩存和讀/寫緩沖區,這使得加載和存儲操作看上去可能是在亂序執行。

1屬于編譯器重排序,2和3屬于處理器重排序。從Java源代碼到最終實際執行的指令序列,會分别經曆下面3種重排序:

Java中的指令重排

as-if-serial 語義

as-if-serial的意思是:不管指令怎麼重排序,在單線程下執行結果不能被改變。不管是編譯器級别還是處理器級别的重排序都必須遵循as-if-serial語義。

為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關系的操作做重排序。但是as-if-serial規則允許對有控制依賴關系的指令做重排序,因為在單線程程式中,對存在控制依賴的操作重排序,不會改變執行結果,但是多線程下确有可能會改變結果。

資料依賴

int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3      

上述代碼,​

​a​

​​和​

​b​

​​不存在依賴關系,是以1、2可以進行重排序;​

​c​

​​依賴 ​

​a​

​​和​

​b​

​,是以3必須在1、2的後面執行。

控制依賴

public void use(boolean flag, int a, int b) {
  if (flag) { // 1
    int i = a * b; // 2
  }
}      

​flag​

​​和​

​i​

​存在控制依賴關系。當指令重排序後,2這一步會将結果值寫入重排序緩沖(Reorder Buffer,ROB)的硬體緩存中,當判斷為true時,再把結果值寫入變量i中。

happens-before 語義

JSR-133使用happens-before的概念來闡述操作之間的記憶體可見性。**在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關系。**這裡提到的兩個操作既可以是在一個線程之内,也可以是在不同線程之間。

兩個操作之間具有happens-before關系,并不意味着前一個操作必須要在後一個 操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一 個操作按順序排在第二個操作之前(the first is visible to and ordered before the second)。

happens-before 部分規則

  1. 程式順序規則: 一個線程中的每個操作,happens-before于該線程中的任意後續操作。

    主要含義是:在一個線程内不管指令怎麼重排序,程式運作的結果都不會發生改變。和as-if-serial 比較像。

  2. 螢幕鎖規則: 對一個鎖的解鎖,happens-before于随後對這個鎖的加鎖。

    主要含義是:同一個鎖的解鎖一定發生在加鎖之後

  3. 管程鎖定規則: 一個線程擷取到鎖後,它能看到前一個擷取到鎖的線程所有的操作結果。

    主要含義是:無論是在單線程環境還是多線程環境,對于同一個鎖來說,一個線程對這個鎖解鎖之後,另一個線程擷取了這個鎖都能看到前一個線程的操作結果!(管程是一種通用的同步原語,synchronized就是管程的實作)

  4. volatile變量規則: 對一個volatile域的寫,happens-before于任意後續對這個volatile域的讀。

    主要含義是:如果一個線程先去寫一個volatile變量,然後另一個線程又去讀這個變量,那麼這個寫操作的結果一定對讀的這個線程可見。

  5. 傳遞性: 如果A happens-before B,且B happens-before C,那麼A happens-before C。
  6. start()規則: 如果線程A執行操作ThreadB.start()(啟動線程B),那麼A線程的ThreadB.start()操作happens-before于線程B中的任意操作。

    主要含義是:線程A在啟動子線程B之前對共享變量的修改結果對線程B可見。

  7. join()規則: 如果線程A執行操作ThreadB.join()并成功傳回,那麼線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功傳回。

    主要含義是:如果線上程A執行過程中調用了線程B的join方法,那麼當B執行完成後,線上程B中所有操作結果對線程A可見。

  8. 線程中斷規則: 對線程interrupt方法的調用happens-before于被中斷線程的代碼檢測到中斷事件的發生。

    主要含義是:響應中斷一定發生在發起中斷之後。

  9. 對象終結規則: 就是一個對象的初始化的完成,也就是構造函數執行的結束一定 happens-before它的finalize()方法。

一個happens-before規則對應于一個或多個編譯器和處理器重排序規則。

as-if-serial和happens-before的主要作用都是:在保證不改變程式運作結果的前提下,允許部分指令的重排序,最大限度的提升程式執行的效率。

記憶體屏障

我們先來看一個并發環境下指令重排序帶來的問題:

Java中的指令重排

這裡有兩個線程A和線程B,當A執行​

​init​

​​方法時發生了指令重排,2先執行,這時線程B執行​

​use​

​方法,這時我們拿到的變量a卻還是0,是以最後得到的結果 i=0,而不是i=1。

如何解決上述問題呢?一種是使用記憶體屏障(volatile),另一種使用臨界區(synchronized )。

如果我們使用記憶體屏障,那麼JMM的處理器,會要求Java編譯器在生成指令序列時,插入特定類型的記憶體屏障(Memory Barriers,Intel稱之為 Memory Fence)指令,通過記憶體屏障指令來禁止特定類型的處理器重排序。

記憶體屏障的類型

Java中的指令重排

StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支援該屏障(其他類型的屏障不一定被所有處理器支援)。執行該屏障開銷會很昂 貴,因為目前處理器通常要把寫緩沖區中的資料全部重新整理到記憶體中(Buffer Fully Flush)。

常見處理器允許的重排序類型的清單,“N”表示處理器不允許兩個操作重排序,“Y”表示允許重排序:

Java中的指令重排

那麼上面的問題,我們可以在​

​flag​

​​處插入一個記憶體屏障,其作用是:保證在​

​init()​

​方法中,第1步操作一定在第2步之前,禁止第1步和第2步操作出現指令重排序,代碼如下:

public class ControlDep {
  int a = 0;
  volatile boolean flag = false;

  public void init() {
    a = 1; // 1
    flag = true; // 2
    //.......
  }

  public void use() {
    if (flag) { // 3
      int i = a * a; // 4
    }
    //.......
  }
}      
A線程在寫volatile變量之前所有可見的共享變量,在B線程讀同一個volatile變量後,将立即變得對B線程可見。也就是說程式執行執行完第2步的時候,處理器會将第2步和其之前的所有結果強制重新整理到主記憶體。也就是說​

​a=1​

​​也會被強制重新整理到主記憶體中。那麼當另一個線程執行到步驟3的時候,如果判斷到​

​flag=true​

​時,那麼第4步處a一定是等于1的,這樣就保證了程式的正确運作。

順序一緻性

順序一緻性記憶體模型是一個被計算機科學家理想化了的理論參考模型,它為程式員提供了極強的記憶體可見性保證。順序一緻性記憶體模型有兩大特性:

  1. 一個線程中的所有操作必須按照程式的順序來執行。
  2. (不管程式是否同步)所有線程都隻能看到一個單一的操作執行順序。在順序一緻性記憶體模型中,每個操作都必須原子執行且立刻對所有線程可見。

JMM對正确同步的多線程程式的記憶體一緻性做了如下保證:如果程式是正确同步的,程式的執行将具有順序一緻性(Sequentially Consistent)——即程式的執行結果與該程式在順序一緻性記憶體模型中的執行結果相同。

我們看到JMM僅僅是保證了程式運作的結果是和順序執行是一緻,并沒有實作真正的順一緻性。它又是怎麼實作的呢?JMM使用了臨界區(加鎖)來保證程式的順序執行,但是在臨界區内是允許出現指令重排的(JMM不允許臨界區内的代碼“逸出”到臨界區之外,那樣會破壞螢幕的語義)。

我們在回過來看下上面遇到的并發問題,在上面我們說了使用記憶體屏障來解決,這裡我們使用臨界區。

public class ControlDep {
  int a = 0;
  boolean flag = false;

  public synchronized void init() {
    a = 1; // 1
    flag = true; // 2
    //.......
  }

  public synchronized void use() {
    if (flag) { // 3
      int i = a * a; // 4
    }
    //.......
  }
}      

雖然線程A執行​

​init()​

​​方法時,在臨界區内做了重排序,但由于螢幕互斥執行的特性,線程B執行​

​use()​

​方法時,根本無法“觀察”到線程A在臨界區内的重排序。這種重排序既提高了執行效率,又沒有改變程式的執行結果。

Java中的指令重排

從這裡我們可以看到,JMM在具體實作上的基本方針為:在不改變(正确同步的)程式執 行結果的前提下,盡可能地為編譯器和處理器的優化打開友善之門。

volatile的記憶體語義

volatile的特性

  • 可見性: 對一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入
  • 原子性: 對任意單個volatile變量的讀/寫具有原子性,但類似于volatile++這種複合操作不具有原子性

了解volatile特性的一個好方法是把對volatile變量的單個讀/寫,看成是使用同一個鎖對這 些單個讀/寫操作做了同步。下面通過具體的示例來說明,示例代碼如下:

class VolatileFeaturesExample {
    // 使用volatile聲明64位的long型變量
    volatile long vl = 0L;

    // 普通long型變量
    volatile long v2 = 0L;

    public void set(long l) {
        // 單個volatile變量的寫
        vl = l;
    }

    public synchronized void syncSet(long l) {
        // 單個volatile變量的寫執行效果等價于對普通變量的加同步鎖來寫
        v2 = l;
    }

    public long get() {
        // 單個volatile變量的讀
        return vl;
    }

    public synchronized long syncGet() {
        // 單個volatile變量的讀執行效果等價于對普通變量的加同步鎖來讀
        return vl;
    }

    public void getAndIncrement() {
        // 複合(多個)volatile變量的讀/寫 不具備原子性
        vl++;
        // v1++ 等價于 如下代碼(不具備原子性)
        long temp = syncGet();
        temp = temp + 1;
        syncSet(temp);
    }
}      

volatile寫和讀的記憶體語義

  • volatile寫的記憶體語義: 當寫一個volatile變量時,JMM會把該線程對應的本地記憶體中的所有共享變量值重新整理到主記憶體
  • Java中的指令重排
  • volatile讀的記憶體語義:當讀一個volatile變量時,JMM會把該線程對應的本地記憶體置為無效。線程接下來将從主記憶體中讀取所有共享變量。
  • Java中的指令重排

volatile記憶體語義的實作

為了實作volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定類型的處理器重排序。

具體限制規則如下

Java中的指令重排
  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確定 volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確定 volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

具體插入的記憶體屏障

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。在每個volatile寫操作的後面插入一個StoreLoad屏障。
  • Java中的指令重排
  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。在每個volatile讀操作的後面插入一個LoadStore屏障。
  • Java中的指令重排

鎖的記憶體語義

  • 當線程釋放鎖時,JMM會把該線程對應的本地記憶體中的共享變量重新整理到主記憶體中。 線程A釋放一個鎖,實質上是線程A向接下來将要擷取這個鎖的某個線程發出了(線程A 對共享變量所做修改的)消息。
  • 當線程擷取鎖時,JMM會把該線程對應的本地記憶體置為無效。進而使得被螢幕保護的臨界區代碼必須從主記憶體中讀取共享變量。線程B擷取一個鎖,實質上是線程B接收了之前某個線程發出的(在釋放這個鎖之前對共 享變量所做修改的)消息。
線程A釋放鎖,随後線程B擷取這個鎖,這個過程實質上是線程A通過主記憶體向線程B發送消息。

final的記憶體語義

  1. 在構造函數内對一個final域的寫入,與随後把這個被構造對象的引用指派給一個引用變量,這兩個操作之間不能重排序。也就是說隻有将對象執行個體化完成後,才能将對象引用指派給變量。
  2. 初次讀一個包含final域的對象的引用,與随後初次讀這個final域,這兩個操作之間不能重排序。也就是下面示例的4和5不能重排序。
  3. 當final域為引用類型時,在構造函數内對一個final引用的對象的成員域的寫入,與随後在構造函數外把這個被構造對象的引用指派給一個引用變量,這兩個操作之間不能重排序。

下面通過代碼在說明一下:

public class FinalExample {
    int i;   // 普通變量
    final int j;   // final變量
    static FinalExample obj;

    public FinalExample() { // 構造函數
        i = 1;// 寫普通域
        j = 2;// 寫final域
    }

    public static void writer() {   // 寫線程A執行
        // 這一步實際上有三個指令,如下:
        // memory = allocate();  // 1:配置設定對象的記憶體空間
        // ctorInstance(memory); // 2:初始化對象
        // instance = memory;  // 3:設定instance指向剛配置設定的記憶體位址
        obj = new FinalExample();
    }

    public static void reader() {   // 讀線程B執行            
        FinalExample object = obj;  // 4. 讀對象引用
        int a = object.i; // 5. 讀普通域
        int b = object.j; // 讀final域
    }
}      
  1. 如果沒有final語義的保證,在​

    ​writer()​

    ​​方法中,那三個指令可能發生重排序,導緻步驟3先于2執行,然後線程B在執行​

    ​reader()​

    ​方法時拿到一個沒有初始化的對象。
  2. 在讀一個對象的final域之前,一定會先讀包含這個final 域的對象的引用。在這個示例程式中,如果該引用不為null,那麼引用對象的final域一定已經 被A線程初始化過了。

final語義在處理器中的實作

  • 會要求編譯器在final域的寫之後,構造函數return之前插入一個StoreStore障屏。
  • 讀final域的重排序規則要求編譯器在讀final域的操作前面插入一個LoadLoad屏障。

源碼

​​https://github.com/wyh-spring-ecosystem-student/spring-boot-student/tree/releases​​

spring-boot-student-concurrent 工程

參考

layering-cache

繼續閱讀