
文章整理自 博學谷狂野架構師
重排序
資料依賴性
如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性,資料依賴分下列三種型別:
| 名稱 | 代碼示例 | 說明 |
|---|---|---|
| 寫后讀 | a = 1;b = a; | 寫一個變數之后,再讀這個位置, |
| 寫后寫 | a = 1;a = 2; | 寫一個變數之后,再寫這個變數, |
| 讀后寫 | a = b;b = 1; | 讀一個變數之后,再寫這個變數, |
上面三種情況,只要重排序兩個操作的執行順序,程式的執行結果將會被改變,
前面提到過,編譯器和處理器可能會對操作做重排序,編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關系的兩個操作的執行順序,
注意,這里所說的資料依賴性僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同處理器之間和不同執行緒之間的資料依賴性不被編譯器和處理器考慮,
as-if-serial語意
as-if-serial語意的意思指:不管怎么重排序(編譯器和處理器為了提高并行度),(單執行緒)程式的執行結果不能被改變,編譯器,runtime 和處理器都必須遵守as-if-serial語意,
為了遵守as-if-serial語意,編譯器和處理器不會對存在資料依賴關系的操作做重排序,因為這種重排序會改變執行結果,但是,如果操作之間不存在資料依賴關系,這些操作可能被編譯器和處理器重排序,為了具體說明,請看下面計算圓面積的代碼示例:
COPYdouble pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三個操作的資料依賴關系如下圖所示:

如上圖所示,A和C之間存在資料依賴關系,同時B和C之間也存在資料依賴關系,因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程式的結果將會被改變),但A和B之間沒有資料依賴關系,編譯器和處理器可以重排序A和B之間的執行順序,下圖是該程式的兩種執行順序:

as-if-serial語意把單執行緒程式保護了起來,遵守as-if-serial語意的編譯器,runtime 和處理器共同為撰寫單執行緒程式的程式員創建了一個幻覺:單執行緒程式是按程式的順序來執行的,as-if-serial語意使單執行緒程式員無需擔心重排序會干擾他們,也無需擔心記憶體可見性問題,
程式順序規則
根據happens- before的程式順序規則,上面計算圓的面積的示例代碼存在三個happens- before關系:
COPYA happens- before B;
B happens- before C;
A happens- before C;
這里的第3個happens- before關系,是根據happens- before的傳遞性推匯出來的,
這里A happens- before B,但實際執行時B卻可以排在A之前執行(看上面的重排序后的執行順序),如果A happens- before B,JMM并不要求A一定要在B之前執行,JMM僅僅要求前一個操作(執行的結果)對后一個操作可見,且前一個操作按順序排在第二個操作之前,這里操作A的執行結果不需要對操作B可見;而且重排序操作A和操作B后的執行結果,與操作A和操作B按happens- before順序執行的結果一致,在這種情況下,JMM會認為這種重排序并不非法(not illegal),JMM允許這種重排序,
在計算機中,軟體技術和硬體技術有一個共同的目標:在不改變程式執行結果的前提下,盡可能的開發并行度,編譯器和處理器遵從這一目標,從happens- before的定義我們可以看出,JMM同樣遵從這一目標,
重排序對多執行緒的影響
現在讓我們來看看,重排序是否會改變多執行緒程式的執行結果,請看下面的示例代碼:
COPYclass ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
flag變數是個標記,用來標識變數a是否已被寫入,這里假設有兩個執行緒A和B,A首先執行writer()方法,隨后B執行緒接著執行reader()方法,執行緒B在執行操作4時,能否看到執行緒A在操作1對共享變數a的寫入?
答案是:不一定能看到,
由于操作1和操作2沒有資料依賴關系,編譯器和處理器可以對這兩個操作重排序;同樣,操作3和操作4沒有資料依賴關系,編譯器和處理器也可以對這兩個操作重排序,讓我們先來看看,當操作1和操作2重排序時,可能會產生什么效果?請看下面的程式執行時序圖:

如上圖所示,操作1和操作2做了重排序,程式執行時,執行緒A首先寫標記變數flag,隨后執行緒B讀這個變數,由于條件判斷為真,執行緒B將讀取變數a,此時,變數a還根本沒有被執行緒A寫入,在這里多執行緒程式的語意被重排序破壞了!
注:本文統一用紅色的虛箭線表示錯誤的讀操作,用綠色的虛箭線表示正確的讀操作,
下面再讓我們看看,當操作3和操作4重排序時會產生什么效果(借助這個重排序,可以順便說明控制依賴性),
下面是操作3和操作4重排序后,程式的執行時序圖:

