1. 程式人生 > >Java記憶體模型-Java記憶體模型中的順序一致性

Java記憶體模型-Java記憶體模型中的順序一致性

         如果程式是正確同步的,程式的執行將具有順序一致性(Sequentially Consistent)——即程式的執行結果與該程式在順序一致性記憶體模型中的執行結果相同。

         順序一致性記憶體模型有兩大特性。

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

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

         順序一致性模型有一個單一的全域性記憶體,這個記憶體通過一個左右擺動的開關可以連線到任意一個執行緒,同時每一個執行緒必須按照程式的順序來執行記憶體讀/寫操作。從上面的示意圖可以看出,在任意時間點最多隻能有一個執行緒可以連線到記憶體。當多個執行緒併發執行時,圖中的開關裝置能把所有執行緒的所有記憶體讀/寫操作序列化(即在順序一致性模型中,所有操作之間具有全序關係)。

 

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

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

 

未同步程式的執行特性

         對於未同步或未正確同步的多執行緒程式,JMM只提供最小安全性:執行緒執行時讀取到的值,要麼是之前某個執行緒寫入的值,要麼是預設值(0,Null,False),JMM保證執行緒讀操作讀取到的值不會無中生有(Out Of Thin Air)的冒出來。為了實現最小安全性,JVM在堆上分配物件時,首先會對記憶體空間進行清零,然後才會在上面分配物件(JVM內部會同步這兩個操作)。因此,在已清零的記憶體空間(Pre-zeroed Memory)分配物件時,域的預設初始化已經完成了。

         JMM不保證未同步程式的執行結果與該程式在順序一致性模型中的執行結果一致。因為如果想要保證執行結果一致,JMM需要禁止大量的處理器和編譯器的優化,這對程式的執行效能會產生很大的影響。而且未同步程式在順序一致性模型中執行時,整體是無序的,其執行結果往往無法預知。而且,保證未同步程式在這兩個模型中的執行結果一致沒什麼意義

         未同步程式在JMM中的執行時,整體上是無序的,其執行結果無法預知。未同步程式在兩個模型中的執行特性有如下幾個差異。

         1)順序一致性模型保證單執行緒內的操作會按程式的順序執行,而JMM不保證單執行緒內的操作會按程式的順序執行(比如上面正確同步的多執行緒程式在臨界區內的重排序)。這一點前面已經講過了,這裡就不再贅述。

         2)順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序。這一點前面也已經講過,這裡就不再贅述。

         3)JMM不保證對64位的long型和double型變數的寫操作具有原子性,而順序一致性模型保證對所有的記憶體讀/寫操作都具有原子性。

volatile的記憶體語義

         volatile變數自身具有下列特性。

         可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。

         原子性:對任意單個volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

         1.volatile寫-讀建立的happens-before關係

                  volatile的寫-讀與鎖的釋放-獲取有相同的記憶體效果:volatile寫和

                  鎖的釋放有相同的記憶體語義;volatile讀與鎖的獲取有相同的記憶體語義。

         2.volatile寫-讀的記憶體語義

                  volatile寫的記憶體語義:當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。

                  volatile讀的記憶體語義:當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

         下面對volatile寫和volatile讀的記憶體語義做個總結。

         執行緒A寫一個volatile變數,實質上是執行緒A向接下來將要讀這個volatile變數的某個執行緒發出了(其對共享變數所做修改的)訊息。

         執行緒B讀一個volatile變數,實質上是執行緒B接收了之前某個執行緒發出的(在寫這個volatile變數之前對共享變數所做修改的)訊息。

         執行緒A寫一個volatile變數,隨後執行緒B讀這個volatile變數,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息。

volatile記憶體語義的實現

                                                                                volatile重排序規則表

 

鎖的記憶體語義

         1.鎖的釋放-獲取建立的happens-before關係

         鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的執行緒向獲取同一個鎖的執行緒傳送訊息。

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

         當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。

         當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數。

         執行緒A釋放一個鎖,實質上是執行緒A向接下來將要獲取這個鎖的某個執行緒發出了(執行緒A對共享變數所做修改的)訊息。

         執行緒B獲取一個鎖,實質上是執行緒B接收了之前某個執行緒發出的(在釋放這個鎖之前對共享變數所做修改的)訊息。

         執行緒A釋放鎖,隨後執行緒B獲取這個鎖,這個過程實質上是執行緒A通過主記憶體向執行緒B傳送訊息。

         3.鎖記憶體語義的實現

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

                  1)利用volatile變數的寫-讀所具有的記憶體語義。

                  2)利用CAS所附帶的volatile讀和volatile寫的記憶體語義。

         4.concurrent包的實現

         通用化的實現模式。

         首先,宣告共享變數為volatile。

         然後,使用CAS的原子條件更新來實現執行緒之間的同步。

         同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的記憶體語義來實現執行緒之間的通訊。

         AQS,非阻塞資料結構和原子變數類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴於這些基礎類來實現的。

                                                                              concurrent包的實現示意圖

final域的記憶體語義

         1.final域的重排序規則

         對於final域,編譯器和處理器要遵守兩個重排序規則。

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

         2)初次讀一個包含final域的物件的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

 

         2.寫final域的重排序規則

         寫final域的重排序規則禁止把final域的寫重排序到建構函式之外。這個規則的實現包含下面2個方面。

         1)JMM禁止編譯器把final域的寫重排序到建構函式之外。

         2)編譯器會在final域的寫之後,建構函式return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到建構函式之外。

         寫final域的重排序規則可以確保:在物件引用為任意執行緒可見之前,物件的final域已經被正確初始化過了,而普通域不具有這個保障。

         3.讀final域的重排序規則

         讀final域的重排序規則是,在一個執行緒中,初次讀物件引用與初次讀該物件包含的final域,JMM禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。

         4.final域為引用型別

         對於引用型別,寫final域的重排序規則對編譯器和處理器增加了如下約束:在建構函式內對一個final引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。

