乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

今日分享開始啦,請大家多多指教~

本篇文章是給大家研究一下重排序與記憶體一致性和volatile的記憶體語義,正文開始啦~

乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

happens-before

happens-before是一種關係,在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係,注意,這裡的兩個操作既可以是不同執行緒,也可以是同一個執行緒。

那happens-before有什麼規則了

程式順序規則:一個執行緒中的每個操作,該執行緒中的任意後續動作都必須可以看到前面操作的結果,所以happens-before於該執行緒的任意後續動作。

監視器鎖規則:當一個鎖解鎖後,後面的加鎖動作都要可以看到解鎖動作,所以happens-before於隨後對這個鎖的加鎖。

volatile變數規則:volatile實現了變數的執行緒可見性,所以對這個變數的操作都要被後續可見,所以happens-before於任意後續對這個volatile域的讀。

傳遞性:如果B可見A,即A可以happens-before於B,如果此時,C又可見B,即B可以happens-before於C,那麼對於A和C,A可以happens-before於C。

其實happens-before只是一個規則,抽象了JMM提供的記憶體可見性而已,也就是不用去認識透徹前面提到過的各種重排序,而happens-before的實現其實也就是JMM禁止了各種重排序。

重排序

重排序是指:編譯器和處理器為了最佳化程式效能而對指令序列進行重新排序的一種手段,關鍵在於是為了最佳化效能,而且要注意,每個執行緒都會發生重排序,因為處理器每次執行都只是執行一個執行緒。

資料依賴性

資料依賴性是指:有兩個操作訪問同一個變數,並且這些操作至少有一個為寫操作,那麼此時這兩個操作就存在資料依賴性,也因此資料依賴性根據兩個操作的順序會分為三種:

寫後讀:寫入一個變數之後,再進行讀取;

讀後寫:讀一個變數之後,再進行寫入,注意這裡是寫入而不是修改,比如,a = b;b = 1就是一個讀後寫;

寫後寫:寫一個變數之後,再重新進行寫入。

上面三種資料依賴性,只要發生操作的重排序,程式的執行結果都會被改變,而前面已經提到過,編譯器和處理器是會對操作進行重排序的,所以為了防止執行結果發生改變,編譯器和處理器要辨別出操作是否存在資料依賴性。

如果存在資料依賴性是不會進行重排序的,但這種自動禁止重排序操作僅僅出現在單執行緒和單處理器,也就是僅僅只會考慮單執行緒和單處理器的資料依賴性,對於不同執行緒和不同處理器之間的資料依賴性是不會被考慮的。

as-if-serial語義

as-if-serial語義是指:不管怎麼進行重排序,程式的執行結果都不能改變,當然這也只是針對單執行緒,也就是單執行緒的執行結果都不能改變,不保證多執行緒是否發生了改變。

舉個例子:

double pi = 3。14; //A操作

double r = 1; //B操作

double area = pi * r * r; //C操作

在上面的三個操作,產生資料依賴性的有A與C、B與C,而且產生的都是寫後寫資料依賴性,那麼A與B是沒有資料依賴性的,這兩個操作發生重排序是不會違反as-if-serial語義,所以這兩個操作允許發生重排序,但是C操作就不可以隨便發生重排序了,必須要滿足A-happensbefore-C與B-happensbefore-C。

總的來說,as-if-serial語義是將單執行緒程式保護了起來,不用去考慮重排序導致的問題,讓開發者可以認為程式就是按順序執行的,重排序不會干擾。

as-if-serial也允許對存在控制依賴的操作進行重排序。

控制依賴就是指:邏輯判斷操作,即if那些判斷語句,那些判斷語句也是一個操作,具體來說就是,允許先執行if裡面的程式碼塊,然後再判斷if的條件是否為True或者False。

因為控制依賴會影響指令序列執行的並行度,本可以執行多個命令的,偏偏要先去執行判斷命令,等判斷完再去執行其他命令,這會降低了指令序列的並行度,所以乾脆就一起並行執行,判斷條件後再考慮結果是否保留即可,即允許發生重排序。

重排序對多執行緒的影響

重排序是針對單執行緒進行的,單執行緒發生重排序是沒有任何問題的,因為有著as-if-serial語義的保證,但是多執行緒各自執行緒發生重排序,組合起來就會產生多執行緒的語義錯誤,把程式的執行結果給改變。

假如A執行緒修改了一個flag變數,而B執行緒去獲取這個flag變數,那麼由於A的重排序,將修改flag變數的操作提前或者延後了,B執行緒獲取的flag變數可能為修改前的,也可能為修改後的。

順序一致性

程式一致性是用來形容多執行緒同步執行的,規則如下

一個執行緒中的所有操作必須按照程式的順序來執行;

所有執行緒都只能看到一個單一的操作執行順序,不管是同步還是不同步,每個操作都必須是原子執行且立刻對所有執行緒可見。

舉個例子:

有一個執行緒A,擁有三個操作,A1、A2、A3;另外一個執行緒B,也有三個操作,B1、B2、B3。