在程式中,操作3和操作4存在控制依賴關系,當代碼中存在控制依賴性時,會影響指令序列執行的并行度,為此,編譯器和處理器會采用猜測(Speculation)執行來克服控制相關性對并行度的影響,以處理器的猜測執行為例,執行執行緒B的處理器可以提前讀取并計算a*a,然后把計算結果臨時保存到一個名為重排序緩沖(reorder buffer ROB)的硬體快取中,當接下來操作3的條件判斷為真時,就把該計算結果寫入變數i中,
從圖中我們可以看出,猜測執行實質上對操作3和4做了重排序,重排序在這里破壞了多執行緒程式的語意!
在單執行緒程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語意允許對存在控制依賴的操作做重排序的原因);但在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果,
順序一致性
資料競爭與順序一致性保證
當程式未正確同步時,就會存在資料競爭,java記憶體模型規范對資料競爭的定義如下:
- 在一個執行緒中寫一個變數,
- 在另一個執行緒讀同一個變數,
- 而且寫和讀沒有通過同步來排序,
當代碼中包含資料競爭時,程式的執行往往產生違反直覺的結果,如果一個多執行緒程式能正確同步,這個程式將是一個沒有資料競爭的程式,
JMM對正確同步的多執行緒程式的記憶體一致性做了如下保證:
如果程式是正確同步的,程式的執行將具有順序一致性(sequentially consistent)–即程式的執行結果與該程式在順序一致性記憶體模型中的執行結果相同(馬上我們將會看到,這對于程式員來說是一個極強的保證),這里的同步是指廣義上的同步,包括對常用同步原語(lock,volatile和final)的正確使用,
順序一致性記憶體模型
順序一致性記憶體模型是一個被計算機科學家理想化了的理論參考模型,它為程式員提供了極強的記憶體可見性保證,順序一致性記憶體模型有兩大特性:
- 一個執行緒中的所有操作必須按照程式的順序來執行,
- (不管程式是否同步)所有線程都只能看到一個單一的操作執行順序,在順序一致性記憶體模型中,每個操作都必須原子執行且立刻對所有執行緒可見,
順序一致性記憶體模型為程式員提供的視圖如下:

在概念上,順序一致性模型有一個單一的全域記憶體,這個記憶體通過一個左右擺動的開關可以連接到任意一個執行緒,同時,每一個執行緒必須按程式的順序來執行記憶體讀/寫操作,從上圖我們可以看出,在任意時間點最多只能有一個執行緒可以連接到記憶體,當多個執行緒并發執行時,圖中的開關裝置能把所有執行緒的所有記憶體讀/寫操作串行化,
為了更好的理解,下面我們通過兩個示意圖來對順序一致性模型的特性做進一步的說明,
假設有兩個執行緒A和B并發執行,其中A執行緒有三個操作,它們在程式中的順序是:A1->A2->A3,B執行緒也有三個操作,它們在程式中的順序是:B1->B2->B3,
假設這兩個執行緒使用監視器來正確同步:A執行緒的三個操作執行后釋放監視器,隨后B執行緒獲取同一個監視器,那么程式在順序一致性模型中的執行效果將如下圖所示:

現在我們再假設這兩個執行緒沒有做同步,下面是這個未同步程式在順序一致性模型中的執行示意圖:

未同步程式在順序一致性模型中雖然整體執行順序是無序的,但所有執行緒都只能看到一個一致的整體執行順序,以上圖為例,執行緒A和B看到的執行順序都是:B1->A1->A2->B2->A3->B3,之所以能得到這個保證是因為順序一致性記憶體模型中的每個操作必須立即對任意執行緒可見,
但是,在JMM中就沒有這個保證,未同步程式在JMM中不但整體的執行順序是無序的,而且所有執行緒看到的操作執行順序也可能不一致,比如,在當前執行緒把寫過的資料快取在本地記憶體中,且還沒有重繪到主記憶體之前,這個寫操作僅對當前執行緒可見;從其他執行緒的角度來觀察,會認為這個寫操作根本還沒有被當前執行緒執行,只有當前執行緒把本地記憶體中寫過的資料重繪到主記憶體之后,這個寫操作才能對其他執行緒可見,在這種情況下,當前執行緒和其它執行緒看到的操作執行順序將不一致,
同步程式的順序一致性效果
下面我們對前面的示例程式ReorderExample用監視器來同步,看看正確同步的程式如何具有順序一致性,
請看下面的示例代碼:
COPYclass SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() {
a = 1;
flag = true;
}
public synchronized void reader() {
if (flag) {
int i = a;
……
}
}
}
上面示例代碼中,假設A執行緒執行writer()方法后,B執行緒執行reader()方法,這是一個正確同步的多執行緒程式,根據JMM規范,該程式的執行結果將與該程式在順序一致性模型中的執行結果相同,下面是該程式在兩個記憶體模型中的執行時序對比圖:

