1. 程式人生 > 實用技巧 >7 重排序與happens-before

7 重排序與happens-before

7 重排序與happens-before

7.1 什麼是重排序?

計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排。

為什麼指令重排序可以提高效能?

簡單地說,每一個指令都會包含多個步驟,每個步驟可能使用不同的硬體。因此,流水線技術產生了,它的原理是指令1還沒有執行完,就可以開始執行指令2,而不用等到指令1執行結束之後再執行指令2,這樣就大大提高了效率。

但是,流水線技術最害怕中斷,恢復中斷的代價是比較大的,所以我們要想盡辦法不讓流水線中斷。指令重排就是減少中斷的一種技術。

我們分析一下下面這個程式碼的執行情況:

a = b + c;
d = e - f ;

先載入b、c(注意,即有可能先載入b,也有可能先載入c

),但是在執行add(b,c)的時候,需要等待b、c裝載結束才能繼續執行,也就是增加了停頓,那麼後面的指令也會依次有停頓,這降低了計算機的執行效率。

為了減少這個停頓,我們可以先載入e和f,然後再去載入add(b,c),這樣做對程式(序列)是沒有影響的,但卻減少了停頓。既然add(b,c)需要停頓,那還不如去做一些有意義的事情。

綜上所述,指令重排對於提高CPU處理效能十分必要。雖然由此帶來了亂序的問題,但是這點犧牲是值得的。

