1. 程式人生 > >Java並發編程原理與實戰四十一:重排序 和 happens-before

Java並發編程原理與實戰四十一:重排序 和 happens-before

而已 註意 ron 不知道 load chm title 並行 ola

一、概念理解

首先我們先來了解一下什麽是重排序:重排序是指編譯器和處理器為了優化程序性能而對指令序列進行重新排序的一種手段。

從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序,如下圖所示

技術分享圖片

上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序可能會導致多線程程序出現內存可見性問題。在單線程程序中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。

1)數據依賴性(針對單個處理器而已)

關於重排序,這裏要先講一個概念就是數據依賴性問題。如果兩個操作訪問同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在數據依賴性。數據依賴分為下列3種類型,如下表所示。

技術分享圖片

上面3種情況,只要重排序兩個操作的執行順序,程序的執行結果就會被改變。前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關系的兩個操作的執行順序。這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。

2)as-if-serial語義

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

為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序。as-if-serial語義把單線程程序保護了起來,as-if-serial語義使單線程程序員無需擔心重排序會幹擾他們,也無需擔心內存可見性問題。

3)happens-before

如果一個操作執行的結果需要對另一個操作可見,那麽這兩個操作之間必須要存在happens-before關系。這裏提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。

對happens-before關系的具體定義如下。

① 如果一個操作happens-before另一個操作,那麽第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
②兩個操作之間存在happens-before關系,並不意味著Java平臺的具體實現必須要按照 happens-before關系指定的順序來執行。如果重排序之後的執行結果,與按happens-before關系來執行的結果一致,那麽這種重排序並不非法(也就是說,JMM允許這種重排序)。

上面的①是JMM對程序員的承諾。從程序員的角度來說,可以這樣理解happens-before關系:如果A happens-before B,那麽Java內存模型將向程序員保證——A操作的結果將對B可見,且A的執行順序排在B之前。註意,這只是Java內存模型向程序員做出的保證!上面的②是JMM對編譯器和處理器重排序的約束原則。正如前面所言,其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麽優化都行。因此,happens-before關系本質上和as-if-serial語義是一回事。

·as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關系保證正確同步的多線程程序的執行結果不被改變。
·as-if-serial語義給編寫單線程程序的程序員創造了一個幻境:單線程程序是按程序的順序來執行的。happens-before關系給編寫正確同步的多線程程序的程序員創造了一個幻境:正確同步的多線程程序是按happens-before指定的順序來執行的。
as-if-serial語義和happens-before這麽做的目的,都是為了在不改變程序執行結果的前提下,盡可能地提高程序執行的並行度。

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()操作成功返回。

二、例子分析

假設有兩個線程分別調用同一個test對象的writer()和reader()。請問,b的值是什麽?

(a) 1
(b) 2
(c) 1 or 2

 public class test{
    private boolean flag = false;
    private int a = 0;

    public void writer(){
            a = 1;
            flag = True;
    }
    public void reader(){
        if (flag){
            b = a + 1
        }
    }
}

這裏主要涉及的是處理器重排序問題。當前處理器為了加速指令執行,會將部分指令重排序之後執行。

數據依賴

數據依賴是一個簡單的概念,就是判斷前後兩行代碼在數據上有否有依賴關系。例如:

num1 = 1                // (a)
num2 = 2                // (b)
result = num1 + num2    // (c)

顯然,c 語句用到的 num1 和 num2 依賴 a 和 b。

數據依賴分三種:

  • 1 store - load
  • 2 load - store
  • 3 store - store

如何判斷是否有依賴,很簡單,只用判斷兩個語句之間是否用到同一個變量,是否是寫操作。

Happen before

JVM定義了一個概念叫做 happen before,意思是前一條執行的結果要對後一條執行可見。簡單來說前一條執行完,才能執行後一條。但實際上為了提高處理速度,JVM弱化了這個概念,在有數據依賴的情況下,前一條執行完,才能執行後一條。

看下面的例子:

num1 = 1                // (a)
num2 = 2                // (b)
result = num1 + num2    // (c)

對於上述三條語句 a, b, c執行,單線程順序執行的情況。

a happen before b       
b happen before c。

根據傳遞性可以得出:

a happen before c

c指令要用到的 num1 和 num2 顯然是依賴 a 和 b 的,典型的store-load。所以c指令必須等到 a 和 b 執行完才能執行。然而 a 和 b 並沒有數據依賴,於是 JVM 允許處理器對 a 和 b 進行重排序。

a -> b -> c = 3
b -> a -> c = 3

那麽happen before到底是什麽?我的理解是happen before是JVM對底層內存控制抽象出一層概念。我們可以根據代碼順序來判斷happen before的關系,而JVM底層會根據實際情況執行不同的 action (例如添加內存屏障,處理器屏障,阻止重排序又或者是不做任何額外操作,允許處理器沖排序)。通過這一層使得內存控制對程序員透明,程序員也不需要考慮代碼實際執行情況,JVM會保證單線程執行成功,as-if-serial。

既然JVM已經透明了內存控制,那為什麽要搞清楚這點,那就是JVM只保證單線程執行成功,而多線程環境下,就會出各種各樣的問題。

答案

下面就用上述講的分析一下最初的題目。

A線程執行:

    public void writer(){
            a = 1;              // (1)
            flag = True;        // (2)
    }

B線程執行:

    public void reader(){
        if (flag){              // (3)
            b = a + 1           // (4)
        }
    }

1.先考慮大多數人考慮的情況:

指令順序:(1)-> (2) -> (3) -> (4),b = 1 +1 = 2