happens-before

         1.JMM的設計

         JMM把happens-before要求禁止的重排序分為了下面兩類

         1)會改變程式執行結果的重排序。

         2)不會改變程式執行結果的重排序。

         JMM對這兩種不同性質的重排序,採取了不同的策略,如下。

         ·對於會改變程式執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。

         ·對於不會改變程式執行結果的重排序,JMM對編譯器和處理器不做要求(JMM允許這種重排序)。

                                                                               JMM的設計示意圖

         2.happens-before的定義

         1)如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

         2)兩個操作之間存在happens-before關係,並不意味著Java平臺的具體實現必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。

         上面的1)是JMM對程式設計師的承諾。從程式設計師的角度來說,可以這樣理解happens-before關係:如果A happens-before B,那麼Java記憶體模型將向程式設計師保證——A操作的結果將對B可見,且A的執行順序排在B之前。注意,這只是Java記憶體模型向程式設計師做出的保證!

         上面的2)是JMM對編譯器和處理器重排序的約束原則。正如前面所言,JMM其實是在遵循一個基本原則:只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。JMM這麼做的原因是:程式設計師對於這兩個操作是否真的被重排序並不關心,程式設計師關心的是程式執行時的語義不能被改變(即執行結果不能被改變)。因此,happens-before關係本質上和as-if-serial語義是一回事。

         ·as-if-serial語義保證單執行緒內程式的執行結果不被改變,happens-before關係保證正確同步的多執行緒程式的執行結果不被改變。

         ·as-if-serial語義給編寫單執行緒程式的程式設計師創造了一個幻境:單執行緒程式是按程式的順序來執行的。happens-before關係給編寫正確同步的多執行緒程式的程式設計師創造了一個幻境:正確同步的多執行緒程式是按happens-before指定的順序來執行的。

         as-if-serial語義和happens-before這麼做的目的,都是為了在不改變程式執行結果的前提下,儘可能地提高程式執行的並行度。

         3.happens-before規則

         1)程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。

         2)監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。

         3)volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。

         4)傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。

         5)start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。

         6)join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。

 

雙重檢查鎖定與延遲初始化

         1.雙重檢查鎖定的由來

         在Java程式中,有時候可能需要推遲一些高開銷的物件初始化操作,並且只有在使用這些物件時才進行初始化。此時,程式設計師可能會採用延遲初始化。

         在早期的JVM中,synchronized(甚至是無競爭的synchronized)存在巨大的效能開銷。因此,人們想出了一個“聰明”的技巧:雙重檢查鎖定(Double-Checked Locking)。人們想通過雙重檢查鎖定來降低同步的開銷。

         2.問題的根源

         多執行緒執行時序圖

 

         由於單執行緒內要遵守intra-thread semantics,從而能保證A執行緒的執行結果不會被改變。但是,當執行緒A和B按圖時序執行時,B執行緒將看到一個還沒有被初始化的物件。

         3.基於volatile的解決方案

         把instance宣告為volatile型就可以實現執行緒安全的延遲初始化。

         這個方案本質上是通過禁止圖3-39中的2和3之間的重排序,來保證執行緒安全的延遲初始化。

         4.基於類初始化的解決方案

         JVM在類的初始化階段(即在Class被載入後,且被執行緒使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個執行緒對同一個類的初始化。

         基於這個特性,可以實現另一種執行緒安全的延遲初始化方案(這個方案被稱之為Initialization On Demand Holder idiom)。

         對於類或介面的初始化,Java語言規範制定了精巧而複雜的類初始化處理過程。Java初始化一個類或介面的處理過程如下:

         第1階段:通過在Class物件上同步(即獲取Class物件的初始化鎖),來控制類或介面的初始化。這個獲取鎖的執行緒會一直等待,直到當前執行緒能夠獲取到這個初始化鎖。假設Class物件當前還沒有被初始化(初始化狀態state,此時被標記為state=noInitialization),且有兩個執行緒A和B試圖同時初始化這個Class物件。下圖是對應的示意圖。

          第2階段:執行緒A執行類的初始化,同時執行緒B在初始化鎖對應的condition上等待。

 

         第3階段:執行緒A設定state=initialized,然後喚醒在condition中等待的所有執行緒。

         第4階段:執行緒B結束類的初始化處理。

         第5階段:執行緒C執行類的初始化的處理。

         通過對比基於volatile的雙重檢查鎖定的方案和基於類初始化的方案,我們會發現基於類初始化的方案的實現程式碼更簡潔。但基於volatile的雙重檢查鎖定的方案有一個額外的優勢:除了可以對靜態欄位實現延遲初始化外,還可以對例項欄位實現延遲初始化。

         欄位延遲初始化降低了初始化類或建立例項的開銷,但增加了訪問被延遲初始化的欄位的開銷。在大多數時候,正常的初始化要優於延遲初始化。如果確實需要對例項欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於volatile的延遲初始化的方案;如果確實需要對靜態欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於類初始化的方案。

 

                                                                                                  摘抄自《Java併發程式設計的藝術》方騰飛  魏鵬 程曉明 著