1. 程式人生 > >java執行緒和記憶體 java 重排序 java happens-before java 記憶體語義

java執行緒和記憶體 java 重排序 java happens-before java 記憶體語義

執行緒之間的通訊機制有兩種:

 

  1.         共享記憶體:在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,通過寫-讀記憶體中的公共狀態進行隱式通訊。
  2.         訊息傳遞:在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過傳送訊息來顯式進行通訊

重排序:


    重排序是指編譯器和處理器為了優化程式效能而對指令序列進行重新排序。
    重排序分3種類型。

  1.         編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
  2.         指令級並行的重排序。處理器將多條指令重疊執行。資料沒有依賴性處理器可以改變語句對應機器指令的執行順序。
  3.         記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

        原始碼-----1編譯器重排序-------2指令級重排序----------3記憶體系統的重排序

        1屬於編譯器重排序,2和3屬於處理器重排序     

   

這些重排序可能會導致多執行緒程式出現記憶體可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定型別的編譯器重排序對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定型別的記憶體屏障(Memory Barriers,Intel稱之為Memory Fence)指令,通過記憶體屏障指令來禁止特定型別的處理器重排序。JMM屬於語言級的記憶體模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的記憶體可見性保證。


happens-before 規則

  •     程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。
  •     監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  •     volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  •     傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。如果前一個操作(A)必須要對後         一個操作(C)可見 ,那麼這兩個操作(A C) 指令不能重排。

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


順序一致性模型

 

  •     順序一致性記憶體模型是一個理論參考模型,在設計的時候,處理器的記憶體模型和程式語言的記憶體模型都會以順序一致性記憶體模型作為參照。
  •     順序一致性模型有一個單一的全域性記憶體,在任意時間點最多隻能有一個執行緒可以連線到記憶體。在順序一致性模型中,所有操作之間具有全序關係。

順序一致性模型的同步與未同步


           假設A執行緒有3個操作在程式中的順序是:A1→A2→A3。B執行緒也有3個操作在程式中的順序是:B1→B2→B3。

 

  •    同步程式:A執行緒的3個操作執行後釋放監視器鎖,隨後B執行緒獲取同一個監視器鎖。那麼他們的執行順序是 A1→A2→A3→B1→B2→B3。
  •     未同步程式:他們的執行可能是A1→A2→B1→A3→B2→B3也可能是  B1→A1→A2→A3→B2→B3   整體執行順序是無序的,但所有執行緒都只能看到一個一致的整體執行順序(執行緒a 看到的是 A1→A2→B1→A3→B2→B3  執行緒b 看到的也是 A1→A2→B1→A3→B2→B3  ),順序一致性記憶體模型中的每個操作必須立即對任意執行緒可見。

 JMM順序一致性

未同步程式

  •     整體的執行順序是無序,
  •     所有執行緒看到的操作執行順序也可能不一致。比如,在當前執行緒把寫過的資料快取在本地記憶體中,在沒有重新整理到主記憶體之      前,這個寫操作僅對當前執行緒可見;其他執行緒不可見。

同步程式:

          jmm 執行結果將與該程式在順序一致性模型中的執行結果相同。順序一致性模型中,所有操作完全按程式的順序序列執行。而在JMM中,臨界區( 存在併發的程式碼塊 )內的程式碼可以重排序(但JMM不允許臨界區內的程式碼“逸出”到臨界區之外,那樣會破壞監視器的語義)。JMM會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特別處理(比如加鎖),雖然執行緒A在臨界區內做了重排序,但由於監視器互斥執行的特性,這裡的執行緒B根本無法“觀察”到執行緒A在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程式的執行結果。

 

未同步順序一致性 和 jmm 順序一致性差異

  1. 順序一致性模型保證單執行緒內的操作會按程式的順序執行,而JMM不保證單執行緒內按程式的順序執行(會重排序)。
  2. 順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序。
  3. JMM不保證對64位的long和double型變數的寫操作具有原子性,而順序一致性模型保證對所有的記憶體讀/寫都具有原子性。

 

為什麼JMM 不保證64 位的long 和double 的原子性


在計算機中,資料通過匯流排在處理器和記憶體之間傳遞。每次處理器和記憶體之間的資料傳遞都是通過一系列步驟來完成的,這一系列步驟稱之為匯流排事務(Bus Transaction)。匯流排事務包括讀事務(Read Transaction)和寫事務(WriteTransaction)。讀事務從記憶體傳送資料到處理器,寫事務從處理器傳送資料到記憶體,  匯流排仲裁會確保所有處理器都能公平的訪問記憶體在任意時
間點,最多隻能有一個處理器可以訪問記憶體。這個特性確保了單個匯流排事務之中的記憶體讀/寫操作具有原子性。