那麼在同步的時候,這2個執行緒共6個操作的執行順序如下所示(假設A執行緒先執行)

乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

可以看見,每個執行緒的三個操作都必須是按順序執行的。

下面是不同步的時候,這2個執行緒共6個操作的執行順序可能會有多種,下面只是其中一種情況

乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

可以看到,即使是不同步的情況下,雖然整體上是無序的,但順序一致性保證每個執行緒裡面的操作是順序執行的。

實現順序一致性的前提保證是每個操作必須立即對任意執行緒可見,就這樣就可以後面的操作不會受影響,可以立即執行。

但在JMM中,並不能實現順序一致性,每個操作不是立即對任意執行緒可見的,前面提到過,每個執行緒都有自己的快取,操作是先對快取操作,然後再對主存操作的,所以對於不同步的多執行緒來說,不但整體的執行順序是亂序的,而且所有執行緒看到的操作執行順序也可能不一致,因為可能會發生重排序;如果是同步的話,也可能不是一致的,因為重排序,不過由於as-if-serial語義,外界可以視為順序一致的。

下面就來分析一下JMM同步和不同步情況下與順序一致性的區別:

同步程式

在順序一致性中,所有操作完全按程式的順序序列執行的,而在JMM中,對於臨界區的程式碼是可能會發生重排序的,具體一點就是加鎖的程式碼會發生重排序。

這種重排序可以提高執行效率,而且沒有改變執行的結果。

總的來說,JMM在不改變同步程式執行結果的前提下,會盡可能地使用編譯器和處理器的最佳化。

不同步程式

而對於不同步的程式,JMM只會提供最小的安全性,只會保證讀出來的值不會無中生有,讀取的值要麼是前面執行緒寫入的值,要麼就是預設值(0,False,Null)。

而這個最小的安全性是由JVM在物件記憶體分配上實現的,在堆上分配記憶體的時候,首先會對分配的記憶體進行清空,然後才在上面分配物件(這兩個操作是原子的),在分配物件時,就是預設值了。

從效能上考慮,為了不禁止大量的處理器和編譯器的最佳化,所以JMM不支援程式一致性,而且未同步程式不僅整體上無序,個別執行緒裡面也是無序的(與同步程式一樣)。

volatile可見性實驗

乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

我這裡開了兩個執行緒,後面的執行緒去修改volatile變數,前面的執行緒不斷獲取volatile變數,

結果是會一致卡在死迴圈,控制檯沒有任何輸出。

假如將flag讓volatile來進行修飾

乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

結果是:三秒後,就不會不斷打印出資訊出來。

注意,Thread。sleep是會重新整理執行緒記憶體的,所以不要使用Thread。sleep來分別讓一個執行緒獲取兩次volatile變數。

volatile的特性

volatile其實相當於對變數的單詞讀或寫操作加了鎖、做了同步。

由於是加了鎖,所以就有前面提到的鎖的語義,即鎖的happens-before,鎖的happens-before規定了釋放鎖的操作對於後續獲得鎖操作是可見的,所以釋放鎖的執行緒對於後續獲得鎖的執行緒是可見的,意味著volatile修飾的變數的最後寫入是可以被後面獲得鎖的執行緒讀取的。

32位的作業系統去操作64位的變數時,會分成高32位和低32位去執行,但由於鎖,會導致這個操作也是具有原子性的,因為鎖的語義決定了臨界區程式碼的執行具有原子性,即必須要整個程式碼塊執行完,如果沒有鎖,那麼就不是原子性的,可能會被分成不連續的兩步來執行。

所以,volatile變數自身是具有下面特性的

原子性:無論多大的變數,對其單詞讀或寫操作都是具有原子性的,但如果類似於i++這種操作就不具備原子性了,因為這本來就是兩條命令。

可見性:操作volatile變數的執行緒是可以獲取前一個執行緒對其的修改,即當前執行緒總是可以看到volatile變數最後的寫入。

volatile 寫與讀的記憶體語義

我們先來研究一下什麼依賴關係需要volatile

前面提到過總共有三種依賴關係

讀後寫

寫後讀

寫後寫

volatile是實現可見性的,所以寫後寫就不用考慮了,而且讀後寫是不需要可見性的,所以需要可見性的是寫後讀。

寫語義

volatile寫的記憶體語義如下:

當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體(即不僅修改了本地記憶體,而且還重新整理到了主記憶體),注意,這個重新整理是按快取行的形式(64位元組)。

兩個執行緒,A執行緒修改flag與A,flag與A原本為預設值

乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

所以volatile的寫是有兩個操作的,然後這兩個操作會合成一個原子操作。

讀語義

volatile的讀記憶體語義為:當讀一個volatile變數時,JVM會把執行緒對應的本地記憶體置為無效,接下來重新去主記憶體中讀取共享變數,並且更新本地記憶體,注意:是讀的時候會置為無效,假如不讀就不會置為無效然後重新獲取。

還是上面的例子,不過多了一個執行緒B,執行緒B一開始讀的是預設值,後來再進行了一次讀取。