指令重排一般分為以下三種:

  • 編譯器優化重排

    編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  • 指令並行重排

    現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性

    (即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。

  • 記憶體系統重排

    由於處理器使用快取和讀寫快取衝區,這使得載入(load)和儲存(store)操作看上去可能是在亂序執行,因為三級快取的存在,導致記憶體與快取的資料同步存在時間差。

指令重排可以保證序列語義一致,但是沒有義務保證多執行緒間的語義也一致。所以在多執行緒下,指令重排序可能會導致一些問題。

7.2 順序一致性模型與JMM的保證

順序一致性模型是一個理論參考模型,記憶體模型在設計的時候都會以順序一致性記憶體模型作為參考。

7.2.1 資料競爭與順序一致性

當程式未正確同步的時候,就可能存在資料競爭。

資料競爭:在一個執行緒中寫一個變數,在另一個執行緒讀同一個變數,並且寫和讀沒有通過同步來排序。

如果程式中包含了資料競爭,那麼執行的結果往往充滿了不確定性,比如讀發生在了寫之前,可能就會讀到錯誤的值;如果一個執行緒程式能夠正確同步,那麼就不存在資料競爭。

Java記憶體模型(JMM)對於正確同步多執行緒程式的記憶體一致性做了以下保證:

如果程式是正確同步的,程式的執行將具有順序一致性。 即程式的執行結果和該程式在順序一致性模型中執行的結果相同。

這裡的同步包括了使用volatilefinalsynchronized等關鍵字來實現多執行緒下的同步

如果程式設計師沒有正確使用volatilefinalsynchronized,那麼即便是使用了同步(單執行緒下的同步),JMM也不會有記憶體可見性的保證,可能會導致你的程式出錯,並且具有不可重現性,很難排查。

所以如何正確使用volatilefinalsynchronized,是程式設計師應該去了解的。後面會有專門的章節介紹這幾個關鍵字的記憶體語義及使用。

7.2.2 順序一致性模型

順序一致性記憶體模型是一個理想化的理論參考模型,它為程式設計師提供了極強的記憶體可見性保證。

順序一致性模型有兩大特性:

  • 一個執行緒中的所有操作必須按照程式的順序(即Java程式碼的順序)來執行。
  • 不管程式是否同步,所有執行緒都只能看到一個單一的操作執行順序。即在順序一致性模型中,每個操作必須是原子性的,且立刻對所有執行緒可見

為了理解這兩個特性,我們舉個例子,假設有兩個執行緒A和B併發執行,執行緒A有3個操作,他們在程式中的順序是A1->A2->A3,執行緒B也有3個操作,B1->B2->B3。

假設正確使用了同步,A執行緒的3個操作執行後釋放鎖,B執行緒獲取同一個鎖。那麼在順序一致性模型中的執行效果如下所示:

正確同步圖

操作的執行整體上有序,並且兩個執行緒都只能看到這個執行順序。

假設沒有使用同步,那麼在順序一致性模型中的執行效果如下所示:

沒有正確同步圖

操作的執行整體上無序,但是兩個執行緒都只能看到這個執行順序。之所以可以得到這個保證,是因為順序一致性模型中的每個操作必須立即對任意執行緒可見

但是JMM沒有這樣的保證。

比如,在當前執行緒把寫過的資料快取在本地記憶體中,在沒有重新整理到主記憶體之前,這個寫操作僅對當前執行緒可見;從其他執行緒的角度來觀察,這個寫操作根本沒有被當前執行緒所執行。只有當前執行緒把本地記憶體中寫過的資料重新整理到主記憶體之後,這個寫操作才對其他執行緒可見。在這種情況下,當前執行緒和其他執行緒看到的執行順序是不一樣的。

7.2.3 JMM中同步程式的順序一致性效果

在順序一致性模型中,所有操作完全按照程式的順序序列執行。但是JMM中,臨界區內(同步塊或同步方法中)的程式碼可以發生重排序(但不允許臨界區內的程式碼“逃逸”到臨界區之外,因為會破壞鎖的記憶體語義)。

雖然執行緒A在臨界區做了重排序,但是因為鎖的特性,執行緒B無法觀察到執行緒A在臨界區的重排序。這種重排序既提高了執行效率,又沒有改變程式的執行結果。

同時,JMM會在退出臨界區和進入臨界區做特殊的處理,使得在臨界區內程式獲得與順序一致性模型相同的記憶體檢視。

由此可見,JMM的具體實現方針是:在不改變(正確同步的)程式執行結果的前提下,儘量為編譯期和處理器的優化開啟方便之門

7.2.4 JMM中未同步程式的順序一致性效果

對於未同步的多執行緒程式,JMM只提供最小安全性:執行緒讀取到的值,要麼是之前某個執行緒寫入的值,要麼是預設值,不會無中生有。

為了實現這個安全性,JVM在堆上分配物件時,首先會對記憶體空間清零,然後才會在上面分配物件(這兩個操作是同步的)。

JMM沒有保證未同步程式的執行結果與該程式在順序一致性中執行結果一致。因為如果要保證執行結果一致,那麼JMM需要禁止大量的優化,對程式的執行效能會產生很大的影響。

未同步程式在JMM和順序一致性記憶體模型中的執行特性有如下差異: 1. 順序一致性保證單執行緒內的操作會按程式的順序執行;JMM不保證單執行緒內的操作會按程式的順序執行。(因為重排序,但是JMM保證單執行緒下的重排序不影響執行結果) 2. 順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序。(因為JMM不保證所有操作立即可見) 3. JMM不保證對64位的long型和double型變數的寫操作具有原子性,而順序一致性模型保證對所有的記憶體讀寫操作都具有原子性。

7.3 happens-before

7.3.1 什麼是happens-before?

一方面,程式設計師需要JMM提供一個強的記憶體模型來編寫程式碼;另一方面,編譯器和處理器希望JMM對它們的束縛越少越好,這樣它們就可以最可能多的做優化來提高效能,希望的是一個弱的記憶體模型。

JMM考慮了這兩種需求,並且找到了平衡點,對編譯器和處理器來說,只要不改變程式的執行結果(單執行緒程式和正確同步了的多執行緒程式),編譯器和處理器怎麼優化都行。

而對於程式設計師,JMM提供了happens-before規則(JSR-133規範),滿足了程式設計師的需求——簡單易懂,並且提供了足夠強的記憶體可見性保證。換言之,程式設計師只要遵循happens-before規則,那他寫的程式就能保證在JMM中具有強的記憶體可見性。

JMM使用happens-before的概念來定製兩個操作之間的執行順序。這兩個操作可以在一個執行緒以內,也可以是不同的執行緒之間。因此,JMM可以通過happens-before關係向程式設計師提供跨執行緒的記憶體可見性保證。

happens-before關係的定義如下: 1. 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。 2. 兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼JMM也允許這樣的重排序。

happens-before關係本質上和as-if-serial語義是一回事。

as-if-serial語義保證單執行緒內重排序後的執行結果和程式程式碼本身應有的結果是一致的,happens-before關係保證正確同步的多執行緒程式的執行結果不被重排序改變。

總之,如果操作A happens-before操作B,那麼操作A在記憶體上所做的操作對操作B都是可見的,不管它們在不在一個執行緒。

7.3.2 天然的happens-before關係

在Java中,有以下天然的happens-before關係:

  • 程式順序規則:一個執行緒中的每一個操作,happens-before於該執行緒中的任意後續操作。
  • 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  • volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  • 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
  • start規則:如果執行緒A執行操作ThreadB.start()啟動執行緒B,那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作、
  • join規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。

舉例:

int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);

根據以上介紹的happens-before規則,假如只有一個執行緒,那麼不難得出:

1> A happens-before B 
2> B happens-before C 
3> A happens-before C

注意,真正在執行指令的時候,其實JVM有可能對操作A & B進行重排序,因為無論先執行A還是B,他們都對對方是可見的,並且不影響執行結果。

如果這裡發生了重排序,這在視覺上違背了happens-before原則,但是JMM是允許這樣的重排序的。

所以,我們只關心happens-before規則,不用關心JVM到底是怎樣執行的。只要確定操作A happens-before操作B就行了。

重排序有兩類,JMM對這兩類重排序有不同的策略:

  • 會改變程式執行結果的重排序,比如 A -> C,JMM要求編譯器和處理器都不許禁止這種重排序。
  • 不會改變程式執行結果的重排序,比如 A -> B,JMM對編譯器和處理器不做要求,允許這種重排序。