在順序一致性模型中,所有操作完全按程式的順序串行執行,而在JMM中,臨界區內的代碼可以重排序(但JMM不允許臨界區內的代碼“逸出”到臨界區之外,那樣會破壞監視器的語意),JMM會在退出監視器和進入監視器這兩個關鍵時間點做一些特別處理,使得執行緒在這兩個時間點具有與順序一致性模型相同的記憶體視圖(具體細節后文會說明),雖然執行緒A在臨界區內做了重排序,但由于監視器的互斥執行的特性,這里的執行緒B根本無法“觀察”到執行緒A在臨界區內的重排序,這種重排序既提高了執行效率,又沒有改變程式的執行結果,
從這里我們可以看到JMM在具體實作上的基本方針:在不改變(正確同步的)程式執行結果的前提下,盡可能的為編譯器和處理器的優化打開方便之門,
未同步程式的執行特性
對于未同步或未正確同步的多執行緒程式,JMM只提供最小安全性:執行緒執行時讀取到的值,要么是之前某個執行緒寫入的值,要么是默認值(0,null,false),JMM保證執行緒讀操作讀取到的值不會無中生有(out of thin air)的冒出來,為了實作最小安全性,JVM在堆上分配物件時,首先會清零記憶體空間,然后才會在上面分配物件(JVM內部會同步這兩個操作),因此,在以清零的記憶體空間(pre-zeroed memory)分配物件時,域的默認初始化已經完成了,
JMM不保證未同步程式的執行結果與該程式在順序一致性模型中的執行結果一致,因為未同步程式在順序一致性模型中執行時,整體上是無序的,其執行結果無法預知,保證未同步程式在兩個模型中的執行結果一致毫無意義,
和順序一致性模型一樣,未同步程式在JMM中的執行時,整體上也是無序的,其執行結果也無法預知,同時,未同步程式在這兩個模型中的執行特性有下面幾個差異:
- 順序一致性模型保證單執行緒內的操作會按程式的順序執行,而JMM不保證單執行緒內的操作會按程式的順序執行(比如上面正確同步的多執行緒程式在臨界區內的重排序),這一點前面已經講過了,這里就不再贅述,
- 順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序,這一點前面也已經講過,這里就不再贅述,
- JMM不保證對64位的long型和double型變數的讀/寫操作具有原子性,而順序一致性模型保證對所有的記憶體讀/寫操作都具有原子性,
第3個差異與處理器總線的作業機制密切相關,在計算機中,資料通過總線在處理器和記憶體之間傳遞,每次處理器和記憶體之間的資料傳遞都是通過一系列步驟來完成的,這一系列步驟稱之為總線事務(bus transaction),總線事務包括讀事務(read transaction)和寫事務(write transaction),讀事務從記憶體傳送資料到處理器,寫事務從處理器傳送資料到記憶體,每個事務會讀/寫記憶體中一個或多個物理上連續的字,這里的關鍵是,總線會同步試圖并發使用總線的事務,在一個處理器執行總線事務期間,總線會禁止其它所有的處理器和I/O設備執行記憶體的讀/寫,下面讓我們通過一個示意圖來說明總線的作業機制:

如上圖所示,假設處理器A,B和C同時向總線發起總線事務,這時總線仲裁(bus arbitration)會對競爭作出裁決,這里我們假設總線在仲裁后判定處理器A在競爭中獲勝(總線仲裁會確保所有處理器都能公平的訪問記憶體),此時處理器A繼續它的總線事務,而其它兩個處理器則要等待處理器A的總線事務完成后才能開始再次執行記憶體訪問,假設在處理器A執行總線事務期間(不管這個總線事務是讀事務還是寫事務),處理器D向總線發起了總線事務,此時處理器D的這個請求會被總線禁止,
總線的這些作業機制可以把所有處理器對記憶體的訪問以串行化的方式來執行;在任意時間點,最多只能有一個處理器能訪問記憶體,這個特性確保了單個總線事務之中的記憶體讀/寫操作具有原子性,
在一些32位的處理器上,如果要求對64位資料的寫操作具有原子性,會有比較大的開銷,為了照顧這種處理器,java語言規范鼓勵但不強求JVM對64位的long型變數和double型變數的寫具有原子性,當JVM在這種處理器上運行時,會把一個64位long/ double型變數的寫操作拆分為兩個32位的寫操作來執行,這兩個32位的寫操作可能會被分配到不同的總線事務中執行,此時對這個64位變數的寫將不具有原子性,
當單個記憶體操作不具有原子性,將可能會產生意想不到后果,請看下面示意圖:

如上圖所示,假設處理器A寫一個long型變數,同時處理器B要讀這個long型變數,處理器A中64位的寫操作被拆分為兩個32位的寫操作,且這兩個32位的寫操作被分配到不同的寫事務中執行,同時處理器B中64位的讀操作被分配到單個的讀事務中執行,當處理器A和B按上圖的時序來執行時,處理器B將看到僅僅被處理器A“寫了一半“的無效值,
注意,在JSR -133之前的舊記憶體模型中,一個64位long/ double型變數的讀/寫操作可以被拆分為兩個32位的讀/寫操作來執行,從JSR -133記憶體模型開始(即從JDK5開始),僅僅只允許把一個64位long/ double型變數的寫操作拆分為兩個32位的寫操作來執行,任意的讀操作在JSR -133中都必須具有原子性(即任意讀操作必須要在單個讀事務中執行),
本文由
傳智教育博學谷狂野架構師教研團隊發布,如果本文對您有幫助,歡迎
關注和點贊;如果您有任何建議也可留言評論或私信,您的支持是我堅持創作的動力,轉載請注明出處!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/544679.html
標籤:Java