2.意想不到的情況
對於A線程來說,語句 (1)和(2)並不存在任何數據依賴問題。因此處理器可以對其進行重排序,也就是指令 (2)可能會先於指令(1)執行。
那麽當指令按照(2)-> (3) -> (4) -> (1) 順序,b = 0 +1 = 1

3.還有一種情況
對於B線程,處理器可能會提前處理 (4),將結果放到 ROB中,如果控制語句(3)為真,就將結果從ROB取出來直接使用,這是一種優化技術,預測。
所以指令執行順序可能是 (4) -> x -> x ->x

看來4條語句都有可能最先被執行。

總結一下,在多處理器環境中,由於每個處理器都有自己的讀寫緩存區,所以會使部分數據不一致。JMM會有一系列 action 保證數據一致性,但是在多線程環境下,還是會有很多詭異的問題發生,這個時候就要考慮處理器,編譯器重排序。

三、知識點總結

1,指令重排序

大多數現代微處理器都會采用將指令亂序執行(out-of-order execution,簡稱OoOE或OOE)的方法,
在條件允許的情況下,直接運行當前有能力立即執行的後續指令,避開獲取下一條指令所需數據時造成的等待。
通過亂序執行的技術,處理器可以大大提高執行效率。 除了處理器,常見的Java運行時環境的JIT編譯器也會做指令重排序操作,即生成的機器指令與字節碼指令順序不一致。

2,as-if-serial語義

As-if-serial語義的意思是,所有的動作(Action)都可以為了優化而被重排序,但是必須保證它們重排序後的結果和程序代碼本身的應有結果是一致的。
Java編譯器、運行時和處理器都會保證單線程下的as-if-serial語義。

ps:即指令好像是連續的,是對這種執行效果特性的一個說法。

為了保證這一語義,重排序不會發生在有數據依賴的操作之中

3,內存訪問重排序與內存可見性

技術分享圖片
計算機系統中,為了盡可能地避免處理器訪問主內存的時間開銷,處理器大多會利用緩存(cache)以提高性能。
即緩存中的數據與主內存的數據並不是實時同步的,各CPU(或CPU核心)間緩存的數據也不是實時同步的。
這導致在同一個時間點,各CPU所看到同一內存地址的數據的值可能是不一致的。

從程序的視角來看,就是在同一個時間點,各個線程所看到的共享變量的值可能是不一致的。

有的觀點會將這種現象也視為重排序的一種,命名為“內存系統重排序”。
因為這種內存可見性問題造成的結果就好像是內存訪問指令發生了重排序一樣。
(執行了卻不知道執行了和以為執行了卻重排序沒有執行造成相同效果)
技術分享圖片

4,內存訪問重排序與Java內存模型

Java的目標是成為一門平臺無關性的語言,即Write once, run anywhere. 但是不同硬件環境下指令重排序的規則不盡相同。
例如,x86下運行正常的Java程序在IA64下就可能得到非預期的運行結果。

為此,JSR-1337制定了Java內存模型(Java Memory Model, JMM),旨在提供一個統一的可參考的規範,屏蔽平臺差異性。

從Java 5開始,Java內存模型成為Java語言規範的一部分。

根據Java內存模型中的規定,可以總結出以下幾條happens-before規則。

(ps:內存模型即通過運行環境把一些可見性和重排序問題統一成一個標準描述)

Happens-before的前後兩個操作不會被重排序且後者對前者的內存可見。

技術分享圖片
程序次序法則:     線程中的每個動作A都happens-before於該線程中的每一個動作B,其中,在程序中,所有的動作B都能出現在A之後。
監視器鎖法則:     對一個監視器鎖的解鎖 happens-before於每一個後續對同一監視器鎖的加鎖。
volatile變量法則:對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。
線程啟動法則:     在一個線程裏,對Thread.start的調用會happens-before於每個啟動線程的動作。
線程終結法則:線程中的任何動作都happens-before於其他線程檢測到這個線程已經終結、或者從Thread.join調用中成功返回,或Thread.isAlive返回false。
中斷法則:        一個線程調用另一個線程的interrupt happens-before於被中斷的線程發現中斷。
終結法則:        一個對象的構造函數的結束happens-before於這個對象finalizer的開始。
傳遞性:          如果A happens-before於B,且B happens-before於C,則A happens-before於C
技術分享圖片

Happens-before關系只是對Java內存模型的一種近似性的描述,它並不夠嚴謹,但便於日常程序開發參考使用,

關於更嚴謹的Java內存模型的定義和描述,請閱讀JSR-133原文或Java語言規範章節17.4。

除此之外,Java內存模型對volatile和final的語義做了擴展。

對volatile語義的擴展保證了volatile變量在一些情況下不會重排序,volatile的64位變量double和long的讀取和賦值操作都是原子的。
對final語義的擴展保證一個對象的構建方法結束前,所有final成員變量都必須完成初始化(前提是沒有this引用溢出)。

(ps:沒有理解final的意思)

Java內存模型關於重排序的規定,總結後如下表所示。(ps:下表沒看懂)

技術分享圖片

5,內存屏障

內存屏障(Memory Barrier,或有時叫做內存柵欄,Memory Fence)是一種CPU指令,用於控制特定條件下的重排序和內存可見性問題

Java編譯器也會根據內存屏障的規則禁止重排序。

內存屏障可以被分為以下幾種類型:

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

有的處理器的重排序規則較嚴,無需內存屏障也能很好的工作,Java編譯器會在這種情況下不放置內存屏障。
為了實現上一章中討論的JSR-133的規定,Java編譯器會這樣使用內存屏障。(ps:下表沒看懂)

技術分享圖片

四、案例參考

https://blog.csdn.net/qq_32646795/article/details/78221064

Java並發編程原理與實戰四十一:重排序 和 happens-before