在一些32位的處理器上,如果要求對64位資料的寫操作具有原子性,會有比較大的開銷。為了照顧這種處理器,Java語言規範鼓勵但不強求JVM對64位的long型變數和double型變數的寫操作具有原子性。當JVM在這種處理器上執行時,可能會把一個64位long/double型變數的寫操作拆分為兩個32位的寫操作來執行。這兩個32位的寫操作可能會被分配到不同的匯流排事務中執行,此時對這個64位變數的寫操作將不具有原子性。

 

 

volatile

特性:

  1. 可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。
  2. 原子性:對任意單個volatile變數讀寫具有原子性,類似volatile++這種複合操作不具有原子性。

volatile記憶體:

  • 當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。
  • 當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

volatile重排序:

 

  是否可以重排序
1普通讀寫  -  2 volatile 讀     是
volatile 讀  -  2 (volatile 讀寫  或   普通讀寫)     否
volatile 寫  -  2 (volatile 讀寫  或   普通讀寫)     否
1 (volatile 讀寫  或   普通讀寫)  -  2 volatile 寫     否
   

 

 在每個volatile寫操作的前面插入一個StoreStore屏障。
·在每個volatile寫操作的後面插入一個StoreLoad屏障。
·在每個volatile讀操作的後面插入一個LoadLoad屏障。
·在每個volatile讀操作的後面插入一個LoadStore屏障。

鎖釋放和鎖獲取的記憶體語義

 

  1. 執行緒A釋放一個鎖,實質上是執行緒A向接下來將要獲取這個鎖的某個執行緒發出了(執行緒A對共享變數所做修改的)訊息。
  2. 執行緒B獲取一個鎖,實質上是執行緒B接收了之前某個執行緒發出的(在釋放這個鎖之前對共享變數所做修改的)訊息。
  3. 執行緒A釋放鎖,隨後執行緒B獲取這個鎖,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息。

 

鎖釋放-獲取的記憶體語義的實現至少有下面兩種方式。

 

  1. 利用volatile變數的寫-讀所具有的記憶體語義。
  2. 利用CAS所附帶的volatile讀和volatile寫的記憶體語義。

檢視ReentrantLock的原始碼可以看出  原始碼中 有一個volatile變數  state 通過對state 的cas 更新來實現鎖。

final域的的重排序規則

   寫final域: 在建構函式內對一個final域的寫入,與隨後把這個被構造物件的引用賦值給一個引用變數,這兩個操作不能重排序
   讀final域: 初次讀一個包含final域的物件的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

class FinalExample{
	int i;//普通變數
	final int j;//final變數
	static FinalExample obj;
	public FinalExample(){//建構函式
		i = 1;//寫普通域
		j = 2;//寫final域
	}
	public static void writer(){//執行緒A寫執行
		obj = new FinalExample();
	}
	public static void read(){//執行緒B讀執行
		FinalExample fe = obj;//讀取包含final域物件的引用
		int a = fe.i;//讀取普通變數
		int b = fe.j;//讀取final變數
	}
}

關於寫final域排序如上圖程式碼  處理器會對程式碼重排序 假設執行緒A執行 writer 然後執行緒 B執行 read 此時程式碼被重排序

 


    //1 執行緒 a 執行  writer  然後 FinalExample中的 i=1; 普通變數被重排序到了 FinalExample構造函  數外
	public FinalExample(){//建構函式
		j = 2;//寫final域編譯器會在return 前為final域 插入一個StoreStore屏障 不會被重排序到方法外。
	}

    i = 1;//寫普通域
	

    //2 執行緒 b由於執行緒 a和b 是併發執行所以在a FinalExample() 建構函式後恰好 執行緒b 執行read 所以此時執行緒b 會讀取不到普通域 i 而final 域j 不能重排序到構造方法外所以可以正確讀寫
	public static void read(){//執行緒B讀執行
		FinalExample fe = obj;//讀取包含final域物件的引用
		int a = fe.i;//讀取普通變數
		int b = fe.j;//讀取final變數
	}

 final域引用型別的排序問題

 


public class User {
    
    final String[] arr;
    public User() {
        arr = new String[]{};
        arr[0] = "0";
        arr[1] = "1";
        arr[2] = "2";//寫final域編譯器會在return 前為final域 插入一個StoreStore屏障 不會被重排序到方法外。  所以 arr[0] = "0" 也不會被重排序到方法外;
    }
    
}

 

 

為什麼final域的引用不能從建構函式溢位(final 域在步驟2 溢位)

寫final域的重排序規則可以確保:在引用變數為任意執行緒可見之前,該引用變數指向的物件的final域已經在建構函式中被正確初始化過了。 要達到這個效果還要保證物件的引用不能在建構函式溢位

圖程式碼中的 1和2 可能重排序   當A執行緒執行完了2  this 已經賦值給了 u   另一個執行緒 去執行 3 的時候u不為null str會為null

因為A執行緒還沒有執行1  

public class User {
    private static  User u; 
    final String str;
    public User() {
       str="1"; // 1
       u=this;  // 2 
    }
    public  static void sout(){ //3
        if(u!=null){
            System.out.println(u.str);
        }
       
    }

}

 

 

併發程式設計網