乾貨推薦!阿里p7大佬由淺入深講解Java併發,看完大廠面試穩了

讀寫語義

讀寫語義對應的其實就是volatile的變數修飾後,會進行怎樣的過程。

其實volatile的讀寫語義,就是執行緒之間的通訊,所以volatile也是實現了執行緒之間的通訊,來提供可見性。

執行緒A去寫volatile變數,實質上是執行緒A對其他要操控該volatile變數的其他執行緒發出了訊息,該訊息表明了執行緒A已經把該變數修改了,其他執行緒需要重新去獲取。

執行緒B去讀volatile變數時,實質上是執行緒B接收到了之前某個執行緒發出的訊息(可能沒有訊息,不過也認為接收到),知道這個變數改了,需要去重新獲取。

所以A寫B讀,就實現了兩個執行緒之間的通訊,雖然不太嚴謹,因為可能A不寫,B也要讀。

volatile的實現

前面已經提到過volatile的實現,位元組碼上加了acc_volatile修飾符,然後指令層面上是使用了記憶體屏障,下面就來再詳細研究。

volatile的記憶體語義實現

volatile還有一個功能就是可以防止命令重排序,也就是volatile的記憶體語義。

為了實現volatile記憶體語義,JMM會限制重排序,因為重排序會讓語義出現變化,也就是會打斷與別的執行緒的通訊,前面提到過,重排序總共有三種,而JMM會限制編譯器重排序與處理器重排序,並不會限制記憶體重排序。

單純看錶,很難去辨別為什麼,所以下面只看不發生重排序的部分。

當第二個操作是volatile寫時,無論第一個操作是什麼,都不能發生重排序,保證了volatile寫之前的操作不會被重排序到寫後面。

當第一個操作是volatile讀的時候,無論第二個操作是什麼,都不能發生重排序,保證了volatile讀之後的操作不會被重排序到讀之前。

當第一個操作為volatile寫的時候,且第二個操作是volatile讀的時候,是不可以發生重排序。

第三個比較容易理解,因為volatile寫會影響後面volatile讀的嘛,先寫後讀跟

讀後寫是完全不一樣的,所以兩次操作分別為volatile讀和volatile寫或volatile寫和volatile讀都是不允許重排序的。

關鍵在於前兩條怎麼理解

其實都是因為volatile的讀語義,每次volatile讀都會使快取行失效,需要去重新獲取快取行,快取行中不僅有volatile變數,還有其他共享變數。

現在回到第二條

當第一個操作為volatile讀的時候,後面也是普通讀,重排序是沒有問題,但如果後面是普通寫,普通寫後續可能是會重新整理進主存中的,此時volatile讀是會出現問題的。

當第一個操作為volatile讀的時候,第二個操作也為volatile讀的時候,會形成兩次新的快取行,而每次快取行相同變數對應的值都可能不一樣,此時如果發生重排序,就會出現不一致,比如,不發生重排序時,從第一次新的快取行裡面讀A,從第二次新的快取行裡面讀B,發生了重排序後,就是從第一次新的快取行裡面讀B2,從第二次新的快取行裡面讀A2,B與B2是不一樣的,A於A2也是不一樣的,所以不可以重排序。

現在回到第一條

當第一個操作為volatile寫的時候,會直接修改主存,影響後面的volatile讀,所以對於第二個操作為volatile讀是不可以重排序的。

當第一個操作為volatile寫的時候,會直接修改主存,是會對其他執行緒造成影響的,同時重排序的話,會造成結果不一致,所以也不可以重排序volatile寫。

當第一個操作為volatile寫的時候,可以普通讀,但不可以普通寫,因為普通寫後面也會更新到主存中去,重排序也是會導致結果不一致的。

接下來關於不需要重排序的

普通讀寫和普通讀寫之前沒有volatile要求,所以可以重排序,當然這會導致併發問題。

普通讀寫和volatile讀之間,只有一個volatile讀要求,這個讀要求不會被普通讀寫影響,所以也是可以重排序,不過對於普通讀寫部分會產生併發問題。

為了實現記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序,也就是上面提到的限制重排序的型別,對於執行效率來說,屏障數越少越好,但讓JMM去動態發現最優的屏障佈置是不可能的,所以採用了保守策略的JMM記憶體屏障和插入策略。

在每一個volatile寫操作的前面插入一個StoreStore屏障,保證了在volatile寫操作之前,上面的所有寫操作已經執行完成,並且都重新整理到主存中。

在每一個volatile寫操作的後面插入一個StoreLoad屏障,保證了必須執行完volatile寫操作,下面的讀操作才可以執行。

在每一個volatile讀操作的後面插入一個LoadLoad屏障,保證了在volatile讀之前,上面的所有讀操作都要完成。

在每一個volatile讀操作的後面插入一個LoadStore屏障,保證了下面的寫操作,必須要等待volatile讀操作完成才可以繼續。

由於第一次操作為普通讀,第二次操作為volatile讀是允許發生重排序的,所以volatile讀前面不需要加記憶體屏障。

今日份分享已結束,請大家多多包涵和指點!

相關文章